Restore the stable legacy Unity project
This commit is contained in:
parent
56a156ee87
commit
7006a6b0dd
2070 changed files with 180490 additions and 169303 deletions
44
.gitignore
vendored
44
.gitignore
vendored
|
|
@ -32,10 +32,6 @@
|
|||
# Visual Studio cache directory
|
||||
.vs/
|
||||
|
||||
# Local agent/editor runtime state
|
||||
.brokkr/
|
||||
.codex/
|
||||
|
||||
# Gradle cache directory
|
||||
.gradle/
|
||||
|
||||
|
|
@ -101,39 +97,6 @@ Economy.Server/bin/
|
|||
Economy.Server/obj/
|
||||
Economy.Shared/bin/
|
||||
Economy.Shared/obj/
|
||||
Aetheria.State/bin/
|
||||
Aetheria.State/obj/
|
||||
Aetheria.State.Daemon/bin/
|
||||
Aetheria.State.Daemon/obj/
|
||||
Aetheria.State.Daemon.Smoke/bin/
|
||||
Aetheria.State.Daemon.Smoke/obj/
|
||||
Aetheria.State.Import/bin/
|
||||
Aetheria.State.Import/obj/
|
||||
Aetheria.State.Smoke/bin/
|
||||
Aetheria.State.Smoke/obj/
|
||||
Aetheria.State.Unity/bin/
|
||||
Aetheria.State.Unity/obj/
|
||||
Aetheria.State.Unity.Smoke/bin/
|
||||
Aetheria.State.Unity.Smoke/obj/
|
||||
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
|
||||
*.client-quic-*.pfx
|
||||
|
||||
# JetBrains Rider .idea folder
|
||||
.idea/
|
||||
|
|
@ -143,11 +106,4 @@ Aetheria.State.Daemon/*.log
|
|||
|
||||
Packages/packages-lock.json
|
||||
GameData/PlayerSettings.msgpack
|
||||
GameData/*.cultmesh/
|
||||
GameData/*.cc.records/
|
||||
GameData/*.cc.records.*/
|
||||
GameData/*.cc.eve.pending/
|
||||
GameData/*.cc.before-*
|
||||
GameData/*.cc.daemon.*.cc
|
||||
/UserSettings/
|
||||
Vault/Aetheria/.obsidian/plugins/copilot/data.json
|
||||
|
|
|
|||
Binary file not shown.
81
AGENTS.md
81
AGENTS.md
|
|
@ -1,81 +0,0 @@
|
|||
# Aetheria Agent Instructions
|
||||
|
||||
This repo is mid-migration toward CultMesh-native gameplay state. Treat access
|
||||
simplicity as an architectural requirement, not a nice-to-have.
|
||||
|
||||
## Canonical Typed State
|
||||
|
||||
- Default to one canonical shared typed document for each gameplay state concept.
|
||||
The daemon may own authority over that document, but it must not imply a
|
||||
separate private daemon truth plus a copied client-facing state document.
|
||||
- The shared runtime assembly defines the document. The daemon mutates/publishes
|
||||
it. Unity, Electron, tools, tests, and later runtimes read the same document
|
||||
type through CultMesh.
|
||||
- Client input should feel like grabbing a managed typed state handle and either
|
||||
reading it for display or writing/submitting through it according to authority
|
||||
policy. Prediction, debounce, routing, quorum, reconciliation, and smoothing
|
||||
are CultMesh responsibilities.
|
||||
|
||||
Use projections only when the state shape is intentionally different:
|
||||
|
||||
- hidden-information filtering;
|
||||
- expensive or shared derived aggregation;
|
||||
- viewport/windowed selection;
|
||||
- SoA/native render or physics layout;
|
||||
- lossy UI summaries;
|
||||
- Eve/CultUI surface documents;
|
||||
- compatibility mirrors with a named removal stage.
|
||||
|
||||
"The client needs to see it" is not a projection reason.
|
||||
|
||||
## Access Simplicity
|
||||
|
||||
- A UI-only inline value should be derived from already accessible typed state.
|
||||
- A shared UI read should usually be: define the typed document, then read it
|
||||
with `client.State.Reactive<TDocument>()` or `client.State.Latest<TDocument>()`.
|
||||
- A simulation feature should usually be: define the canonical typed document,
|
||||
let daemon authority mutate/publish it, then read or modify that same managed
|
||||
document from clients according to authority policy.
|
||||
- Add named handles only when type alone cannot identify the state, such as
|
||||
parameterized viewport/detail documents, multiple documents sharing one CLR
|
||||
type, semantic query identity, operation policy, or native view ownership.
|
||||
|
||||
## Heretek Shape
|
||||
|
||||
The facade/projector/adapter/surface-builder chain is heretek now. Do not add a
|
||||
stack of translation layers to recover one typed domain value.
|
||||
|
||||
One adapter at a true boundary is allowed:
|
||||
|
||||
- Unity GameObject presentation;
|
||||
- Eve/CultUI lowering;
|
||||
- legacy import;
|
||||
- persistence;
|
||||
- native view ownership.
|
||||
|
||||
Two or more translation layers in a row mean the code is missing a CultMesh
|
||||
primitive, generated typed handle, canonical document, query surface, operation
|
||||
handle, state pointer, or native view descriptor.
|
||||
|
||||
## Verifier Discipline
|
||||
|
||||
The verifier should encode the desired architecture, not the current accidental
|
||||
shape. When removing old access paths, add checks that reject their return. Do
|
||||
not write verifier rules that require wrapper/session/facade/projector chains as
|
||||
proof of correctness.
|
||||
|
||||
Good verifier pressure:
|
||||
|
||||
- client code owns `CultMeshReactiveDocument<TDocument>`;
|
||||
- client code reads `.Current`;
|
||||
- client code uses generic typed document access where possible;
|
||||
- hot render paths use named SoA/native views;
|
||||
- operations are typed and authority-aware;
|
||||
- legacy adapters are quarantined and named for deletion.
|
||||
|
||||
Bad verifier pressure:
|
||||
|
||||
- requiring `LatestFoo()`/`ReactiveFoo()` wrappers for single fixed documents;
|
||||
- requiring one-document session wrappers;
|
||||
- requiring facade/projector/adapter/surface-builder chains;
|
||||
- treating renderer-local facade indexes as gameplay-state APIs.
|
||||
BIN
Aetheria RTS.lnk
BIN
Aetheria RTS.lnk
Binary file not shown.
|
|
@ -1,4 +0,0 @@
|
|||
import { EveBrowserProviderHost } from "../node_modules/@gamecult/eve-browser-lowering/dist/index.js";
|
||||
import { mountEveElectronProvider } from "../node_modules/@gamecult/eve-electron/src/eve-electron-renderer.mjs";
|
||||
|
||||
void mountEveElectronProvider({ EveBrowserProviderHost });
|
||||
|
|
@ -1,797 +0,0 @@
|
|||
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));
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import type { CultMeshViewportRequest } from "cultmesh-ts";
|
||||
|
||||
export {
|
||||
AetheriaRtsIpcChannels,
|
||||
AetheriaRtsSchemas,
|
||||
AetheriaRuntimeDaemonCommandKinds,
|
||||
aetheriaRuntimeEveCommandDocumentSlots,
|
||||
createAetheriaRuntimeGameDocuments,
|
||||
createAetheriaRuntimeGameOperationHandles,
|
||||
createAetheriaRuntimeGameQueryHandles,
|
||||
createAetheriaRuntimeGameStatePointers,
|
||||
createAetheriaRuntimeGameVerseHandles,
|
||||
describeAetheriaRuntimeGameLiveFeedSurface,
|
||||
describeAetheriaRuntimeGameQueryHandles,
|
||||
describeAetheriaRuntimeGameSurfaceCatalog,
|
||||
} from "./aetheria-rts-generated-bindings.js";
|
||||
|
||||
export type {
|
||||
AetheriaRuntimeDaemonCommandReceipt,
|
||||
AetheriaRuntimeGameLiveFeedDiagnostic,
|
||||
AetheriaRuntimeGameDocumentResolvers,
|
||||
AetheriaRuntimeGameDocuments,
|
||||
AetheriaRuntimeGameOperationHandles,
|
||||
AetheriaRuntimeGameQueryDiagnostic,
|
||||
AetheriaRuntimeGameStatePointerResolvers,
|
||||
AetheriaRuntimeGameStatePointers,
|
||||
AetheriaRuntimeGameSurfaceCatalogDiagnostic,
|
||||
AetheriaRuntimeGameVerseHandles,
|
||||
AetheriaRuntimeSelectedObjectRequest as SelectedObjectRequest,
|
||||
AetheriaRuntimeSetMoveVectorRequest as RtsSetMoveVectorRequest,
|
||||
AetheriaRuntimeSetTargetRequest as RtsSetTargetRequest,
|
||||
AetheriaRuntimeViewportFeedRequest,
|
||||
AetheriaRuntimeViewportFeedSnapshot,
|
||||
AssetManifestEntry,
|
||||
AssetManifestDocument,
|
||||
AssetRef,
|
||||
AuthorityRuleDocument,
|
||||
AuthorityStatusDocument,
|
||||
BodyView,
|
||||
DaemonHealthDocument,
|
||||
EntityStatus,
|
||||
GravityInfluence,
|
||||
GravityViewportResponse,
|
||||
InventoryItem,
|
||||
InventoryDocument,
|
||||
ObjectsViewportResponse,
|
||||
RenderSplatsViewportResponse,
|
||||
SelectedObjectDocument,
|
||||
StarbridgeBaseStatusDocument,
|
||||
StarbridgeRuntimeRoleDocument,
|
||||
StarbridgeSessionDocument,
|
||||
StarbridgeStationStockItemDocument,
|
||||
StarbridgeWaveForecastDocument,
|
||||
ViewObject,
|
||||
ViewportResponse,
|
||||
} from "./aetheria-rts-generated-bindings.js";
|
||||
|
||||
export type ViewportRequest = CultMeshViewportRequest;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,495 +0,0 @@
|
|||
import {
|
||||
AetheriaRtsSchemas,
|
||||
aetheriaRuntimeAssetManifestDocumentSlots as assetManifestSlots,
|
||||
aetheriaRuntimeAssetManifestEntrySlots as assetManifestEntrySlots,
|
||||
aetheriaRuntimeAssetRefSlots as assetRefSlots,
|
||||
aetheriaRuntimeAuthorityRuleSlots as authorityRuleSlots,
|
||||
aetheriaRuntimeDaemonHealthDocumentSlots as healthSlots,
|
||||
aetheriaRuntimeGameViewportBoundsSlots as viewportBoundsSlots,
|
||||
aetheriaRuntimeGameViewportDocumentSlots as gameViewportSlots,
|
||||
aetheriaRuntimeGameViewportObjectSlots as viewportObjectSlots,
|
||||
aetheriaRuntimeGravityViewportDocumentSlots as gravityViewportSlots,
|
||||
aetheriaRuntimeInventoryDocumentSlots as inventoryDocumentSlots,
|
||||
aetheriaRuntimeObjectsViewportDocumentSlots as objectsViewportSlots,
|
||||
aetheriaRuntimeRenderSplatLayerDefinitionSlots as renderSplatLayerSlots,
|
||||
aetheriaRuntimeRenderSplatsViewportDocumentSlots as renderSplatsViewportSlots,
|
||||
aetheriaRuntimeRenderSplatSoaSlots as renderSplatSoaSlots,
|
||||
aetheriaRuntimeRtsBodyViewSlots as bodyViewSlots,
|
||||
aetheriaRuntimeRtsEntityStatusSlots as viewportStatusSlots,
|
||||
aetheriaRuntimeRtsGravityInfluenceSlots as gravityInfluenceSlots,
|
||||
aetheriaRuntimeRtsInventoryItemSlots as viewportInventoryItemSlots,
|
||||
aetheriaRuntimeSelectedObjectDocumentSlots as selectedObjectDocumentSlots,
|
||||
aetheriaRuntimeStarbridgeBaseStatusSlots as starbridgeBaseSlots,
|
||||
aetheriaRuntimeStarbridgeRuntimeRoleSlots as starbridgeRoleSlots,
|
||||
aetheriaRuntimeStarbridgeSessionSummaryDocumentSlots as starbridgeSummarySlots,
|
||||
aetheriaRuntimeStarbridgeStationStockItemSlots as starbridgeStockSlots,
|
||||
aetheriaRuntimeStarbridgeWaveForecastSlots as starbridgeWaveForecastSlots,
|
||||
aetheriaRuntimeVerseAuthorityPolicyDocumentSlots as authorityPolicySlots,
|
||||
} from "./aetheria-rts-generated-bindings.js";
|
||||
import { cultMeshRectFromBounds, cultMeshViewportRequest } from "cultmesh-ts";
|
||||
import type {
|
||||
AssetManifestDocument,
|
||||
AssetRef,
|
||||
AuthorityStatusDocument,
|
||||
BodyView,
|
||||
DaemonHealthDocument,
|
||||
EntityStatus,
|
||||
GravityViewportResponse,
|
||||
GravityInfluence,
|
||||
InventoryDocument,
|
||||
InventoryItem,
|
||||
ObjectsViewportResponse,
|
||||
RenderSplatsViewportResponse,
|
||||
SelectedObjectDocument,
|
||||
StarbridgeSessionDocument,
|
||||
ViewObject,
|
||||
ViewportRequest,
|
||||
ViewportResponse,
|
||||
} from "./aetheria-rts-bindings.js";
|
||||
|
||||
const missingDaemonRunId = "aetheria.run.unknown";
|
||||
|
||||
export function readSelectedObjectDocument(selectedObjectDocument: unknown): SelectedObjectDocument {
|
||||
const document = arr(selectedObjectDocument);
|
||||
return {
|
||||
schema: str(document[selectedObjectDocumentSlots.schema]) || AetheriaRtsSchemas.selectedObject,
|
||||
frameId: num(document[selectedObjectDocumentSlots.frameId]),
|
||||
runId: str(document[selectedObjectDocumentSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[selectedObjectDocumentSlots.zoneIndex]),
|
||||
entityIndex: num(document[selectedObjectDocumentSlots.entityIndex], -1),
|
||||
selected: readNullableViewportObject(document[selectedObjectDocumentSlots.selected]),
|
||||
};
|
||||
}
|
||||
|
||||
export function readInventoryDocument(inventoryDocument: unknown): InventoryDocument {
|
||||
const document = arr(inventoryDocument);
|
||||
return {
|
||||
schema: str(document[inventoryDocumentSlots.schema]) || AetheriaRtsSchemas.inventory,
|
||||
frameId: num(document[inventoryDocumentSlots.frameId]),
|
||||
runId: str(document[inventoryDocumentSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[inventoryDocumentSlots.zoneIndex]),
|
||||
entityIndex: num(document[inventoryDocumentSlots.entityIndex], -1),
|
||||
entityKey: str(document[inventoryDocumentSlots.entityKey]),
|
||||
items: list<unknown[]>(document[inventoryDocumentSlots.items]).map(readViewportInventoryItem),
|
||||
equipment: list<unknown[]>(document[inventoryDocumentSlots.equipment]).map(readViewportInventoryItem),
|
||||
cargo: list<unknown[]>(document[inventoryDocumentSlots.cargo]).map(readViewportInventoryItem),
|
||||
};
|
||||
}
|
||||
|
||||
export function readViewportDocument(viewportDocument: unknown): ViewportResponse {
|
||||
const document = arr(viewportDocument);
|
||||
const objects = readViewportObjects(list<unknown[]>(document[gameViewportSlots.objects]));
|
||||
const gravityInfluences = readGravityInfluences(list<unknown[]>(document[gameViewportSlots.gravityInfluences]));
|
||||
const bodies = readBodyViews(list<unknown[]>(document[gameViewportSlots.bodies]));
|
||||
return {
|
||||
schema: str(document[gameViewportSlots.schema]) || AetheriaRtsSchemas.gameViewport,
|
||||
frameId: num(document[gameViewportSlots.frameId]),
|
||||
publishedAtUtc: str(document[gameViewportSlots.publishedAtUtc]),
|
||||
simulationTimeSeconds: num(document[gameViewportSlots.simulationTimeSeconds]),
|
||||
runId: str(document[gameViewportSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[gameViewportSlots.zoneIndex]),
|
||||
zoneName: str(document[gameViewportSlots.zoneName]),
|
||||
currentEntityKey: str(document[gameViewportSlots.currentEntityKey]),
|
||||
viewport: readViewportBounds(document[gameViewportSlots.viewport]),
|
||||
controlledEntityIndices: numberList(document[gameViewportSlots.controlledEntityIndices]),
|
||||
objects,
|
||||
gravityInfluences,
|
||||
bodies,
|
||||
};
|
||||
}
|
||||
|
||||
export function readObjectsViewportDocument(viewportDocument: unknown): ObjectsViewportResponse {
|
||||
const document = arr(viewportDocument);
|
||||
return {
|
||||
schema: str(document[objectsViewportSlots.schema]) || AetheriaRtsSchemas.objectsViewport,
|
||||
frameId: num(document[objectsViewportSlots.frameId]),
|
||||
publishedAtUtc: str(document[objectsViewportSlots.publishedAtUtc]),
|
||||
simulationTimeSeconds: num(document[objectsViewportSlots.simulationTimeSeconds]),
|
||||
runId: str(document[objectsViewportSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[objectsViewportSlots.zoneIndex]),
|
||||
zoneName: str(document[objectsViewportSlots.zoneName]),
|
||||
currentEntityKey: str(document[objectsViewportSlots.currentEntityKey]),
|
||||
viewport: readViewportBounds(document[objectsViewportSlots.viewport]),
|
||||
controlledEntityIndices: numberList(document[objectsViewportSlots.controlledEntityIndices]),
|
||||
objects: readViewportObjects(list<unknown[]>(document[objectsViewportSlots.objects])),
|
||||
};
|
||||
}
|
||||
|
||||
export function readGravityViewportDocument(viewportDocument: unknown): GravityViewportResponse {
|
||||
const document = arr(viewportDocument);
|
||||
return {
|
||||
schema: str(document[gravityViewportSlots.schema]) || AetheriaRtsSchemas.gravityViewport,
|
||||
frameId: num(document[gravityViewportSlots.frameId]),
|
||||
publishedAtUtc: str(document[gravityViewportSlots.publishedAtUtc]),
|
||||
simulationTimeSeconds: num(document[gravityViewportSlots.simulationTimeSeconds]),
|
||||
runId: str(document[gravityViewportSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[gravityViewportSlots.zoneIndex]),
|
||||
zoneName: str(document[gravityViewportSlots.zoneName]),
|
||||
viewport: readViewportBounds(document[gravityViewportSlots.viewport]),
|
||||
gravityInfluences: readGravityInfluences(list<unknown[]>(document[gravityViewportSlots.gravityInfluences])),
|
||||
bodies: readBodyViews(list<unknown[]>(document[gravityViewportSlots.bodies])),
|
||||
};
|
||||
}
|
||||
|
||||
export function readRenderSplatsViewportDocument(viewportDocument: unknown): RenderSplatsViewportResponse {
|
||||
const document = arr(viewportDocument);
|
||||
return {
|
||||
schema: str(document[renderSplatsViewportSlots.schema]) || AetheriaRtsSchemas.renderSplatsViewport,
|
||||
frameId: num(document[renderSplatsViewportSlots.frameId]),
|
||||
publishedAtUtc: str(document[renderSplatsViewportSlots.publishedAtUtc]),
|
||||
simulationTimeSeconds: num(document[renderSplatsViewportSlots.simulationTimeSeconds]),
|
||||
runId: str(document[renderSplatsViewportSlots.runId]) || missingDaemonRunId,
|
||||
zoneIndex: num(document[renderSplatsViewportSlots.zoneIndex]),
|
||||
zoneName: str(document[renderSplatsViewportSlots.zoneName]),
|
||||
viewport: readViewportBounds(document[renderSplatsViewportSlots.viewport]),
|
||||
layers: list<unknown[]>(document[renderSplatsViewportSlots.layers]).map(readRenderSplatLayer),
|
||||
splats: readRenderSplatSoa(document[renderSplatsViewportSlots.splats]),
|
||||
};
|
||||
}
|
||||
|
||||
export function readDaemonHealthDocument(healthDocument: unknown): DaemonHealthDocument {
|
||||
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 readAuthorityStatusDocument(policyDocument: unknown): AuthorityStatusDocument {
|
||||
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<unknown[]>(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 readStarbridgeSessionSummaryDocument(summaryDocument: unknown): StarbridgeSessionDocument {
|
||||
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<unknown[]>(summary[starbridgeSummarySlots.stationStock]).map(toStarbridgeStationStockItem),
|
||||
waveForecast: list<unknown[]>(summary[starbridgeSummarySlots.waveForecast]).map(toStarbridgeWaveForecast),
|
||||
runtimeRoles: list<unknown[]>(summary[starbridgeSummarySlots.runtimeRoles]).map(toStarbridgeRuntimeRole),
|
||||
};
|
||||
}
|
||||
|
||||
export function readAssetManifestDocument(assetManifestDocument: unknown): AssetManifestDocument {
|
||||
const manifest = arr(assetManifestDocument);
|
||||
return {
|
||||
schema: str(manifest[assetManifestSlots.schema]) || AetheriaRtsSchemas.assetManifest,
|
||||
publishedAtUtc: str(manifest[assetManifestSlots.publishedAtUtc]),
|
||||
runId: str(manifest[assetManifestSlots.runId]),
|
||||
baseUri: str(manifest[assetManifestSlots.baseUri]),
|
||||
assets: list<unknown[]>(manifest[assetManifestSlots.assets]).map(toAssetManifestEntry),
|
||||
};
|
||||
}
|
||||
|
||||
function readViewportBounds(value: unknown): ViewportRequest {
|
||||
const bounds = arr(value);
|
||||
return cultMeshViewportRequest(
|
||||
cultMeshRectFromBounds(
|
||||
num(bounds[viewportBoundsSlots.minX]),
|
||||
num(bounds[viewportBoundsSlots.minY]),
|
||||
num(bounds[viewportBoundsSlots.maxX]),
|
||||
num(bounds[viewportBoundsSlots.maxY]),
|
||||
),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
function readViewportObjects(objects: unknown[][]): ViewObject[] {
|
||||
return objects.map(readViewportObject);
|
||||
}
|
||||
|
||||
function readNullableViewportObject(value: unknown): ViewObject | null {
|
||||
return Array.isArray(value) ? readViewportObject(value) : null;
|
||||
}
|
||||
|
||||
function readViewportObject(object: unknown[]): ViewObject {
|
||||
return {
|
||||
entityIndex: num(object[viewportObjectSlots.entityIndex], -1),
|
||||
entityKey: str(object[viewportObjectSlots.entityKey]),
|
||||
displayName: str(object[viewportObjectSlots.displayName]),
|
||||
kind: str(object[viewportObjectSlots.kind]),
|
||||
factionKey: str(object[viewportObjectSlots.factionKey]),
|
||||
x: num(object[viewportObjectSlots.x]),
|
||||
y: num(object[viewportObjectSlots.y]),
|
||||
z: num(object[viewportObjectSlots.z]),
|
||||
directionX: num(object[viewportObjectSlots.directionX]),
|
||||
directionY: num(object[viewportObjectSlots.directionY]),
|
||||
velocityX: num(object[viewportObjectSlots.velocityX]),
|
||||
velocityY: num(object[viewportObjectSlots.velocityY]),
|
||||
controlled: bool(object[viewportObjectSlots.controlled]),
|
||||
targetEntityIndex: num(object[viewportObjectSlots.targetEntityIndex], -1),
|
||||
isActive: object[viewportObjectSlots.isActive] !== false,
|
||||
visibility: num(object[viewportObjectSlots.visibility]),
|
||||
iconAsset: assetRef(arr(object[viewportObjectSlots.iconAsset])),
|
||||
status: readViewportStatus(object[viewportObjectSlots.status]),
|
||||
inventory: list<unknown[]>(object[viewportObjectSlots.inventory]).map(readViewportInventoryItem),
|
||||
};
|
||||
}
|
||||
|
||||
function readViewportStatus(value: unknown): EntityStatus {
|
||||
const status = arr(value);
|
||||
return {
|
||||
hull: num(status[viewportStatusSlots.hull]),
|
||||
shield: num(status[viewportStatusSlots.shield]),
|
||||
heat: num(status[viewportStatusSlots.heat]),
|
||||
};
|
||||
}
|
||||
|
||||
function readViewportInventoryItem(value: unknown[]): InventoryItem {
|
||||
const itemKey = str(value[viewportInventoryItemSlots.itemKey]);
|
||||
return {
|
||||
source: str(value[viewportInventoryItemSlots.source]),
|
||||
itemKey,
|
||||
quantity: num(value[viewportInventoryItemSlots.quantity]),
|
||||
quality: num(value[viewportInventoryItemSlots.quality]),
|
||||
durability: num(value[viewportInventoryItemSlots.durability]),
|
||||
enabled: value[viewportInventoryItemSlots.enabled] !== false,
|
||||
iconAsset: assetRef(arr(value[viewportInventoryItemSlots.iconAsset])),
|
||||
};
|
||||
}
|
||||
|
||||
function readGravityInfluences(influences: unknown[][]): GravityInfluence[] {
|
||||
return influences.map(influence => ({
|
||||
bodyKey: str(influence[gravityInfluenceSlots.bodyKey]),
|
||||
orbitKey: str(influence[gravityInfluenceSlots.orbitKey]),
|
||||
kind: str(influence[gravityInfluenceSlots.kind]),
|
||||
x: num(influence[gravityInfluenceSlots.x]),
|
||||
y: num(influence[gravityInfluenceSlots.y]),
|
||||
radius: num(influence[gravityInfluenceSlots.radius]),
|
||||
gravityDepth: num(influence[gravityInfluenceSlots.gravityDepth]),
|
||||
gravityDepthExponent: num(influence[gravityInfluenceSlots.gravityDepthExponent]),
|
||||
waveRadius: num(influence[gravityInfluenceSlots.waveRadius]),
|
||||
waveDepth: num(influence[gravityInfluenceSlots.waveDepth]),
|
||||
waveSpeed: num(influence[gravityInfluenceSlots.waveSpeed]),
|
||||
}));
|
||||
}
|
||||
|
||||
function readBodyViews(bodies: unknown[][]): BodyView[] {
|
||||
return bodies.map(body => {
|
||||
const kind = str(body[bodyViewSlots.kind]);
|
||||
return {
|
||||
bodyKey: str(body[bodyViewSlots.bodyKey]),
|
||||
orbitKey: str(body[bodyViewSlots.orbitKey]),
|
||||
name: str(body[bodyViewSlots.name]),
|
||||
kind,
|
||||
x: num(body[bodyViewSlots.x]),
|
||||
y: num(body[bodyViewSlots.y]),
|
||||
radius: num(body[bodyViewSlots.radius]),
|
||||
isAsteroidBelt: bool(body[bodyViewSlots.isAsteroidBelt]),
|
||||
body: num(body[bodyViewSlots.body]),
|
||||
iconAsset: assetRef(arr(body[bodyViewSlots.iconAsset])),
|
||||
iconSize: num(body[bodyViewSlots.iconSize]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function readRenderSplatLayer(layer: unknown[]): RenderSplatsViewportResponse["layers"][number] {
|
||||
return {
|
||||
layerKey: str(layer[renderSplatLayerSlots.layerKey]),
|
||||
displayName: str(layer[renderSplatLayerSlots.displayName]),
|
||||
channel: num(layer[renderSplatLayerSlots.channel]),
|
||||
blendMode: str(layer[renderSplatLayerSlots.blendMode]),
|
||||
graphicsFormat: str(layer[renderSplatLayerSlots.graphicsFormat]),
|
||||
clearBeforeDraw: bool(layer[renderSplatLayerSlots.clearBeforeDraw]),
|
||||
clearR: num(layer[renderSplatLayerSlots.clearR]),
|
||||
clearG: num(layer[renderSplatLayerSlots.clearG]),
|
||||
clearB: num(layer[renderSplatLayerSlots.clearB]),
|
||||
clearA: num(layer[renderSplatLayerSlots.clearA]),
|
||||
};
|
||||
}
|
||||
|
||||
function readRenderSplatSoa(value: unknown): RenderSplatsViewportResponse["splats"] {
|
||||
const splats = arr(value);
|
||||
return {
|
||||
count: num(splats[renderSplatSoaSlots.count]),
|
||||
centerX: numberList(splats[renderSplatSoaSlots.centerX]),
|
||||
centerY: numberList(splats[renderSplatSoaSlots.centerY]),
|
||||
halfExtentX: numberList(splats[renderSplatSoaSlots.halfExtentX]),
|
||||
halfExtentY: numberList(splats[renderSplatSoaSlots.halfExtentY]),
|
||||
rotationCos: numberList(splats[renderSplatSoaSlots.rotationCos]),
|
||||
rotationSin: numberList(splats[renderSplatSoaSlots.rotationSin]),
|
||||
channel: numberList(splats[renderSplatSoaSlots.channel]),
|
||||
falloff: numberList(splats[renderSplatSoaSlots.falloff]),
|
||||
valueR: numberList(splats[renderSplatSoaSlots.valueR]),
|
||||
valueG: numberList(splats[renderSplatSoaSlots.valueG]),
|
||||
valueB: numberList(splats[renderSplatSoaSlots.valueB]),
|
||||
valueA: numberList(splats[renderSplatSoaSlots.valueA]),
|
||||
sourceKey: stringList(splats[renderSplatSoaSlots.sourceKey]),
|
||||
layerIndex: numberList(splats[renderSplatSoaSlots.layerIndex]),
|
||||
sourceKind: numberList(splats[renderSplatSoaSlots.sourceKind]),
|
||||
frequencyX: numberList(splats[renderSplatSoaSlots.frequencyX]),
|
||||
frequencyY: numberList(splats[renderSplatSoaSlots.frequencyY]),
|
||||
phaseX: numberList(splats[renderSplatSoaSlots.phaseX]),
|
||||
phaseY: numberList(splats[renderSplatSoaSlots.phaseY]),
|
||||
animationSpeed: numberList(splats[renderSplatSoaSlots.animationSpeed]),
|
||||
sourceFlags: numberList(splats[renderSplatSoaSlots.sourceFlags]),
|
||||
};
|
||||
}
|
||||
|
||||
function toStarbridgeBaseStatus(status: unknown[]): StarbridgeSessionDocument["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[]): StarbridgeSessionDocument["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",
|
||||
iconAsset: assetRef(arr(item[starbridgeStockSlots.iconAsset])),
|
||||
};
|
||||
}
|
||||
|
||||
function toStarbridgeWaveForecast(wave: unknown[]): StarbridgeSessionDocument["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[]): StarbridgeSessionDocument["runtimeRoles"][number] {
|
||||
return {
|
||||
runtimeId: str(role[starbridgeRoleSlots.runtimeId]),
|
||||
role: str(role[starbridgeRoleSlots.role]),
|
||||
entityKey: str(role[starbridgeRoleSlots.entityKey]),
|
||||
};
|
||||
}
|
||||
|
||||
function toAssetManifestEntry(entry: unknown[]): AssetManifestDocument["assets"][number] {
|
||||
return {
|
||||
ref: assetRef(arr(entry[assetManifestEntrySlots.ref])),
|
||||
sizeBytes: num(entry[assetManifestEntrySlots.sizeBytes]),
|
||||
width: num(entry[assetManifestEntrySlots.width]),
|
||||
height: num(entry[assetManifestEntrySlots.height]),
|
||||
tags: stringList(entry[assetManifestEntrySlots.tags]),
|
||||
};
|
||||
}
|
||||
|
||||
function assetRef(value: unknown[]): AssetRef {
|
||||
const assetKey = str(value[assetRefSlots.assetKey]);
|
||||
if (assetKey.length === 0)
|
||||
return emptyAsset();
|
||||
|
||||
return asset(
|
||||
assetKey,
|
||||
str(value[assetRefSlots.kind]),
|
||||
str(value[assetRefSlots.uri]),
|
||||
str(value[assetRefSlots.transport]),
|
||||
str(value[assetRefSlots.mimeType]),
|
||||
str(value[assetRefSlots.contentHash]),
|
||||
stringRecord(value[assetRefSlots.metadata]),
|
||||
);
|
||||
}
|
||||
|
||||
function asset(
|
||||
assetKey: string,
|
||||
kind: string,
|
||||
uri: string,
|
||||
transport: string,
|
||||
mimeType: string,
|
||||
contentHash = "",
|
||||
metadata: Record<string, string> = {},
|
||||
): AssetRef {
|
||||
return {
|
||||
assetKey,
|
||||
kind,
|
||||
uri,
|
||||
transport,
|
||||
contentHash,
|
||||
mimeType,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAsset(kind = ""): AssetRef {
|
||||
return asset("", kind, "", "", "");
|
||||
}
|
||||
|
||||
function arr(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function list<T extends unknown[]>(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 stringRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, candidate]) => [key, 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;
|
||||
}
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
import { app, BrowserWindow, ipcMain, protocol, shell } from "electron";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { startEveElectronProviderHost } from "@gamecult/eve-electron/live-provider-host";
|
||||
import { CultMesh } from "cultmesh-ts";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
|
||||
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_RUNTIME_ROOT ?? resolve(projectRoot, "runtime"));
|
||||
const runtimeStatePath = resolve(runtimeRoot, "aetheria.cc");
|
||||
const daemonDll = resolve(repoRoot, "Aetheria.State.Daemon", "bin", "Debug", "net10.0", "Aetheria.State.Daemon.dll");
|
||||
const rendererIndex = resolve(projectRoot, "wwwroot", "index.html");
|
||||
const clientCultMeshPort = Number.parseInt(process.env.AETHERIA_CLIENT_CULTMESH_PORT ?? "3076", 10);
|
||||
const verseId = process.env.AETHERIA_VERSE_ID?.trim() || "aetheria.local";
|
||||
const daemonId = process.env.AETHERIA_DAEMON_ID?.trim() || "aetheria-daemon";
|
||||
const configuredDaemonUri = process.env.AETHERIA_CULTMESH_URI?.trim() ?? "";
|
||||
const launchLocalDaemon = configuredDaemonUri.length === 0;
|
||||
const daemonCultMeshUri = configuredDaemonUri || `cultmesh://aetheria/daemon/${encodeURIComponent(daemonId)}`;
|
||||
const clientCultMeshAdvertiseHost = process.env.AETHERIA_CLIENT_CULTMESH_ADVERTISE_HOST?.trim() || "127.0.0.1";
|
||||
const localDaemonRudpEndpoint = launchLocalDaemon ? `rudp://127.0.0.1:${clientCultMeshPort}` : "";
|
||||
const electronSmoke = process.env.AETHERIA_ELECTRON_SMOKE === "1";
|
||||
const electronSmokeResultPath = process.env.AETHERIA_ELECTRON_SMOKE_RESULT;
|
||||
|
||||
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let eveHost: { close(): Promise<void>; window: BrowserWindow } | null = null;
|
||||
let isQuitting = false;
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
writeElectronSmokeResult({ ok: false, stage: "electron-ready" });
|
||||
|
||||
try {
|
||||
if (launchLocalDaemon) {
|
||||
await ensureDotnetBuild();
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
daemonProcess = startDotnet("aetheria-daemon", daemonDll, [
|
||||
"--state",
|
||||
runtimeStatePath,
|
||||
"--verse-id",
|
||||
verseId,
|
||||
"--daemon-id",
|
||||
daemonId,
|
||||
"--client-cultmesh-port",
|
||||
clientCultMeshPort.toString(),
|
||||
"--client-cultmesh-advertise-host",
|
||||
clientCultMeshAdvertiseHost,
|
||||
"--tick-interval-ms",
|
||||
"20",
|
||||
"--fixed-delta-ms",
|
||||
"20",
|
||||
"--api-publication-interval-ms",
|
||||
"100000",
|
||||
"--no-odin-announcements",
|
||||
]);
|
||||
} else {
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
}
|
||||
|
||||
eveHost = await startEveElectronProviderHost({
|
||||
electron: { app, BrowserWindow, ipcMain, protocol, shell },
|
||||
dependencies: { CultMesh, encode },
|
||||
providerTarget: {
|
||||
providerId: "aetheria.daemon",
|
||||
advertisementRecordRef: "eve:provider:aetheria.daemon",
|
||||
peerId: daemonId,
|
||||
verseId,
|
||||
role: "aetheria-daemon",
|
||||
endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [],
|
||||
},
|
||||
renderer: rendererIndex,
|
||||
runtimeId: "eve-electron-aetheria",
|
||||
surfaceId: process.env.EVE_SURFACE_ID || "aetheria.starbridge.commander",
|
||||
window: {
|
||||
show: !electronSmoke,
|
||||
backgroundColor: "#0b1016",
|
||||
title: "Aetheria Starbridge",
|
||||
},
|
||||
});
|
||||
mainWindow = eveHost.window;
|
||||
writeElectronSmokeResult({ ok: false, stage: "renderer-loaded" });
|
||||
if (electronSmoke) {
|
||||
await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.providerAdvertisement()"), 10000, "provider advertisement smoke");
|
||||
writeElectronSmokeResult({ ok: false, stage: "provider-advertisement-readable" });
|
||||
await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.surface({ surfaceId: 'aetheria.starbridge.commander' })"), 10000, "surface smoke");
|
||||
writeElectronSmokeResult({ ok: false, stage: "provider-surface-readable" });
|
||||
await withTimeout(mainWindow.webContents.executeJavaScript(`window.eveProvider.submitCommand({
|
||||
providerId: 'aetheria.daemon', surfaceId: 'aetheria.starbridge.commander', command: 'aetheria.daemon.commands.SensorPing',
|
||||
clientId: 'aetheria-electron-smoke-preflight', payload: {}
|
||||
})`), 10000, "command receipt smoke");
|
||||
writeElectronSmokeResult({ ok: false, stage: "provider-command-receipt-readable" });
|
||||
const result = await runElectronSmoke(mainWindow);
|
||||
writeElectronSmokeResult({ ok: true, result });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
exitElectronSmoke(0);
|
||||
}
|
||||
} catch (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);
|
||||
} else {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
isQuitting = true;
|
||||
stopChild(daemonProcess);
|
||||
});
|
||||
|
||||
async function runElectronSmoke(window: BrowserWindow): Promise<Record<string, unknown>> {
|
||||
const started = Date.now();
|
||||
let lastResult: Record<string, unknown> | null = null;
|
||||
while (Date.now() - started < 15000) {
|
||||
lastResult = await window.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const eveProvider = window.eveProvider;
|
||||
const status = document.querySelector("#status")?.textContent ?? "";
|
||||
const bodyMode = document.body.className;
|
||||
const eveHostText = document.querySelector("#eve-surface-host")?.textContent ?? "";
|
||||
const worldScene = document.querySelector(".cultui-world-scene");
|
||||
const worldEntityCount = worldScene?.querySelectorAll(".cultui-world-entity").length ?? 0;
|
||||
const controlledWorldEntityCount = worldScene?.querySelectorAll('.cultui-world-entity[data-controlled="true"]').length ?? 0;
|
||||
const workerRosterCount = document.querySelectorAll('.cultui-list-item[data-component-kind="agent.item"]').length;
|
||||
const providerAdvertisement = eveProvider ? await eveProvider.providerAdvertisement() : null;
|
||||
const eveSurface = eveProvider ? await eveProvider.surface({ surfaceId: "aetheria.starbridge.commander" }) : null;
|
||||
const pilotSurface = eveProvider ? await eveProvider.surface({ surfaceId: "aetheria.pilot" }) : null;
|
||||
const findComponent = (component, predicate) => {
|
||||
if (!component || typeof component !== "object") return null;
|
||||
if (predicate(component)) return component;
|
||||
for (const child of component.children ?? []) {
|
||||
const match = findComponent(child, predicate);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const eveFieldSurface = findComponent(pilotSurface?.surface?.root, component =>
|
||||
component.kind === "field.surface2d" || component.kind === "gravity.surface");
|
||||
const embedded = slotId => eveFieldSurface?.embeddedDocuments?.find(slot => slot.slotId === slotId) ?? null;
|
||||
const resolveEmbedded = async slot => slot ? eveProvider.document({ documentId: slot.documentId, schemaId: slot.schemaId }) : null;
|
||||
const [renderSplatsResolved, gravityResolved, objectsResolved] = await Promise.all([
|
||||
resolveEmbedded(embedded("renderSplats")),
|
||||
resolveEmbedded(embedded("gravity")),
|
||||
resolveEmbedded(embedded("objects")),
|
||||
]);
|
||||
const assetProbe = await new Promise(resolve => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ loaded: true, width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () => resolve({ loaded: false, width: 0, height: 0 });
|
||||
image.src = "eve-asset://asset?uri=" + encodeURIComponent("cultmesh://aetheria/assets/map/entity/player");
|
||||
});
|
||||
const eveReceipt = eveProvider ? await eveProvider.submitCommand({
|
||||
providerId: "aetheria.daemon",
|
||||
surfaceId: "aetheria.starbridge.commander",
|
||||
command: "aetheria.daemon.commands.SensorPing",
|
||||
clientId: "aetheria-electron-smoke",
|
||||
payload: {},
|
||||
}) : null;
|
||||
return {
|
||||
hasApi: !!eveProvider &&
|
||||
typeof eveProvider.providerAdvertisement === "function" &&
|
||||
typeof eveProvider.document === "function" &&
|
||||
typeof eveProvider.surface === "function" &&
|
||||
typeof eveProvider.submitCommand === "function",
|
||||
status,
|
||||
bodyMode,
|
||||
eveHostText,
|
||||
worldSceneReady: !!worldScene,
|
||||
worldEntityCount,
|
||||
controlledWorldEntityCount,
|
||||
workerRosterCount,
|
||||
providerAdvertisement,
|
||||
eveSurface,
|
||||
pilotSurface,
|
||||
renderSplatsResolved,
|
||||
gravityResolved,
|
||||
objectsResolved,
|
||||
assetProbe,
|
||||
eveFieldSurface,
|
||||
eveReceipt
|
||||
};
|
||||
})()
|
||||
`, true) as Record<string, unknown>;
|
||||
|
||||
if (isElectronSmokeReady(lastResult))
|
||||
return lastResult;
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
throw new Error(`Aetheria Starbridge Electron smoke did not reach ready state. Last result: ${JSON.stringify(lastResult)}`);
|
||||
}
|
||||
|
||||
function isElectronSmokeReady(result: Record<string, unknown>): boolean {
|
||||
const status = stringValue(result.status);
|
||||
const bodyMode = stringValue(result.bodyMode);
|
||||
const eveHostText = stringValue(result.eveHostText);
|
||||
const eveSurface = objectValue(result.eveSurface);
|
||||
const providerAdvertisement = objectValue(result.providerAdvertisement);
|
||||
const renderSplatsResolved = objectValue(result.renderSplatsResolved);
|
||||
const gravityResolved = objectValue(result.gravityResolved);
|
||||
const objectsResolved = objectValue(result.objectsResolved);
|
||||
const eveFieldSurface = objectValue(result.eveFieldSurface);
|
||||
const eveReceipt = objectValue(result.eveReceipt);
|
||||
return result.hasApi === true &&
|
||||
status.includes("Starbridge Commander") &&
|
||||
bodyMode.includes("eve-game-mode") &&
|
||||
result.worldSceneReady === true &&
|
||||
Number(result.worldEntityCount) > 0 &&
|
||||
Number(result.controlledWorldEntityCount) === 1 &&
|
||||
Number(result.workerRosterCount) > 0 &&
|
||||
eveHostText.includes("Station Stock") &&
|
||||
eveHostText.includes("Wave Forecast") &&
|
||||
arrayValue(providerAdvertisement?.surfaces).some(surface => objectValue(surface)?.surfaceId === "aetheria.starbridge.commander") &&
|
||||
objectValue(eveSurface?.surface)?.id === "aetheria.starbridge.commander" &&
|
||||
stringValue(renderSplatsResolved?.documentId).length > 0 &&
|
||||
stringValue(gravityResolved?.documentId).length > 0 &&
|
||||
stringValue(objectsResolved?.documentId).length > 0 &&
|
||||
renderSplatsResolved?.schemaId === "gamecult.fields.splats.v1" &&
|
||||
gravityResolved?.schemaId === "gamecult.fields.gravity.v1" &&
|
||||
objectsResolved?.schemaId === "gamecult.fields.objects.v1" &&
|
||||
objectValue(result.assetProbe)?.loaded === true &&
|
||||
Number(objectValue(result.assetProbe)?.width) > 0 &&
|
||||
Number(objectValue(result.assetProbe)?.height) > 0 &&
|
||||
stringValue(eveFieldSurface?.id).length > 0 &&
|
||||
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
|
||||
objectValue(slot)?.slotId === "renderSplats") &&
|
||||
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
|
||||
objectValue(slot)?.slotId === "gravity") &&
|
||||
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
|
||||
objectValue(slot)?.slotId === "objects") &&
|
||||
stringValue(eveReceipt?.commandId).length > 0 &&
|
||||
objectValue(eveReceipt?.route)?.kind === "network" &&
|
||||
eveReceipt?.accepted === true;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> | null {
|
||||
return value != null && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function writeElectronSmokeResult(result: Record<string, unknown>): void {
|
||||
if (!electronSmokeResultPath)
|
||||
return;
|
||||
|
||||
writeFileSync(electronSmokeResultPath, JSON.stringify(result, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function exitElectronSmoke(exitCode: number): void {
|
||||
isQuitting = true;
|
||||
void eveHost?.close();
|
||||
eveHost = null;
|
||||
stopChild(daemonProcess);
|
||||
daemonProcess = null;
|
||||
mainWindow?.destroy();
|
||||
mainWindow = null;
|
||||
app.exit(exitCode);
|
||||
}
|
||||
|
||||
function withTimeout<T>(work: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return Promise.race([
|
||||
work,
|
||||
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`Timed out during ${label}.`)), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function ensureDotnetBuild(): Promise<void> {
|
||||
if (existsSync(daemonDll) && !daemonBuildInputsAreNewer(daemonDll))
|
||||
return;
|
||||
|
||||
await runProcess("dotnet", ["build", resolve(repoRoot, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj")], "Aetheria.State.Daemon.build");
|
||||
}
|
||||
|
||||
function daemonBuildInputsAreNewer(outputPath: string): boolean {
|
||||
const outputMtime = statSync(outputPath).mtimeMs;
|
||||
const sourceRoots = [
|
||||
resolve(repoRoot, "Aetheria.State.Daemon"),
|
||||
resolve(repoRoot, "Aetheria.State"),
|
||||
resolve(repoRoot, "Packages", "org.gamecult.aetheria.state", "Runtime"),
|
||||
];
|
||||
|
||||
return sourceRoots.some(sourceRoot => newestSourceMtime(sourceRoot) > outputMtime);
|
||||
}
|
||||
|
||||
function newestSourceMtime(root: string): number {
|
||||
if (!existsSync(root))
|
||||
return 0;
|
||||
|
||||
const stat = statSync(root);
|
||||
if (stat.isFile())
|
||||
return isBuildInput(root) ? stat.mtimeMs : 0;
|
||||
|
||||
let newest = 0;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = resolve(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "bin" || entry.name === "obj")
|
||||
continue;
|
||||
newest = Math.max(newest, newestSourceMtime(path));
|
||||
} else if (entry.isFile() && isBuildInput(path)) {
|
||||
newest = Math.max(newest, statSync(path).mtimeMs);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
function isBuildInput(path: string): boolean {
|
||||
return path.endsWith(".cs") || path.endsWith(".csproj") || path.endsWith(".props") || path.endsWith(".targets");
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function showFailure(error: unknown): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria Starbridge failed to start", message))}`);
|
||||
}
|
||||
|
||||
function startupHtml(title: string, detail: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #0b1016;
|
||||
color: #dbe7f3;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(520px, calc(100vw - 48px));
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8fa3b7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(detail)}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
305
Aetheria.Rts.Web/package-lock.json
generated
305
Aetheria.Rts.Web/package-lock.json
generated
|
|
@ -1,305 +0,0 @@
|
|||
{
|
||||
"name": "aetheria-rts-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aetheria-rts-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@gamecult/eve-browser-lowering": "file:../../Eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-electron": "file:../../EveElectron",
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"../../Eve/packages/eve-browser-lowering": {
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"../../EveElectron": {
|
||||
"name": "@gamecult/eve-electron",
|
||||
"version": "0.1.0",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"electron": ">=30"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"electron": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/@gamecult/eve-browser-lowering": {
|
||||
"resolved": "../../Eve/packages/eve-browser-lowering",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gamecult/eve-electron": {
|
||||
"resolved": "../../EveElectron",
|
||||
"link": true
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"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",
|
||||
"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",
|
||||
"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-rts.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",
|
||||
"@gamecult/eve-browser-lowering": "file:../../Eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-electron": "file:../../EveElectron",
|
||||
"cultcache-ts": "file:../../CultLib/packages/cultcache-ts",
|
||||
"cultmesh-ts": "file:../../CultLib/packages/cultmesh-ts",
|
||||
"cultnet-ts": "file:../../CultLib/packages/cultnet-ts"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,576 +0,0 @@
|
|||
$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."
|
||||
}
|
||||
|
||||
$forbiddenFileBackedDebugSurfaceHits = & rg -n 'debugSurface|debug-surface|watchDebugSurface|AETHERIA_CULTUI_DEBUG_SURFACE_PATH|cultui-debug-surface|file_surface|debug-log' Client Electron scripts/generate-rts-bindings.mjs 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: Electron must consume daemon Eve surfaces, not file-backed CultUI debug surfaces.`n$forbiddenFileBackedDebugSurfaceHits"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for file-backed debug surface checks."
|
||||
}
|
||||
|
||||
$forbiddenRtsSurfaceContractHits = & rg -n 'gamecult\.aetheria\.rts\.(surfaces|viewport_feed)' Client Electron scripts/generate-rts-bindings.mjs 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: Aetheria CultMesh surface catalog/feed ids must not be RTS-branded.`n$forbiddenRtsSurfaceContractHits"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for RTS-branded surface contract checks."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
}
|
||||
|
||||
$rendererText = Get-Content Client/app.ts -Raw
|
||||
if (-not $rendererText.Contains('window.aetheriaRts.eveSurface') -or
|
||||
-not $rendererText.Contains('renderEveSurface') -or
|
||||
-not $rendererText.Contains('resolveEveDocument') -or
|
||||
-not $rendererText.Contains('submitEveCommand')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS renderer is not lowering daemon Eve through the generated preload API."
|
||||
}
|
||||
|
||||
$indexText = Get-Content wwwroot/index.html -Raw
|
||||
if (-not $indexText.Contains('eve-surface-host')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS renderer is not mounting the daemon Eve surface host."
|
||||
}
|
||||
|
||||
$transportLayoutPatterns = @(
|
||||
'new Array<unknown>',
|
||||
'bounds\[',
|
||||
'viewportSchema',
|
||||
'commandSchema',
|
||||
'createBaseCommandDocument',
|
||||
'decodeViewObject',
|
||||
'decodeInventoryItem',
|
||||
'decodeGravityInfluence',
|
||||
'decodeBodyView',
|
||||
'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."
|
||||
}
|
||||
|
||||
$remotePublicationReaderPatterns = @(
|
||||
'sendSnapshotRequest',
|
||||
'snapshot_response_raw',
|
||||
'CultNetSnapshotResponseRawMessage',
|
||||
'decode\('
|
||||
)
|
||||
|
||||
$remotePublicationReaderPattern = ($remotePublicationReaderPatterns -join '|')
|
||||
$remotePublicationReaderHits = & rg -n $remotePublicationReaderPattern Electron/aetheria-cultmesh.ts 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: RTS CultMesh client still owns raw snapshot/codec details.`n$remotePublicationReaderHits"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for remote publication layout checks."
|
||||
}
|
||||
|
||||
$requiredBindingSymbols = @(
|
||||
'createAetheriaRuntimeGameOperationHandles',
|
||||
'createAetheriaRuntimeGameQueryHandles',
|
||||
'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',
|
||||
'AetheriaRuntimeGameDocumentSources',
|
||||
'AetheriaRuntimeGameQueryDiagnostic',
|
||||
'AetheriaRuntimeGameLiveFeedDiagnostic',
|
||||
'AetheriaRuntimeGameSurfaceCatalogDiagnostic',
|
||||
'AetheriaRuntimeGameDocuments',
|
||||
'AetheriaRuntimeGameDocumentResolvers',
|
||||
'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.querySource',
|
||||
'CultMesh.query',
|
||||
'CultMeshQuerySource',
|
||||
'routeHint: CultMeshRouteHint',
|
||||
'CultMeshQueryWatcher',
|
||||
'AetheriaRuntimeGameQueryWatchers',
|
||||
'createAetheriaRuntimeGameDocuments',
|
||||
'CultMesh.document',
|
||||
'CultMesh.bindDocument',
|
||||
'CultMesh.describeSurface(documents.daemonFrame)',
|
||||
'watchQuery: watchers.objectsViewport',
|
||||
'sources: [AetheriaRuntimeGameDocumentSources.daemonFrame]',
|
||||
'describeAetheriaRuntimeGameQueryHandles',
|
||||
'describeAetheriaRuntimeGameLiveFeedSurface',
|
||||
'describeAetheriaRuntimeGameSurfaceCatalog',
|
||||
'CultMesh.describeQuerySurface',
|
||||
'CultMesh.describeLiveFeed',
|
||||
'CultMesh.describeSurfaceCatalog',
|
||||
'CultMesh.describeSurface',
|
||||
'CultMesh.describeSurface(operations.setMoveVector)',
|
||||
'CultMesh.describeSurface(operations.setTarget)',
|
||||
'CultMesh.operation',
|
||||
'CultMesh.operationReceipt',
|
||||
'AetheriaRuntimeGameOperationHandles',
|
||||
'AetheriaRuntimeGameVerseHandles',
|
||||
'createAetheriaRuntimeGameVerseHandles',
|
||||
'CultMesh.bindQuery',
|
||||
'CultMesh.bindOperation',
|
||||
'zone: (_zoneId =',
|
||||
'visibleWithin',
|
||||
'entity: (entityKey: string)',
|
||||
'pilot',
|
||||
'selectedObject: CultMesh.query',
|
||||
'inventory: CultMesh.query',
|
||||
'daemonHealth: CultMesh.query',
|
||||
'authorityStatus: CultMesh.query',
|
||||
'starbridgeSession: CultMesh.query'
|
||||
)
|
||||
|
||||
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\.(projectionRecipe|projectionSource)\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 query/document handles, not projection recipes or projection sources.`n$forbiddenGeneratedQuerySurfaceHits"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for generated projection vocabulary 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 handles 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 'createAetheriaRuntimeGameQueryHandles\(' 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.documents = createAetheriaRuntimeGameDocuments(') -or
|
||||
-not $clientText.Contains('this.queryVerse.context.routeHint,')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client is not creating generated document handles 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 document handles are not resolving through the daemon publication catalog."
|
||||
}
|
||||
|
||||
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.verse.withRoute("network", this.publicationDescription)') -or
|
||||
-not $clientText.Contains(': this.verse.withRoute("shared-memory", this.publicationDescription)')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client is not deriving publication-mode Verse views for query reads."
|
||||
}
|
||||
|
||||
if (-not $clientText.Contains('this.commandVerse = this.verse') -or
|
||||
-not $clientText.Contains('.withRoute("network", this.daemonTarget.uri)') -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.publicationDescription = publicationMode === "remote" ? this.daemonTarget.uri : statePath')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client query 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 = createAetheriaRuntimeGameVerseHandles(')) {
|
||||
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('queryDiagnostics()')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client does not expose generated query diagnostics."
|
||||
}
|
||||
|
||||
if (-not $clientText.Contains('describeAetheriaRuntimeGameQueryHandles(this.queries)')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS query 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('describeAetheriaRuntimeGameLiveFeedSurface(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('describeAetheriaRuntimeGameSurfaceCatalog(this.queries, this.operations, this.documents)')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS surface catalog should be read from generated query, operation, and document 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<AetheriaRuntimeViewportFeedRequest, AetheriaRuntimeViewportFeedSnapshot>')) {
|
||||
Write-Error "Stage 7B verifier failed: RTS viewport feed should use the shared CultMesh live feed primitive."
|
||||
}
|
||||
|
||||
$requiredGeneratedPreloadSymbols = @(
|
||||
'<auto-generated',
|
||||
'channels.mapViewport',
|
||||
'channels.objectsViewport',
|
||||
'channels.gravityViewport',
|
||||
'watchViewportFeed',
|
||||
'channels.viewportFeedUpdate',
|
||||
'channels.viewportFeedStop',
|
||||
'channels.setMoveVector',
|
||||
'channels.setTarget',
|
||||
'channels.surfaceCatalog',
|
||||
'channels.surfaceCatalogIndex'
|
||||
)
|
||||
|
||||
foreach ($symbol in $requiredGeneratedPreloadSymbols) {
|
||||
& rg -F -q $symbol Electron/preload.cjs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: expected generated preload symbol '$symbol' was not found."
|
||||
}
|
||||
}
|
||||
|
||||
$forbiddenMainIpcStringHits = & rg -n 'ipcMain\.handle\("aetheria-rts:' Electron/main.ts 2>$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."
|
||||
}
|
||||
|
||||
$forbiddenClientDirectDocumentReturns = @(
|
||||
'return projectSelectedObjectFromFrame',
|
||||
'return projectInventoryFromFrame',
|
||||
'return projectDaemonHealth',
|
||||
'return projectAuthorityStatus',
|
||||
'return projectStarbridgeSessionSummary'
|
||||
)
|
||||
$forbiddenClientDirectDocumentPattern = ($forbiddenClientDirectDocumentReturns -join '|')
|
||||
$forbiddenClientDirectDocumentHits = & rg -n $forbiddenClientDirectDocumentPattern 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$forbiddenClientDirectDocumentHits"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for direct document 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."
|
||||
}
|
||||
|
||||
$requiredEveRendererSymbols = @(
|
||||
'renderEveSurface',
|
||||
'eve-surface-host',
|
||||
'window.aetheriaRts.eveSurface',
|
||||
'resolveEveDocument',
|
||||
'gamecult.eve.surface.v1',
|
||||
'aetheria.daemon.game'
|
||||
)
|
||||
|
||||
foreach ($symbol in $requiredEveRendererSymbols) {
|
||||
& rg -F -q $symbol Client/app.ts wwwroot/index.html
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: expected daemon Eve renderer symbol '$symbol' was not found."
|
||||
}
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
$requiredLocalDocumentSymbols = @(
|
||||
'readViewportDocument',
|
||||
'readObjectsViewportDocument',
|
||||
'readGravityViewportDocument',
|
||||
'readRenderSplatsViewportDocument',
|
||||
'AetheriaRtsSchemas.objectsViewport',
|
||||
'AetheriaRtsSchemas.gravityViewport',
|
||||
'readSelectedObjectDocument',
|
||||
'readInventoryDocument',
|
||||
'AetheriaRtsSchemas.selectedObject',
|
||||
'AetheriaRtsSchemas.inventory',
|
||||
'readDaemonHealthDocument',
|
||||
'readAuthorityStatusDocument',
|
||||
'readStarbridgeSessionSummaryDocument',
|
||||
'AetheriaRtsSchemas.starbridgeSessionSummary'
|
||||
)
|
||||
|
||||
foreach ($symbol in $requiredLocalDocumentSymbols) {
|
||||
& rg -q $symbol Electron/aetheria-rts-local-documents.ts
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: expected daemon-authored document decoder symbol '$symbol' was not found."
|
||||
}
|
||||
}
|
||||
|
||||
$forbiddenLocalDocumentBuilders = & rg -n 'build(Viewport|ObjectsViewport|GravityViewport|RenderSplatsViewport|SelectedObject|Inventory)DocumentFromFrame|frameContext\(frameDocument' Electron/aetheria-rts-local-documents.ts 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: Electron must decode daemon-authored managed documents, not rebuild them from daemonFrame.`n$forbiddenLocalDocumentBuilders"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for stale local document builder checks."
|
||||
}
|
||||
|
||||
& rg -q 'CultMesh.documentsFromPublication' Electron/aetheria-cultmesh.ts
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: RTS publications should be read through a configured CultMesh document catalog."
|
||||
}
|
||||
|
||||
$forbiddenPublicationReaderFiles = & rg -n 'AetheriaLocalPublicationReader|AetheriaRemotePublicationReader|aetheria-local-publication-reader|aetheria-remote-publication-reader' Electron 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: RTS publication reader wrappers should be collapsed into the CultMesh document catalog.`n$forbiddenPublicationReaderFiles"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for stale publication reader checks."
|
||||
}
|
||||
|
||||
& rg -q 'AetheriaRtsSchemas.starbridgeSessionSummary' Electron/aetheria-cultmesh.ts
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: Starbridge summary publication binding was not found."
|
||||
}
|
||||
|
||||
$forbiddenLocalPublicationSidecars = & rg -n '\.(daemon\.frame|daemon\.health|authority\.policy|daemon\.starbridge\.session|daemon\.assets)\.cc' Electron/aetheria-cultmesh.ts 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client should read local publications from the canonical daemon store, not obsolete sidecar files.`n$forbiddenLocalPublicationSidecars"
|
||||
}
|
||||
if ($LASTEXITCODE -gt 1) {
|
||||
Write-Error "Stage 7B verifier could not run rg for obsolete local publication sidecar checks."
|
||||
}
|
||||
|
||||
& rg -q 'localPath: statePath' Electron/aetheria-cultmesh.ts
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7B verifier failed: RTS client local publication bindings do not point at the canonical daemon store."
|
||||
}
|
||||
|
||||
$requiredGeneratedBindingSymbols = @(
|
||||
'aetheriaRuntimeDaemonCommandDocumentSlots',
|
||||
'aetheriaRuntimeDaemonFrameDocumentSlots',
|
||||
'aetheriaRuntimeDaemonHealthDocumentSlots',
|
||||
'aetheriaRuntimeGameViewportDocumentSlots',
|
||||
'aetheriaRuntimeObjectsViewportDocumentSlots',
|
||||
'aetheriaRuntimeGravityViewportDocumentSlots',
|
||||
'aetheriaRuntimeRenderSplatsViewportDocumentSlots',
|
||||
'aetheriaRuntimeSelectedObjectDocumentSlots',
|
||||
'aetheriaRuntimeInventoryDocumentSlots',
|
||||
'aetheriaRuntimeStarbridgeScenarioDocumentSlots',
|
||||
'aetheriaRuntimeStarbridgeSessionDocumentSlots',
|
||||
'aetheriaRuntimeStarbridgeSessionSummaryDocumentSlots',
|
||||
'aetheriaRuntimeStarbridgeStationStockItemSlots',
|
||||
'aetheriaRuntimeStarbridgeWaveDefinitionSlots',
|
||||
'aetheriaRuntimeStarbridgeWaveForecastSlots',
|
||||
'aetheriaRuntimeStarbridgeRuntimeRoleSlots',
|
||||
'aetheriaRuntimeStarbridgeBaseStatusSlots',
|
||||
'aetheriaRuntimeGameViewportBoundsSlots',
|
||||
'aetheriaRuntimeGameViewportObjectSlots',
|
||||
'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."
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: RTS build failed."
|
||||
}
|
||||
|
||||
if (-not (Get-Content wwwroot\index.html -Raw).Contains('eve-surface-host')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: built renderer markup does not host the daemon Eve surface."
|
||||
}
|
||||
|
||||
$appText = Get-Content wwwroot\app.js -Raw
|
||||
if (-not $appText.Contains('mountEveElectronProvider') -or
|
||||
$appText.Contains('window.aetheriaRts.eveProviderAdvertisement') -or
|
||||
$appText.Contains('window.aetheriaRts.eveDocument') -or
|
||||
$appText.Contains('eve:surface:aetheria.daemon.game') -or
|
||||
$appText.Contains('gamecult.fields.gravity.v1') -or
|
||||
$appText.Contains('gamecult.fields.splats.v1') -or
|
||||
$appText.Contains('gamecult.fields.objects.v1') -or
|
||||
$appText.Contains('legacyViewportMode') -or
|
||||
$appText.Contains('mainMenuSurface') -or
|
||||
$appText.Contains('main-menu-mode')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: renderer is not daemon-Eve-only."
|
||||
}
|
||||
$rendererRuntimeText = Get-Content node_modules\@gamecult\eve-electron\src\eve-electron-renderer.mjs -Raw
|
||||
if (-not $rendererRuntimeText.Contains('EveBrowserProviderHost') -or
|
||||
-not $rendererRuntimeText.Contains('eveProvider.providerAdvertisement') -or
|
||||
-not $rendererRuntimeText.Contains('eveProvider.document')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: EveElectron does not own the generic provider renderer bootstrap."
|
||||
}
|
||||
if ($appText.Contains('aetheria.rts.electron') -or
|
||||
$appText.Contains('Aetheria RTS')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: renderer still advertises an RTS-branded client identity."
|
||||
}
|
||||
|
||||
$mainText = Get-Content Electron\main.ts -Raw
|
||||
if (-not $mainText.Contains('startEveElectronProviderHost') -or (Test-Path Electron\preload.cjs)) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron does not consume the EveElectron-owned preload entry."
|
||||
}
|
||||
$hostRuntimeText = Get-Content node_modules\@gamecult\eve-electron\src\live-provider-host.mjs -Raw
|
||||
if (-not $hostRuntimeText.Contains('eve-provider-preload-entry.cjs') -or
|
||||
-not $hostRuntimeText.Contains('registerEveProviderIpc') -or
|
||||
-not $hostRuntimeText.Contains('registerEveAssetProtocol') -or
|
||||
-not $hostRuntimeText.Contains('createEveElectronWindow')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: EveElectron does not own the live provider host lifecycle."
|
||||
}
|
||||
if (-not $mainText.Contains('eve-asset://asset') -or
|
||||
-not $mainText.Contains('assetProbe') -or
|
||||
$mainText.Contains('aetheria-cdn')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify EveElectron-owned provider asset delivery."
|
||||
}
|
||||
if (-not $mainText.Contains('await eveProvider.providerAdvertisement()')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify provider advertisement discovery through preload."
|
||||
}
|
||||
|
||||
if (-not $mainText.Contains('resolveEmbedded') -or
|
||||
-not $mainText.Contains('renderSplatsResolved') -or
|
||||
-not $mainText.Contains('gravityResolved') -or
|
||||
-not $mainText.Contains('objectsResolved') -or
|
||||
-not $mainText.Contains('eveFieldSurface') -or
|
||||
-not $mainText.Contains('embeddedDocuments')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify daemon Eve field document lowering."
|
||||
}
|
||||
|
||||
if (-not $mainText.Contains('await eveProvider.submitCommand') -or
|
||||
-not $mainText.Contains('eveReceipt?.commandId') -or
|
||||
-not $mainText.Contains('eveReceipt?.accepted') -or
|
||||
-not $mainText.Contains('eveReceipt?.route')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify typed operation receipts through preload."
|
||||
}
|
||||
|
||||
if (-not $mainText.Contains('AETHERIA_VERSE_ID') -or
|
||||
-not $mainText.Contains('--verse-id') -or
|
||||
-not $mainText.Contains('AETHERIA_DAEMON_ID') -or
|
||||
-not $mainText.Contains('--daemon-id')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron launcher does not configure daemon Verse identity."
|
||||
}
|
||||
if ($mainText.Contains('AETHERIA_RTS_') -or
|
||||
$mainText.Contains('Aetheria RTS') -or
|
||||
$mainText.Contains('starfire-rts') -or
|
||||
$mainText.Contains('aetheria-rts-daemon') -or
|
||||
$mainText.Contains('aetheria.rts.electron') -or
|
||||
$mainText.Contains('aetheria-rts-electron') -or
|
||||
$mainText.Contains('rtsCultMesh') -or
|
||||
$mainText.Contains('rtsDaemonId') -or
|
||||
$mainText.Contains('rtsVerseId')) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron launcher still uses RTS-branded daemon connection authority."
|
||||
}
|
||||
|
||||
$electron = Join-Path $root "node_modules\electron\dist\electron.exe"
|
||||
if (!(Test-Path -LiteralPath $electron)) {
|
||||
Write-Error "Stage 7C Electron RTS 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_ELECTRON_SMOKE = "1"
|
||||
$env:AETHERIA_RUNTIME_ROOT = $tempRoot
|
||||
$env:AETHERIA_CLIENT_CULTMESH_PORT = $port.ToString()
|
||||
$env:AETHERIA_VERSE_ID = "aetheria.stage7c.electron"
|
||||
$env:AETHERIA_DAEMON_ID = "stage7c-aetheria-daemon"
|
||||
$env:AETHERIA_ELECTRON_SMOKE_RESULT = $resultPath
|
||||
$process = Start-Process -FilePath $electron -ArgumentList "." -WorkingDirectory $root -Wait -PassThru
|
||||
if ($process.ExitCode -ne 0) {
|
||||
Write-Error "Stage 7C Electron RTS verifier failed: Electron exited with code $($process.ExitCode)."
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $resultPath)) {
|
||||
Write-Error "Stage 7C Electron RTS 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 RTS verifier failed: $($result.error)"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:\AETHERIA_ELECTRON_SMOKE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\AETHERIA_RUNTIME_ROOT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\AETHERIA_CLIENT_CULTMESH_PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\AETHERIA_VERSE_ID -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\AETHERIA_DAEMON_ID -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\AETHERIA_ELECTRON_SMOKE_RESULT -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "Stage 7C Electron RTS verifier passed."
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
$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.cc"
|
||||
$daemonOut = Join-Path $tempRoot "daemon.out.log"
|
||||
$daemonErr = Join-Path $tempRoot "daemon.err.log"
|
||||
$daemonProject = Join-Path $repoRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
|
||||
$port = Get-Random -Minimum 41000 -Maximum 64000
|
||||
$endpoint = $null
|
||||
$daemon = $null
|
||||
|
||||
try {
|
||||
$daemon = Start-Process -FilePath "dotnet" -ArgumentList @(
|
||||
"run",
|
||||
"--project",
|
||||
$daemonProject,
|
||||
"--",
|
||||
"--state",
|
||||
$statePath,
|
||||
"--client-cultmesh-host",
|
||||
"127.0.0.1",
|
||||
"--client-cultmesh-port",
|
||||
$port,
|
||||
"--tick-interval-ms",
|
||||
"20",
|
||||
"--fixed-delta-ms",
|
||||
"20",
|
||||
"--api-publication-interval-ms",
|
||||
"100000",
|
||||
"--no-odin-announcements"
|
||||
) -RedirectStandardOutput $daemonOut -RedirectStandardError $daemonErr -WindowStyle Hidden -PassThru
|
||||
|
||||
$deadline = (Get-Date).AddSeconds(40)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ($daemon.HasExited) {
|
||||
Write-Error "Stage 7C verifier failed: daemon exited early ($($daemon.ExitCode)).`n$(if (Test-Path $daemonErr) { Get-Content $daemonErr -Raw })`n$(if (Test-Path $daemonOut) { Get-Content $daemonOut -Raw })"
|
||||
}
|
||||
|
||||
if ((Test-Path $daemonOut) -and ((Get-Content $daemonOut -Raw) -match "Aetheria client CultMesh endpoint: (rudp://127\.0\.0\.1:\d+)")) {
|
||||
$endpoint = $Matches[1]
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
|
||||
if (Test-Path $daemonOut) {
|
||||
Get-Content $daemonOut | Out-Host
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($endpoint)) {
|
||||
Write-Error "Stage 7C verifier failed: daemon did not report a client CultMesh endpoint.`n$(if (Test-Path $daemonErr) { Get-Content $daemonErr -Raw })`n$(if (Test-Path $daemonOut) { Get-Content $daemonOut -Raw })"
|
||||
}
|
||||
|
||||
$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({
|
||||
uri: "cultmesh://aetheria/daemon/stage7c-aetheria-daemon",
|
||||
peerId: "stage7c-daemon",
|
||||
verseId: "aetheria.local",
|
||||
role: "aetheria-daemon",
|
||||
endpoints: [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 query did not produce a frame id.");
|
||||
if (!Array.isArray(viewport.objects) || viewport.objects.length === 0)
|
||||
throw new Error("Viewport query did not expose visible objects.");
|
||||
if (objectsViewport.schema !== "gamecult.fields.objects.v1")
|
||||
throw new Error(`Objects viewport used unexpected schema: ${objectsViewport.schema}`);
|
||||
if (gravityViewport.schema !== "gamecult.fields.gravity.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 the selected-object query.");
|
||||
|
||||
const selected = await client.selectedObject({ entityIndex: selectedIndex });
|
||||
if (!selected.selected)
|
||||
throw new Error(`Selected-object query 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 query did not return equipment and cargo arrays.");
|
||||
|
||||
const health = await client.daemonHealth();
|
||||
if (health.status !== "healthy")
|
||||
throw new Error(`Daemon health document 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.surfaces.v1")
|
||||
throw new Error(`Unexpected Aetheria surface catalog id: ${surfaceCatalog.catalogId}`);
|
||||
if (!surfaceCatalog.surfaces.some(surface => surface.surfaceId === "gamecult.aetheria.viewport_feed.v1" && surface.kind === "live-feed"))
|
||||
throw new Error("Aetheria 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 === "document" &&
|
||||
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 document 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.viewport_feed.v1"))
|
||||
throw new Error("Aetheria surface catalog index did not group the viewport live feed.");
|
||||
if (!Array.isArray(surfaceIndex.queries) || !surfaceIndex.queries.some(surface => surface.surfaceId === "gamecult.fields.objects.v1"))
|
||||
throw new Error("RTS surface catalog index did not group generated query surfaces.");
|
||||
if (!Array.isArray(surfaceIndex.documents) || !surfaceIndex.documents.some(surface => surface.surfaceId === "daemon:aetheria.frame.latest.v1"))
|
||||
throw new Error("RTS surface catalog index did not group generated document 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,
|
||||
documents: surfaceIndex.documents.length
|
||||
}
|
||||
}, null, 2));
|
||||
await client.close();
|
||||
'@ | 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 handles could not read daemon publications."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($daemon -and -not $daemon.HasExited) {
|
||||
Stop-Process -Id $daemon.Id -Force
|
||||
$daemon.WaitForExit()
|
||||
}
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "Stage 7C local runtime verifier passed."
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
$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,
|
||||
"--client-cultmesh-host",
|
||||
"127.0.0.1",
|
||||
"--client-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_RESOLVED_RUDP_ENDPOINT;
|
||||
const clientModulePath = process.env.AETHERIA_STAGE7D_REMOTE_CLIENT_MODULE;
|
||||
if (!endpoint)
|
||||
throw new Error("Stage 7D verifier missing resolved RUDP 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({
|
||||
uri: "cultmesh://aetheria/daemon/stage7d-aetheria-daemon",
|
||||
peerId: "stage7d-daemon",
|
||||
verseId: "aetheria.local",
|
||||
role: "aetheria-daemon",
|
||||
endpoints: [endpoint],
|
||||
}, "unused-local-state.cc", "stage7d-verifier", {
|
||||
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_RESOLVED_RUDP_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_RESOLVED_RUDP_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."
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"preserveSymlinks": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmitOnError": true,
|
||||
"outDir": "electron-dist",
|
||||
"rootDir": "Electron",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"Electron/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmitOnError": true,
|
||||
"outDir": "wwwroot",
|
||||
"rootDir": "Client"
|
||||
},
|
||||
"include": [
|
||||
"Client/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Aetheria Starbridge</title>
|
||||
<link rel="stylesheet" href="../node_modules/@gamecult/eve-browser-lowering/styles.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="status" class="status">Connecting</div>
|
||||
<div class="window-drag-region" aria-hidden="true"></div>
|
||||
<div class="window-controls" aria-label="Window controls">
|
||||
<button class="window-control" type="button" data-window-control="minimize" aria-label="Minimize">-</button>
|
||||
<button class="window-control" type="button" data-window-control="maximize" aria-label="Maximize">[]</button>
|
||||
<button class="window-control window-control-close" type="button" data-window-control="close" aria-label="Close">x</button>
|
||||
</div>
|
||||
<main id="eve-surface-host" class="eve-surface-host" aria-label="Aetheria daemon Eve surface"></main>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
background: #020909;
|
||||
color: #e8edf0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-app-region: drag;
|
||||
overflow: hidden;
|
||||
background: #020909;
|
||||
}
|
||||
|
||||
.eve-surface-host {
|
||||
-webkit-app-region: no-drag;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: fixed;
|
||||
right: 112px;
|
||||
bottom: 10px;
|
||||
z-index: 30;
|
||||
max-width: min(560px, calc(100vw - 140px));
|
||||
overflow: hidden;
|
||||
color: rgba(219, 250, 255, 0.45);
|
||||
font: 400 11px/1.2 "Ubuntu", system-ui, sans-serif;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 15;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
min-width: 0;
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: rgba(219, 250, 255, 0.5);
|
||||
font-family: "Ubuntu", system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.window-control:hover,
|
||||
.window-control:focus-visible {
|
||||
background: rgba(219, 250, 255, 0.09);
|
||||
color: rgba(240, 252, 255, 0.95);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.window-control-close:hover,
|
||||
.window-control-close:focus-visible {
|
||||
background: rgba(255, 88, 88, 0.28);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj" />
|
||||
<ProjectReference Include="$(YmirRoot)\src\Ymir.Core\Ymir.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
|
||||
internal static class AetheriaDaemonGeneratedEntityFactory
|
||||
{
|
||||
public static AetheriaEntitySnapshot Create(
|
||||
string name,
|
||||
string kind,
|
||||
string factionKey,
|
||||
AetheriaDaemonLoadout loadout,
|
||||
double x = 0,
|
||||
double z = 0,
|
||||
string orbitKey = "",
|
||||
int securityLevel = 0,
|
||||
double securityRadius = 0)
|
||||
{
|
||||
var orbital = IsOrbital(kind);
|
||||
return new AetheriaEntitySnapshot
|
||||
{
|
||||
Name = name,
|
||||
Kind = kind,
|
||||
Position = new AetheriaVector3 { X = x, Z = z },
|
||||
Direction = new AetheriaVector2 { Y = 1 },
|
||||
LookDirection = new AetheriaVector2 { Y = 1 },
|
||||
Velocity = new AetheriaVector2(),
|
||||
FactionKey = factionKey,
|
||||
HullItemKey = loadout.HullItemKey,
|
||||
LoadoutGeneration = loadout.Receipt,
|
||||
OrbitKey = orbitKey,
|
||||
SecurityLevel = securityLevel,
|
||||
SecurityRadius = securityRadius,
|
||||
IsActive = true,
|
||||
HeatsinksEnabled = true,
|
||||
Visibility = orbital ? 760 : 420,
|
||||
VisibilitySourceCount = 1,
|
||||
StatGrids =
|
||||
[
|
||||
StatGrid("hull", orbital ? 420 : 130),
|
||||
StatGrid("shield", orbital ? 130 : 50),
|
||||
StatGrid("heat", 0)
|
||||
],
|
||||
Equipment = loadout.Equipment,
|
||||
CargoBays = loadout.CargoBays,
|
||||
DockingBays = loadout.DockingBays,
|
||||
WeaponGroups = loadout.WeaponGroups
|
||||
.Select(indices => new AetheriaWeaponGroupSnapshot { EquipmentIndices = indices })
|
||||
.ToArray(),
|
||||
CargoContents = loadout.CargoBays
|
||||
.Select((_, index) => new AetheriaCargoBayLoadout
|
||||
{
|
||||
Items = index == 0 ? loadout.Cargo : []
|
||||
})
|
||||
.ToArray(),
|
||||
DockingBayContents = loadout.DockingBays
|
||||
.Select(_ => new AetheriaCargoBayLoadout())
|
||||
.ToArray(),
|
||||
DockingBayAssignments = loadout.DockingBays.Select(_ => -1).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsOrbital(string kind) =>
|
||||
string.Equals(kind, "station", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(kind, "turret", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(kind, "orbital", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static AetheriaEntityStatGrid StatGrid(string name, double value) => new()
|
||||
{
|
||||
Name = name,
|
||||
Width = 1,
|
||||
Height = 1,
|
||||
Values = [value]
|
||||
};
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
public static class AetheriaDaemonHangarCoordinator
|
||||
{
|
||||
public const string LocalPlayerKey = "player:local";
|
||||
public const string StarterShipId = "ship:local:vanguard";
|
||||
public const string StarterLoadoutName = "Vanguard One";
|
||||
|
||||
public static async Task EnsureAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now)
|
||||
{
|
||||
var existing = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
if (existing != null)
|
||||
return;
|
||||
|
||||
var factionKey = (catalog.Corporations ?? [])
|
||||
.Select(value => value.CorporationKey)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? throw new InvalidDataException("Hangar creation requires one typed corporation.");
|
||||
var generator = new AetheriaDaemonLoadoutGenerator(
|
||||
catalog,
|
||||
AetheriaDaemonZoneGenerator.GenerationSeed,
|
||||
0,
|
||||
new Dictionary<string, int>(StringComparer.Ordinal) { [factionKey] = 0 },
|
||||
new Dictionary<int, IReadOnlyList<int>> { [0] = [] },
|
||||
isPrelude: true);
|
||||
var generated = generator.Build("ship", factionKey);
|
||||
var loadoutCommit = new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = StarterLoadoutName,
|
||||
OwnerPlayerKey = LocalPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(generated, factionKey)
|
||||
};
|
||||
var loadout = AetheriaRuntimeStateMapper.ToLoadoutTemplate(loadoutCommit, now);
|
||||
var loadoutKey = AetheriaRuntimeStateMapper.LoadoutKey(loadout.Name);
|
||||
var installed = generated.Equipment
|
||||
.Concat(generated.CargoBays)
|
||||
.Concat(generated.DockingBays)
|
||||
.Select(value => value.ItemKey)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var inventory = catalog.EquipmentItems
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.ItemKey) && !installed.Contains(value.ItemKey))
|
||||
.OrderBy(value => value.Price)
|
||||
.ThenBy(value => value.ItemKey, StringComparer.Ordinal)
|
||||
.Take(24)
|
||||
.Select(value => new AetheriaHangarItemStack { ItemKey = value.ItemKey, Quantity = 1 })
|
||||
.ToArray();
|
||||
|
||||
await node.MutableDocument<AetheriaLoadoutTemplate>(loadoutKey).ReplaceAsync(loadout).ConfigureAwait(false);
|
||||
await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey).ReplaceAsync(new AetheriaHangarState
|
||||
{
|
||||
HangarId = "local",
|
||||
PlayerKey = LocalPlayerKey,
|
||||
Revision = 1,
|
||||
Ships =
|
||||
[
|
||||
new AetheriaHangarShip
|
||||
{
|
||||
ShipId = StarterShipId,
|
||||
HullItemKey = generated.HullItemKey,
|
||||
LoadoutTemplateKey = loadoutKey.ToString(),
|
||||
Status = AetheriaHangarShipStatuses.Available
|
||||
}
|
||||
],
|
||||
Inventory = inventory,
|
||||
LoadoutTemplateKeys = [loadoutKey.ToString()],
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<AetheriaDeploymentReceipt> LaunchTerminusAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string requestId,
|
||||
string shipId,
|
||||
long expectedRevision,
|
||||
string now)
|
||||
{
|
||||
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
|
||||
var receipt = await AetheriaHangar.AdmitAsync(node, new AetheriaDeploymentRequest
|
||||
{
|
||||
RequestId = requestId,
|
||||
PlayerKey = hangar.PlayerKey,
|
||||
Mode = AetheriaGameModes.Terminus,
|
||||
ShipId = shipId,
|
||||
LoadoutTemplateKey = ship?.LoadoutTemplateKey ?? "",
|
||||
ExpectedHangarRevision = expectedRevision,
|
||||
ModePolicyId = AetheriaModePolicies.TerminusLocal
|
||||
}, now).ConfigureAwait(false);
|
||||
if (!receipt.Accepted)
|
||||
return receipt;
|
||||
|
||||
await AetheriaDaemonZoneGenerator.WritePlayableRunAsync(
|
||||
node,
|
||||
catalog,
|
||||
now,
|
||||
AetheriaDaemonTerminusScenarios.Standard,
|
||||
receipt).ConfigureAwait(false);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
public static async Task<bool> CanContinueTerminusAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
string deploymentId)
|
||||
{
|
||||
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
var ship = (hangar?.Ships ?? []).SingleOrDefault(value =>
|
||||
string.Equals(value.ShipId, shipId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.ActiveDeploymentId, deploymentId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.Status, AetheriaHangarShipStatuses.Deployed, StringComparison.Ordinal));
|
||||
if (ship == null)
|
||||
return false;
|
||||
var deployment = (hangar!.Deployments ?? []).SingleOrDefault(value =>
|
||||
value.Accepted &&
|
||||
string.Equals(value.DeploymentId, deploymentId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal));
|
||||
if (deployment == null)
|
||||
return false;
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
return !string.IsNullOrWhiteSpace(settings?.ActiveRunKey);
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(
|
||||
AetheriaDaemonLoadout source,
|
||||
string factionKey) => new()
|
||||
{
|
||||
Name = StarterLoadoutName,
|
||||
Kind = "ship",
|
||||
FactionKey = factionKey,
|
||||
Hull = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = source.HullItemKey,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Quantity = 1,
|
||||
Enabled = true
|
||||
},
|
||||
Equipment = source.Equipment.Select(ToRuntimeSlot).ToArray(),
|
||||
CargoBays = source.CargoBays.Select(ToRuntimeSlot).ToArray(),
|
||||
DockingBays = source.DockingBays.Select(ToRuntimeSlot).ToArray(),
|
||||
CargoContents = source.CargoBays.Select((_, index) => new AetheriaRuntimeCargoBayLoadoutCommit
|
||||
{
|
||||
Items = index == 0 ? source.Cargo.Select(ToRuntimeSlot).ToArray() : []
|
||||
}).ToArray(),
|
||||
DockingBayContents = source.DockingBays.Select(_ => new AetheriaRuntimeCargoBayLoadoutCommit()).ToArray(),
|
||||
DockingBayAssignments = source.DockingBays.Select(_ => -1).ToArray(),
|
||||
WeaponGroups = source.WeaponGroups.Select(group => (IReadOnlyList<int>)group.ToArray()).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaEntityItemSlot slot) => new()
|
||||
{
|
||||
X = slot.Position?.X ?? 0,
|
||||
Y = slot.Position?.Y ?? 0,
|
||||
Rotation = slot.Rotation,
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = slot.ItemKey,
|
||||
Quality = slot.Quality,
|
||||
Durability = slot.Durability,
|
||||
Quantity = slot.Quantity,
|
||||
Enabled = slot.Enabled,
|
||||
OverrideShutdown = slot.OverrideShutdown,
|
||||
Temperature = slot.Temperature
|
||||
}
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaLoadoutItemSlot slot) => new()
|
||||
{
|
||||
X = slot.Position?.X ?? 0,
|
||||
Y = slot.Position?.Y ?? 0,
|
||||
Rotation = slot.Rotation,
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,448 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using Random = CultMath.Random;
|
||||
|
||||
public sealed class AetheriaDaemonLoadoutGenerator
|
||||
{
|
||||
private const double RareGenerationWeight = 0.05;
|
||||
private readonly AetheriaRuntimeCatalogSnapshot _catalog;
|
||||
private Random _random;
|
||||
private readonly uint _seed;
|
||||
private readonly int _zoneIndex;
|
||||
private readonly IReadOnlyDictionary<string, int> _homeZones;
|
||||
private readonly IReadOnlyDictionary<int, IReadOnlyList<int>> _adjacency;
|
||||
private readonly double _priceExponent;
|
||||
private readonly bool _isPrelude;
|
||||
|
||||
public AetheriaDaemonLoadoutGenerator(
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
uint seed,
|
||||
int zoneIndex,
|
||||
IReadOnlyDictionary<string, int> homeZones,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<int>> adjacency,
|
||||
double priceExponent = 0.5,
|
||||
bool isPrelude = false)
|
||||
{
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
_seed = seed == 0 ? 1u : seed;
|
||||
_random = new Random(_seed);
|
||||
_zoneIndex = zoneIndex;
|
||||
_homeZones = homeZones ?? throw new ArgumentNullException(nameof(homeZones));
|
||||
_adjacency = adjacency ?? throw new ArgumentNullException(nameof(adjacency));
|
||||
_priceExponent = priceExponent;
|
||||
_isPrelude = isPrelude;
|
||||
}
|
||||
|
||||
public AetheriaDaemonLoadout Build(
|
||||
string entityKind,
|
||||
string availabilityFactionKey,
|
||||
string hullItemKey = "")
|
||||
{
|
||||
var hullType = string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) ? "Station" : "Ship";
|
||||
var hull = Pick(availabilityFactionKey, 0, item =>
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.Hull, StringComparison.Ordinal) &&
|
||||
string.Equals(item.HullType, hullType, StringComparison.Ordinal) &&
|
||||
(string.IsNullOrWhiteSpace(hullItemKey) || string.Equals(item.ItemKey, hullItemKey, StringComparison.Ordinal)))
|
||||
?? throw new InvalidOperationException($"No available {hullType} hull for faction {availabilityFactionKey}.");
|
||||
|
||||
var occupied = new HashSet<(int X, int Y)>();
|
||||
foreach (var hardpoint in hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
|
||||
foreach (var cell in hardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
|
||||
occupied.Add((hardpoint.PositionX + cell.X, hardpoint.PositionY + cell.Y));
|
||||
var slots = new List<AetheriaEntityItemSlot>();
|
||||
var hasDedicatedDockingMounts = (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
|
||||
.Any(hardpoint => string.Equals(hardpoint.Type, "DockingBay", StringComparison.Ordinal));
|
||||
if (string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) && !hasDedicatedDockingMounts)
|
||||
AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2,
|
||||
item => string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal) &&
|
||||
!string.Equals(item.HardpointType, "DockingBay", StringComparison.Ordinal));
|
||||
|
||||
var previous = new List<AetheriaRuntimeCatalogItem>();
|
||||
foreach (var hardpoint in (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
|
||||
.OrderByDescending(value => value.OccupiedCells))
|
||||
{
|
||||
var controllerKind = string.Equals(hardpoint.Type, "ControlModule", StringComparison.Ordinal)
|
||||
? string.Equals(entityKind, "ship", StringComparison.OrdinalIgnoreCase) ? "Cockpit" : "TurretController"
|
||||
: "";
|
||||
var item = previous.FirstOrDefault(candidate => FitsHardpoint(candidate, hardpoint));
|
||||
item ??= PickHardpoint(availabilityFactionKey, hardpoint, candidate =>
|
||||
(IsGear(candidate) || IsDockingBay(candidate)) && FitsHardpoint(candidate, hardpoint) &&
|
||||
(controllerKind.Length == 0 || HasBehavior(candidate, controllerKind)));
|
||||
if (item == null)
|
||||
{
|
||||
if (controllerKind.Length > 0)
|
||||
throw new InvalidOperationException($"No available {controllerKind} fits {hardpoint.Type}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var rotation = ParseRotation(hardpoint.Rotation);
|
||||
slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey, rotation));
|
||||
Reserve(occupied, item, hardpoint.PositionX, hardpoint.PositionY, rotation);
|
||||
previous.Add(item);
|
||||
}
|
||||
|
||||
var cargoBay = AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 3, item =>
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) &&
|
||||
item.InteriorOccupiedCells >= 8);
|
||||
AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item =>
|
||||
IsGear(item) && HasBehavior(item, "Reactor"));
|
||||
AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item =>
|
||||
IsGear(item) && HasBehavior(item, "Capacitor"));
|
||||
|
||||
var cargo = PackCargo(cargoBay, availabilityFactionKey,
|
||||
string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase));
|
||||
var equipment = slots
|
||||
.Where(value => !IsBay(_catalog.FindItem(value.ItemKey)))
|
||||
.ToArray();
|
||||
var cargoBays = slots
|
||||
.Where(value => string.Equals(
|
||||
_catalog.FindItem(value.ItemKey)?.Category,
|
||||
AetheriaRuntimeItemCategories.CargoBay,
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
var dockingBays = slots
|
||||
.Where(value => string.Equals(
|
||||
_catalog.FindItem(value.ItemKey)?.Category,
|
||||
AetheriaRuntimeItemCategories.DockingBay,
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
var selectedKeys = new[] { (Role: "hull", Key: hull.ItemKey) }
|
||||
.Concat(equipment.Select(value => (Role: "equipment", Key: value.ItemKey)))
|
||||
.Concat(cargoBays.Select(value => (Role: "cargo-bay", Key: value.ItemKey)))
|
||||
.Concat(dockingBays.Select(value => (Role: "docking-bay", Key: value.ItemKey)))
|
||||
.Concat(cargo.Select(value => (Role: "cargo", Key: value.Item.ItemKey)));
|
||||
var receipt = new AetheriaLoadoutGenerationReceipt
|
||||
{
|
||||
Seed = _seed,
|
||||
SourceZoneIndex = _zoneIndex,
|
||||
AvailabilityFactionKey = availabilityFactionKey,
|
||||
PriceExponent = _priceExponent,
|
||||
Selections = selectedKeys.Select(value => Selection(value.Role, value.Key, availabilityFactionKey)).ToArray()
|
||||
};
|
||||
var defaultWeaponGroup = equipment
|
||||
.Select((slot, index) => (slot, index))
|
||||
.Where(value => IsWeapon(_catalog.FindItem(value.slot.ItemKey)))
|
||||
.Select(value => value.index)
|
||||
.ToArray();
|
||||
var weaponGroups = defaultWeaponGroup.Length == 0
|
||||
? Array.Empty<int[]>()
|
||||
: new[] { defaultWeaponGroup };
|
||||
return new AetheriaDaemonLoadout(
|
||||
hull.ItemKey,
|
||||
equipment,
|
||||
cargoBays,
|
||||
dockingBays,
|
||||
cargo,
|
||||
weaponGroups,
|
||||
receipt);
|
||||
}
|
||||
|
||||
private AetheriaLoadoutGenerationSelection Selection(string role, string itemKey, string factionKey)
|
||||
{
|
||||
var item = _catalog.FindItem(itemKey) ?? throw new InvalidOperationException($"Generated item {itemKey} left the catalog.");
|
||||
var faction = _catalog.FindCorporation(factionKey);
|
||||
var allegiance = string.Equals(item.ManufacturerKey, factionKey, StringComparison.Ordinal)
|
||||
? 1
|
||||
: (faction?.Allegiances ?? Array.Empty<AetheriaRuntimeCorporationAllegiance>())
|
||||
.FirstOrDefault(value => value.CorporationKey == item.ManufacturerKey)?.Weight ?? 0;
|
||||
return new AetheriaLoadoutGenerationSelection
|
||||
{
|
||||
Role = role,
|
||||
ItemKey = item.ItemKey,
|
||||
ManufacturerKey = item.ManufacturerKey,
|
||||
Price = item.Price,
|
||||
ManufacturerDistance = _homeZones.TryGetValue(item.ManufacturerKey, out var home) ? Distance(_zoneIndex, home) : 1,
|
||||
Allegiance = allegiance
|
||||
};
|
||||
}
|
||||
|
||||
private AetheriaRuntimeCatalogItem AddFreeSpaceItem(
|
||||
AetheriaRuntimeCatalogItem hull,
|
||||
string factionKey,
|
||||
HashSet<(int X, int Y)> occupied,
|
||||
List<AetheriaEntityItemSlot> slots,
|
||||
double sizeExponent,
|
||||
Func<AetheriaRuntimeCatalogItem, bool> filter)
|
||||
{
|
||||
var candidates = Available(factionKey)
|
||||
.Where(filter)
|
||||
.Select(item => (Item: item, Fit: FindFit(hull, item, occupied)))
|
||||
.Where(value => value.Fit.HasValue)
|
||||
.ToArray();
|
||||
var selected = PickWeighted(candidates, sizeExponent, factionKey,
|
||||
value => value.Item);
|
||||
if (selected.Item == null || !selected.Fit.HasValue)
|
||||
throw new InvalidOperationException("No available mandatory equipment fits the remaining hull space.");
|
||||
var fit = selected.Fit.Value;
|
||||
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation));
|
||||
Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation);
|
||||
return selected.Item;
|
||||
}
|
||||
|
||||
private AetheriaRuntimeCatalogItem? TryAddFreeSpaceItem(
|
||||
AetheriaRuntimeCatalogItem hull,
|
||||
string factionKey,
|
||||
HashSet<(int X, int Y)> occupied,
|
||||
List<AetheriaEntityItemSlot> slots,
|
||||
double sizeExponent,
|
||||
Func<AetheriaRuntimeCatalogItem, bool> filter)
|
||||
{
|
||||
var candidates = Available(factionKey)
|
||||
.Where(filter)
|
||||
.Select(item => (Item: item, Fit: FindFit(hull, item, occupied)))
|
||||
.Where(value => value.Fit.HasValue)
|
||||
.ToArray();
|
||||
var selected = PickWeighted(candidates, sizeExponent, factionKey, value => value.Item);
|
||||
if (selected.Item == null || !selected.Fit.HasValue)
|
||||
return null;
|
||||
var fit = selected.Fit.Value;
|
||||
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation));
|
||||
Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation);
|
||||
return selected.Item;
|
||||
}
|
||||
|
||||
private AetheriaLoadoutItemSlot[] PackCargo(
|
||||
AetheriaRuntimeCatalogItem cargoBay,
|
||||
string factionKey,
|
||||
bool includeStationInventory)
|
||||
{
|
||||
var candidates = new List<AetheriaRuntimeCatalogItem>();
|
||||
if (includeStationInventory)
|
||||
{
|
||||
var pool = Available(factionKey)
|
||||
.Where(item => !string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) &&
|
||||
!string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal) &&
|
||||
(!string.Equals(item.Category, AetheriaRuntimeItemCategories.Hull, StringComparison.Ordinal) ||
|
||||
string.Equals(item.HullType, "Ship", StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
for (var draw = 0; draw < 16 && pool.Count > 0; draw++)
|
||||
{
|
||||
var selected = PickWeighted(pool, 1, factionKey, item => item);
|
||||
if (selected == null) break;
|
||||
candidates.Add(selected);
|
||||
pool.Remove(selected);
|
||||
}
|
||||
}
|
||||
|
||||
var interiorCells = Cells(cargoBay.InteriorShapeCells);
|
||||
var width = Math.Max(1, cargoBay.InteriorShapeWidth);
|
||||
var height = Math.Max(1, cargoBay.InteriorShapeHeight);
|
||||
var occupied = new HashSet<(int X, int Y)>();
|
||||
var packed = new List<AetheriaLoadoutItemSlot>();
|
||||
foreach (var item in candidates.OrderByDescending(value => value.OccupiedCells))
|
||||
{
|
||||
var fit = FindFit(interiorCells, width, height, item, occupied);
|
||||
if (!fit.HasValue) continue;
|
||||
Reserve(occupied, item, fit.Value.X, fit.Value.Y, fit.Value.Rotation);
|
||||
packed.Add(CargoSlot(fit.Value.X, fit.Value.Y, item.ItemKey, fit.Value.Rotation));
|
||||
}
|
||||
return packed.ToArray();
|
||||
}
|
||||
|
||||
private AetheriaRuntimeCatalogItem? Pick(
|
||||
string factionKey,
|
||||
double sizeExponent,
|
||||
Func<AetheriaRuntimeCatalogItem, bool> filter)
|
||||
{
|
||||
return PickWeighted(Available(factionKey).Where(filter).ToArray(), sizeExponent, factionKey, item => item);
|
||||
}
|
||||
|
||||
private AetheriaRuntimeCatalogItem? PickHardpoint(
|
||||
string factionKey,
|
||||
AetheriaRuntimeHardpoint hardpoint,
|
||||
Func<AetheriaRuntimeCatalogItem, bool> filter)
|
||||
{
|
||||
var candidates = Available(factionKey).Where(filter).ToArray();
|
||||
if (candidates.Length == 0)
|
||||
return null;
|
||||
var largestFootprint = candidates.Max(item => item.OccupiedCells);
|
||||
return PickWeighted(
|
||||
candidates.Where(item => item.OccupiedCells == largestFootprint).ToArray(),
|
||||
2,
|
||||
factionKey,
|
||||
item => item);
|
||||
}
|
||||
|
||||
private T PickWeighted<T>(
|
||||
IReadOnlyList<T> values,
|
||||
double sizeExponent,
|
||||
string factionKey,
|
||||
Func<T, AetheriaRuntimeCatalogItem> itemOf)
|
||||
{
|
||||
if (values.Count == 0) return default!;
|
||||
var weights = values.Select(value => Weight(itemOf(value), factionKey, sizeExponent)).ToArray();
|
||||
var total = weights.Sum();
|
||||
if (total <= 0) return default!;
|
||||
var cursor = _random.NextFloat() * total;
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
cursor -= weights[i];
|
||||
if (cursor <= 0) return values[i];
|
||||
}
|
||||
return values[^1];
|
||||
}
|
||||
|
||||
private IEnumerable<AetheriaRuntimeCatalogItem> Available(string factionKey)
|
||||
{
|
||||
var faction = _catalog.Corporations.FirstOrDefault(value =>
|
||||
string.Equals(value.CorporationKey, factionKey, StringComparison.Ordinal));
|
||||
if (faction == null) yield break;
|
||||
var allegiances = (faction.Allegiances ?? Array.Empty<AetheriaRuntimeCorporationAllegiance>())
|
||||
.ToDictionary(value => value.CorporationKey, value => value.Weight, StringComparer.Ordinal);
|
||||
foreach (var item in _catalog.EquipmentItems)
|
||||
{
|
||||
if (item.Price <= 0 || string.IsNullOrWhiteSpace(item.ManufacturerKey))
|
||||
continue;
|
||||
if (!_isPrelude &&
|
||||
(!_homeZones.ContainsKey(item.ManufacturerKey) || !allegiances.ContainsKey(item.ManufacturerKey)))
|
||||
continue;
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private double Weight(AetheriaRuntimeCatalogItem item, string factionKey, double sizeExponent)
|
||||
{
|
||||
var faction = _catalog.Corporations.First(value => value.CorporationKey == factionKey);
|
||||
double allegiance;
|
||||
if (string.Equals(item.ManufacturerKey, factionKey, StringComparison.Ordinal))
|
||||
{
|
||||
allegiance = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var authoredWeight = (faction.Allegiances ?? Array.Empty<AetheriaRuntimeCorporationAllegiance>())
|
||||
.FirstOrDefault(value => value.CorporationKey == item.ManufacturerKey)?.Weight ?? 0;
|
||||
var distance = _homeZones.TryGetValue(item.ManufacturerKey, out var home)
|
||||
? Distance(_zoneIndex, home)
|
||||
: 1;
|
||||
allegiance = authoredWeight / Math.Max(1, distance);
|
||||
}
|
||||
return allegiance * Math.Pow(Math.Max(1, item.OccupiedCells), sizeExponent) /
|
||||
Math.Pow(item.Price, _priceExponent) * RarityWeight(item);
|
||||
}
|
||||
|
||||
private static double RarityWeight(AetheriaRuntimeCatalogItem item) =>
|
||||
(item.Tags ?? Array.Empty<string>()).Contains("rarity:rare", StringComparer.Ordinal)
|
||||
? RareGenerationWeight
|
||||
: 1;
|
||||
|
||||
private int Distance(int start, int target)
|
||||
{
|
||||
if (start == target) return 1;
|
||||
var visited = new HashSet<int> { start };
|
||||
var queue = new Queue<(int Zone, int Distance)>();
|
||||
queue.Enqueue((start, 0));
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var adjacent in _adjacency.TryGetValue(current.Zone, out var values) ? values : Array.Empty<int>())
|
||||
{
|
||||
if (!visited.Add(adjacent)) continue;
|
||||
if (adjacent == target) return current.Distance + 1;
|
||||
queue.Enqueue((adjacent, current.Distance + 1));
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static bool FitsHardpoint(AetheriaRuntimeCatalogItem item, AetheriaRuntimeHardpoint hardpoint)
|
||||
{
|
||||
if (!string.Equals(item.HardpointType, hardpoint.Type, StringComparison.Ordinal))
|
||||
return false;
|
||||
var target = Cells(hardpoint.ShapeCells);
|
||||
return RotatedCells(item, ParseRotation(hardpoint.Rotation)).All(target.Contains);
|
||||
}
|
||||
|
||||
private static (int X, int Y, int Rotation)? FindFit(
|
||||
AetheriaRuntimeCatalogItem hull,
|
||||
AetheriaRuntimeCatalogItem item,
|
||||
HashSet<(int X, int Y)> occupied)
|
||||
{
|
||||
return FindFit(Cells(hull.ShapeCells), Math.Max(1, hull.ShapeWidth), Math.Max(1, hull.ShapeHeight), item, occupied);
|
||||
}
|
||||
|
||||
private static (int X, int Y, int Rotation)? FindFit(
|
||||
HashSet<(int X, int Y)> targetCells,
|
||||
int width,
|
||||
int height,
|
||||
AetheriaRuntimeCatalogItem item,
|
||||
HashSet<(int X, int Y)> occupied)
|
||||
{
|
||||
for (var rotation = 0; rotation < 4; rotation++)
|
||||
{
|
||||
var cells = RotatedCells(item, rotation).ToArray();
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
if (cells.All(cell => targetCells.Contains((cell.X + x, cell.Y + y)) &&
|
||||
!occupied.Contains((cell.X + x, cell.Y + y))))
|
||||
return (x, y, rotation);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Reserve(HashSet<(int X, int Y)> occupied, AetheriaRuntimeCatalogItem item, int x, int y, int rotation)
|
||||
{
|
||||
foreach (var cell in RotatedCells(item, rotation)) occupied.Add((cell.X + x, cell.Y + y));
|
||||
}
|
||||
|
||||
private static HashSet<(int X, int Y)> Cells(IReadOnlyList<AetheriaRuntimeShapeCell>? cells) =>
|
||||
(cells ?? Array.Empty<AetheriaRuntimeShapeCell>()).Select(value => (value.X, value.Y)).ToHashSet();
|
||||
|
||||
private static IEnumerable<(int X, int Y)> RotatedCells(AetheriaRuntimeCatalogItem item, int rotation)
|
||||
=> AetheriaRuntimeEquipmentGridGeometry.RotatedCells(item, rotation);
|
||||
|
||||
private static int ParseRotation(string value) =>
|
||||
AetheriaRuntimeEquipmentGridGeometry.ParseRotation(value);
|
||||
|
||||
private static string RotationName(int rotation) =>
|
||||
AetheriaRuntimeEquipmentGridGeometry.RotationName(rotation);
|
||||
|
||||
private static bool HasBehavior(AetheriaRuntimeCatalogItem item, string kind) =>
|
||||
(item.BehaviorKinds ?? Array.Empty<string>()).Contains(kind, StringComparer.Ordinal);
|
||||
|
||||
private static bool IsGear(AetheriaRuntimeCatalogItem item) =>
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.Gear, StringComparison.Ordinal) ||
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.Weapon, StringComparison.Ordinal);
|
||||
|
||||
private static bool IsWeapon(AetheriaRuntimeCatalogItem? item) =>
|
||||
item != null &&
|
||||
(string.Equals(item.Category, AetheriaRuntimeItemCategories.Weapon, StringComparison.Ordinal) ||
|
||||
(item.BehaviorKinds ?? Array.Empty<string>()).Any(kind =>
|
||||
kind.Contains("Weapon", StringComparison.Ordinal)));
|
||||
|
||||
private static bool IsBay(AetheriaRuntimeCatalogItem? item) =>
|
||||
item != null &&
|
||||
(string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) ||
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal));
|
||||
|
||||
private static bool IsDockingBay(AetheriaRuntimeCatalogItem item) =>
|
||||
string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal);
|
||||
|
||||
private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey, int rotation = 0) => new()
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey,
|
||||
Quality = 1, Durability = 1, Quantity = 1, Enabled = true,
|
||||
Rotation = RotationName(rotation)
|
||||
};
|
||||
|
||||
private static AetheriaLoadoutItemSlot CargoSlot(int index, string itemKey) =>
|
||||
CargoSlot(index % 4, index / 4, itemKey);
|
||||
|
||||
private static AetheriaLoadoutItemSlot CargoSlot(int x, int y, string itemKey, int rotation = 0) => new()
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = x, Y = y },
|
||||
Item = new AetheriaLoadoutItem { ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true },
|
||||
Rotation = RotationName(rotation)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record AetheriaDaemonLoadout(
|
||||
string HullItemKey,
|
||||
AetheriaEntityItemSlot[] Equipment,
|
||||
AetheriaEntityItemSlot[] CargoBays,
|
||||
AetheriaEntityItemSlot[] DockingBays,
|
||||
AetheriaLoadoutItemSlot[] Cargo,
|
||||
int[][] WeaponGroups,
|
||||
AetheriaLoadoutGenerationReceipt Receipt);
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using MessagePack;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
internal static class AetheriaDaemonNativeCatalog
|
||||
{
|
||||
private const string NativeCatalogRevisionTag = "native-catalog-revision:1";
|
||||
private const string ZenithHullLegacyId = "82efc0a5-1ba5-4ff3-a281-b2e6e247521d";
|
||||
private const string MediumDockingBayLegacyId = "3e930a2c-ac72-4385-98aa-1c5b0b90db46";
|
||||
private const string DockyardHullLegacyId = "ca098005-8cc8-47f4-be99-7bc842805359";
|
||||
private const string DockyardBayLegacyId = "8ec30f8d-8536-48b4-bd64-65f29f229895";
|
||||
|
||||
public static string DockyardHullItemKey => ItemKey(DockyardHullLegacyId);
|
||||
public static string DockyardBayItemKey => ItemKey(DockyardBayLegacyId);
|
||||
|
||||
public static async Task<bool> EnsureAsync(AetheriaStateNode node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
var zenith = await node.MutableDocument<AetheriaItemDefinition>(
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(ZenithHullLegacyId))
|
||||
.ReadAsync()
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("The daemon-native dockyard requires the canonical Zenith station hull.");
|
||||
var mediumBay = await node.MutableDocument<AetheriaItemDefinition>(
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(MediumDockingBayLegacyId))
|
||||
.ReadAsync()
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("The daemon-native dockyard requires the canonical medium docking bay.");
|
||||
|
||||
var expectedHull = CreateDockyardHull(zenith);
|
||||
var expectedBay = CreateDockyardBay(mediumBay);
|
||||
var hullDocument = node.MutableDocument<AetheriaItemDefinition>(
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(DockyardHullLegacyId));
|
||||
var bayDocument = node.MutableDocument<AetheriaItemDefinition>(
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(DockyardBayLegacyId));
|
||||
var existingHull = await hullDocument.ReadAsync().ConfigureAwait(false);
|
||||
var existingBay = await bayDocument.ReadAsync().ConfigureAwait(false);
|
||||
var hullCurrent = HasGenerationTags(existingHull, expectedHull.Tags);
|
||||
var bayCurrent = HasGenerationTags(existingBay, expectedBay.Tags);
|
||||
|
||||
if (!hullCurrent)
|
||||
await hullDocument.ReplaceAsync(expectedHull).ConfigureAwait(false);
|
||||
if (!bayCurrent)
|
||||
await bayDocument.ReplaceAsync(expectedBay).ConfigureAwait(false);
|
||||
|
||||
return !hullCurrent || !bayCurrent;
|
||||
}
|
||||
|
||||
private static AetheriaItemDefinition CreateDockyardHull(AetheriaItemDefinition source)
|
||||
{
|
||||
const int width = 24;
|
||||
const int height = 24;
|
||||
var hardpoints = Enumerable.Range(0, 6)
|
||||
.SelectMany(index => new[]
|
||||
{
|
||||
Hardpoint("DockingBay", index * 4, 0, 4, 4, source.HullArmor),
|
||||
Hardpoint("DockingBay", index * 4, 20, 4, 4, source.HullArmor)
|
||||
})
|
||||
.Concat(new[]
|
||||
{
|
||||
Hardpoint("Reactor", 10, 10, 4, 4, source.HullArmor),
|
||||
Hardpoint("Radiator", 8, 10, 2, 2, source.HullArmor),
|
||||
Hardpoint("Radiator", 14, 10, 2, 2, source.HullArmor),
|
||||
Hardpoint("Radiator", 10, 14, 2, 2, source.HullArmor),
|
||||
Hardpoint("Radiator", 12, 14, 2, 2, source.HullArmor),
|
||||
Hardpoint("Sensors", 11, 16, 2, 2, source.HullArmor)
|
||||
})
|
||||
.ToArray();
|
||||
var hullCells = RectangleCells(0, 0, width, height);
|
||||
var interiorCells = RectangleCells(1, 1, width - 2, height - 2);
|
||||
|
||||
var hull = Copy(source, DockyardHullLegacyId, "Zenith Dockyard");
|
||||
hull.Description = "A high-capacity Zenith station variant built around twelve dedicated docking berths.";
|
||||
hull.Mass = source.Mass * 4.5;
|
||||
hull.Volume = hullCells.Length;
|
||||
hull.Price = checked(source.Price * 5);
|
||||
hull.ShapeWidth = width;
|
||||
hull.ShapeHeight = height;
|
||||
hull.OccupiedCells = hullCells.Length;
|
||||
hull.ShapeCells = hullCells;
|
||||
hull.InteriorShapeWidth = width;
|
||||
hull.InteriorShapeHeight = height;
|
||||
hull.InteriorOccupiedCells = interiorCells.Length;
|
||||
hull.InteriorShapeCells = interiorCells;
|
||||
hull.Hardpoints = hardpoints;
|
||||
hull.HullDrag = source.HullDrag * 1.75;
|
||||
hull.Tags = Tags(
|
||||
source.Tags,
|
||||
"origin:aetheria-daemon",
|
||||
NativeCatalogRevisionTag,
|
||||
SourceFingerprintTag(source),
|
||||
"station-role:dockyard",
|
||||
"docking-capacity:12",
|
||||
$"presentation-hull-item:{ItemKey(ZenithHullLegacyId)}");
|
||||
return hull;
|
||||
}
|
||||
|
||||
private static AetheriaItemDefinition CreateDockyardBay(AetheriaItemDefinition source)
|
||||
{
|
||||
var bay = Copy(source, DockyardBayLegacyId, "Dockyard Berth");
|
||||
bay.Description = "A medium docking bay packaged for a dedicated station docking hardpoint.";
|
||||
bay.HardpointType = "DockingBay";
|
||||
bay.Price = checked(source.Price * 2);
|
||||
bay.Tags = Tags(
|
||||
source.Tags,
|
||||
"origin:aetheria-daemon",
|
||||
NativeCatalogRevisionTag,
|
||||
SourceFingerprintTag(source),
|
||||
"station-role:dockyard");
|
||||
return bay;
|
||||
}
|
||||
|
||||
private static bool HasGenerationTags(AetheriaItemDefinition? existing, IEnumerable<string> expectedTags)
|
||||
{
|
||||
if (existing == null)
|
||||
return false;
|
||||
|
||||
var expectedGenerationTags = expectedTags.Where(tag =>
|
||||
tag.StartsWith("native-catalog-revision:", StringComparison.Ordinal) ||
|
||||
tag.StartsWith("native-source-sha256:", StringComparison.Ordinal));
|
||||
return expectedGenerationTags.All(tag => existing.Tags.Contains(tag, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static string SourceFingerprintTag(AetheriaItemDefinition source)
|
||||
{
|
||||
var payload = MessagePackSerializer.Serialize(source);
|
||||
return "native-source-sha256:" + Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static AetheriaItemDefinition Copy(AetheriaItemDefinition source, string legacyId, string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
Category = source.Category,
|
||||
LegacyId = legacyId,
|
||||
Description = source.Description,
|
||||
Mass = source.Mass,
|
||||
Volume = source.Volume,
|
||||
Tags = (source.Tags ?? []).ToArray(),
|
||||
ManufacturerLegacyId = source.ManufacturerLegacyId,
|
||||
Price = source.Price,
|
||||
ShapeWidth = source.ShapeWidth,
|
||||
ShapeHeight = source.ShapeHeight,
|
||||
OccupiedCells = source.OccupiedCells,
|
||||
HardpointType = source.HardpointType,
|
||||
HullType = source.HullType,
|
||||
BehaviorKinds = (source.BehaviorKinds ?? []).ToArray(),
|
||||
BehaviorCount = source.BehaviorCount,
|
||||
MaxStack = source.MaxStack,
|
||||
Stackable = source.Stackable,
|
||||
Duration = source.Duration,
|
||||
Durability = source.Durability,
|
||||
WeaponRange = source.WeaponRange,
|
||||
WeaponCaliber = source.WeaponCaliber,
|
||||
WeaponType = source.WeaponType,
|
||||
WeaponFireTypes = source.WeaponFireTypes,
|
||||
WeaponModifiers = source.WeaponModifiers,
|
||||
ShapeCells = Clone(source.ShapeCells),
|
||||
InteriorShapeWidth = source.InteriorShapeWidth,
|
||||
InteriorShapeHeight = source.InteriorShapeHeight,
|
||||
InteriorOccupiedCells = source.InteriorOccupiedCells,
|
||||
InteriorShapeCells = Clone(source.InteriorShapeCells),
|
||||
Hardpoints = (source.Hardpoints ?? []).Select(Clone).ToArray(),
|
||||
BehaviorPayloads = source.BehaviorPayloads ?? [],
|
||||
MinimumTemperature = source.MinimumTemperature,
|
||||
MaximumTemperature = source.MaximumTemperature,
|
||||
ThermalPerformanceCurveKeys = source.ThermalPerformanceCurveKeys ?? [],
|
||||
HullPrefab = source.HullPrefab,
|
||||
SimpleCommodityCategory = source.SimpleCommodityCategory,
|
||||
CompoundCommodityCategory = source.CompoundCommodityCategory,
|
||||
SpecificHeat = source.SpecificHeat,
|
||||
Conductivity = source.Conductivity,
|
||||
HullGridOffset = source.HullGridOffset,
|
||||
HullArmor = source.HullArmor,
|
||||
HullDrag = source.HullDrag,
|
||||
HullCanTow = source.HullCanTow,
|
||||
DockingMaxSizeX = source.DockingMaxSizeX,
|
||||
DockingMaxSizeY = source.DockingMaxSizeY,
|
||||
ActionBarIcon = source.ActionBarIcon,
|
||||
ThermalResilience = source.ThermalResilience,
|
||||
AudioStats = source.AudioStats ?? [],
|
||||
EffectivenessCurveKeys = source.EffectivenessCurveKeys ?? []
|
||||
};
|
||||
|
||||
private static AetheriaItemHardpoint Clone(AetheriaItemHardpoint source) => new()
|
||||
{
|
||||
Type = source.Type,
|
||||
PositionX = source.PositionX,
|
||||
PositionY = source.PositionY,
|
||||
ShapeWidth = source.ShapeWidth,
|
||||
ShapeHeight = source.ShapeHeight,
|
||||
OccupiedCells = source.OccupiedCells,
|
||||
ShapeCells = Clone(source.ShapeCells),
|
||||
Transform = source.Transform,
|
||||
Rotation = source.Rotation,
|
||||
Armor = source.Armor
|
||||
};
|
||||
|
||||
private static AetheriaItemHardpoint Hardpoint(
|
||||
string type,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
double armor) => new()
|
||||
{
|
||||
Type = type,
|
||||
PositionX = x,
|
||||
PositionY = y,
|
||||
ShapeWidth = width,
|
||||
ShapeHeight = height,
|
||||
OccupiedCells = width * height,
|
||||
ShapeCells = RectangleCells(0, 0, width, height),
|
||||
Rotation = "None",
|
||||
Armor = armor
|
||||
};
|
||||
|
||||
private static AetheriaShapeCell[] RectangleCells(int x, int y, int width, int height) =>
|
||||
Enumerable.Range(y, height)
|
||||
.SelectMany(row => Enumerable.Range(x, width)
|
||||
.Select(column => new AetheriaShapeCell { X = column, Y = row }))
|
||||
.ToArray();
|
||||
|
||||
private static AetheriaShapeCell[] Clone(IEnumerable<AetheriaShapeCell>? cells) =>
|
||||
(cells ?? []).Select(cell => new AetheriaShapeCell { X = cell.X, Y = cell.Y }).ToArray();
|
||||
|
||||
private static string[] Tags(IEnumerable<string>? tags, params string[] added) =>
|
||||
(tags ?? []).Concat(added).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
private static string ItemKey(string legacyId) => $"aetheria.item_definition:legacy:{legacyId}";
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
public static class AetheriaDaemonRegularRunWriter
|
||||
{
|
||||
public static async Task<AetheriaDaemonWrittenRun> WriteAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now,
|
||||
uint generationSeed,
|
||||
AetheriaDaemonRegularTopologySettings? topologySettings = null)
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
||||
if (generationSeed == 0) throw new ArgumentOutOfRangeException(nameof(generationSeed));
|
||||
|
||||
var factions = AetheriaDaemonRegularTopologyGenerator.ResolveFossilFactions(catalog);
|
||||
var world = AetheriaDaemonTutorialWorldGenerator.GenerateRegular(
|
||||
factions, generationSeed, topologySettings);
|
||||
var materialized = AetheriaDaemonTutorialWorldMaterializer.Materialize(
|
||||
world, factions, catalog, isPrelude: false, identityPrefix: "regular");
|
||||
var runId = $"sector-{generationSeed:x8}";
|
||||
var runKey = RunKey(runId);
|
||||
var corporationIndices = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
|
||||
.Select((corporation, index) => (corporation.CorporationKey, index))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.CorporationKey))
|
||||
.ToDictionary(value => value.CorporationKey, value => value.index, StringComparer.Ordinal);
|
||||
var zoneKeys = materialized.Topology.Zones
|
||||
.OrderBy(zone => zone.ZoneIndex)
|
||||
.Select(zone => ZoneKey(runId, zone.ZoneIndex))
|
||||
.ToArray();
|
||||
var playerEntityKey = EntityKey(runId, materialized.PlayerZoneIndex, materialized.PlayerEntityIndex);
|
||||
|
||||
foreach (var zone in materialized.Zones.Values.OrderBy(zone => zone.Topology.ZoneIndex))
|
||||
{
|
||||
var entityKeys = Enumerable.Range(0, zone.Entities.Length)
|
||||
.Select(index => EntityKey(runId, zone.Topology.ZoneIndex, index))
|
||||
.ToArray();
|
||||
await node.MutableDocument<AetheriaZoneState>(new CultRecordKey(ZoneKey(runId, zone.Topology.ZoneIndex)))
|
||||
.ReplaceAsync(new AetheriaZoneState
|
||||
{
|
||||
Name = zone.Topology.Name,
|
||||
Position = new AetheriaVector2 { X = zone.Topology.X, Y = zone.Topology.Y },
|
||||
AdjacentZoneIndices = zone.Topology.AdjacentZoneIndices.ToArray(),
|
||||
FactionIndices = zone.Topology.FactionKeys.Select(key => corporationIndices[key]).ToArray(),
|
||||
OwnerFactionIndex = string.IsNullOrWhiteSpace(zone.Topology.OwnerFactionKey)
|
||||
? -1
|
||||
: corporationIndices[zone.Topology.OwnerFactionKey],
|
||||
EntityKeys = entityKeys,
|
||||
Orbits = zone.Orbits,
|
||||
Bodies = zone.CelestialPlan.Bodies,
|
||||
GravityTerrainRadius = zone.CelestialPlan.Radius,
|
||||
GravityTerrainDepth = zone.CelestialPlan.GravityTerrainDepth,
|
||||
GravityTerrainDepthExponent = zone.CelestialPlan.GravityTerrainDepthExponent,
|
||||
GravityTerrainBoundaryFog = zone.CelestialPlan.GravityTerrainBoundaryFog,
|
||||
GravityTerrainWaveFrequency = 1,
|
||||
DroppedPickups = [],
|
||||
NextPickupIndex = 0
|
||||
}).ConfigureAwait(false);
|
||||
for (var entityIndex = 0; entityIndex < zone.Entities.Length; entityIndex++)
|
||||
{
|
||||
await node.MutableDocument<AetheriaEntitySnapshot>(new CultRecordKey(entityKeys[entityIndex]))
|
||||
.ReplaceAsync(zone.Entities[entityIndex])
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await node.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey)).ReplaceAsync(new AetheriaRunState
|
||||
{
|
||||
RunId = runId,
|
||||
IsTutorial = false,
|
||||
GameMode = AetheriaGameSessionState.AetheriaMode,
|
||||
EntranceZoneIndex = materialized.Topology.EntranceZoneIndex,
|
||||
ExitZoneIndex = materialized.Topology.ExitZoneIndex,
|
||||
CurrentZoneIndex = materialized.PlayerZoneIndex,
|
||||
DiscoveredZoneIndices = materialized.Topology.DiscoveredZoneIndices.ToArray(),
|
||||
ZoneKeys = zoneKeys,
|
||||
FactionRelationships = materialized.Topology.HomeZoneByFactionKey.Keys
|
||||
.Select(key => new AetheriaFactionRelationshipState
|
||||
{
|
||||
FactionKey = key,
|
||||
Relationship = "Neutral",
|
||||
Standing = 0
|
||||
})
|
||||
.ToArray(),
|
||||
HomeZones = materialized.Topology.HomeZoneByFactionKey
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new AetheriaFactionZoneState
|
||||
{
|
||||
FactionIndex = corporationIndices[pair.Key],
|
||||
ZoneIndex = pair.Value
|
||||
})
|
||||
.ToArray(),
|
||||
BossZones = materialized.Topology.BossZoneByFactionKey
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new AetheriaFactionZoneState
|
||||
{
|
||||
FactionIndex = corporationIndices[pair.Key],
|
||||
ZoneIndex = pair.Value
|
||||
})
|
||||
.ToArray(),
|
||||
GenerationSeed = generationSeed,
|
||||
CurrentEntityKey = playerEntityKey,
|
||||
LifecyclePhase = AetheriaRuntimeRunLifecycle.Active,
|
||||
TerminalFrameId = -1,
|
||||
AgentTasks = materialized.Zones.Values
|
||||
.OrderBy(zone => zone.Topology.ZoneIndex)
|
||||
.SelectMany(zone => zone.AgentTasks)
|
||||
.ToArray(),
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
settings.ActiveRunKey = runKey;
|
||||
settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Pilot" : settings.PlayerName;
|
||||
settings.LastUpdatedAtUtc = now;
|
||||
await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReplaceAsync(settings).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new AetheriaDaemonWrittenRun(runId, runKey, playerEntityKey, false)
|
||||
{
|
||||
SessionMode = AetheriaGameSessionState.AetheriaMode
|
||||
};
|
||||
}
|
||||
|
||||
public static string RunKey(string runId) => $"global:aetheria.run_state.{runId}.v1";
|
||||
public static string ZoneKey(string runId, int zoneIndex) =>
|
||||
$"global:aetheria.zone_state.{runId}.{zoneIndex}.v1";
|
||||
public static string EntityKey(string runId, int zoneIndex, int entityIndex) =>
|
||||
$"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
public sealed record AetheriaDaemonRegularTopologySettings(
|
||||
int ZoneCount,
|
||||
float LinkDensity,
|
||||
int MegaCount,
|
||||
int BossCount)
|
||||
{
|
||||
public static AetheriaDaemonRegularTopologySettings Fossil { get; } =
|
||||
new(196, 0.5f, 12, 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Daemon-owned port of the fossil non-tutorial Galaxy constructor. It owns
|
||||
/// regular-sector corporation selection, entrance/exit, boss chokepoints,
|
||||
/// full-radius influence, discovery, and names. Zone contents consume this
|
||||
/// graph and cannot revise it.
|
||||
/// </summary>
|
||||
public static class AetheriaDaemonRegularTopologyGenerator
|
||||
{
|
||||
public static AetheriaDaemonTutorialTopology GenerateFossil(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> availableFactions,
|
||||
uint seed,
|
||||
AetheriaDaemonRegularTopologySettings? settings = null)
|
||||
{
|
||||
settings ??= AetheriaDaemonRegularTopologySettings.Fossil;
|
||||
if (settings.ZoneCount < 3)
|
||||
throw new ArgumentOutOfRangeException(nameof(settings), "Regular topology requires at least three zones.");
|
||||
if (settings.MegaCount < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(settings), "Regular topology requires at least one corporation.");
|
||||
if (availableFactions == null || availableFactions.Count == 0)
|
||||
throw new InvalidDataException("Regular topology requires typed corporations.");
|
||||
|
||||
var random = new CultMath.Random(seed);
|
||||
var factions = availableFactions
|
||||
.OrderBy(_ => random.NextFloat())
|
||||
.Take(settings.MegaCount)
|
||||
.ToArray();
|
||||
var points = AetheriaDaemonTutorialTopologyGenerator.GeneratePoints(
|
||||
settings.ZoneCount, ref random, MainCloudDensity);
|
||||
var links = AetheriaDaemonTutorialTopologyGenerator.DelaunayLinks(points);
|
||||
AetheriaDaemonTutorialTopologyGenerator.PruneLinks(
|
||||
points, links, settings.LinkDensity, MainCloudDensity);
|
||||
var distances = AetheriaDaemonTutorialTopologyGenerator.BuildDistances(points.Length, links);
|
||||
|
||||
var exit = AetheriaDaemonTutorialTopologyGenerator.MaxZone(points.Length, zone =>
|
||||
Enumerable.Range(0, points.Length).Sum(other => distances[zone, other]));
|
||||
var entrance = AetheriaDaemonTutorialTopologyGenerator.MaxZone(points.Length, zone =>
|
||||
distances[exit, zone]);
|
||||
var bossZones = PlaceBosses(
|
||||
factions, settings.BossCount, entrance, exit, links, distances);
|
||||
var homes = PlaceHomes(factions, entrance, exit, bossZones, distances);
|
||||
|
||||
var zoneFactions = new string[points.Length][];
|
||||
var owners = new string[points.Length];
|
||||
for (var zoneIndex = 0; zoneIndex < points.Length; zoneIndex++)
|
||||
{
|
||||
zoneFactions[zoneIndex] = factions
|
||||
.Where(faction => distances[zoneIndex, homes[faction.CorporationKey]] <= faction.InfluenceDistance)
|
||||
.Select(faction => faction.CorporationKey)
|
||||
.ToArray();
|
||||
owners[zoneIndex] = factions
|
||||
.Where(faction => distances[zoneIndex, homes[faction.CorporationKey]] <= faction.InfluenceDistance)
|
||||
.OrderBy(faction => distances[zoneIndex, homes[faction.CorporationKey]])
|
||||
.ThenBy(faction => Array.IndexOf(factions, faction))
|
||||
.Select(faction => faction.CorporationKey)
|
||||
.FirstOrDefault() ?? "";
|
||||
}
|
||||
|
||||
var discovered = new[] { entrance }
|
||||
.Concat(AetheriaDaemonTutorialTopologyGenerator.Neighbors(entrance, links))
|
||||
.Distinct()
|
||||
.OrderBy(index => index)
|
||||
.ToArray();
|
||||
var nameSeed = (uint)random.NextInt(1, int.MaxValue);
|
||||
var zoneNames = AetheriaDaemonTutorialTopologyGenerator.GenerateNames(
|
||||
factions, owners, nameSeed, order: 4);
|
||||
var zones = Enumerable.Range(0, points.Length)
|
||||
.Select(index => new AetheriaDaemonTutorialZoneTopology(
|
||||
index,
|
||||
zoneNames[index],
|
||||
points[index].X,
|
||||
points[index].Y,
|
||||
AetheriaDaemonTutorialTopologyGenerator.Neighbors(index, links).OrderBy(value => value).ToArray(),
|
||||
zoneFactions[index],
|
||||
owners[index]))
|
||||
.ToArray();
|
||||
return new AetheriaDaemonTutorialTopology(
|
||||
seed, 91.29439f, entrance, discovered, homes, zones)
|
||||
{
|
||||
ExitZoneIndex = exit,
|
||||
BossZoneByFactionKey = bossZones
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AetheriaDaemonTutorialFactionInput> ResolveFossilFactions(
|
||||
GameCult.Aetheria.State.Verse.AetheriaRuntimeCatalogSnapshot catalog)
|
||||
{
|
||||
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
||||
return (catalog.Corporations ?? Array.Empty<GameCult.Aetheria.State.Verse.AetheriaRuntimeCorporation>())
|
||||
.Where(corporation => corporation != null && !string.IsNullOrWhiteSpace(corporation.CorporationKey))
|
||||
.Where(corporation => !string.IsNullOrWhiteSpace(corporation.GeonameFileKey))
|
||||
.Select(corporation =>
|
||||
{
|
||||
var names = catalog.FindNameFile(corporation.GeonameFileKey);
|
||||
if (names == null || names.Names.Count == 0)
|
||||
throw new InvalidDataException(
|
||||
$"Regular-sector corporation '{corporation.Name}' has no typed geoname corpus.");
|
||||
return new AetheriaDaemonTutorialFactionInput(
|
||||
corporation.CorporationKey,
|
||||
corporation.Name ?? "",
|
||||
corporation.ShortName ?? "",
|
||||
corporation.InfluenceDistance)
|
||||
{
|
||||
TrainingNames = names.Names,
|
||||
BossHullItemKey = corporation.BossHullItemKey ?? ""
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static float MainCloudDensity(float x, float y)
|
||||
{
|
||||
var point = new CultMath.float2(x + 91.29439f, y + 91.29439f);
|
||||
var frequency = 1f;
|
||||
var amplitude = 0.5f;
|
||||
var sum = 0f;
|
||||
for (var octave = 0; octave < 10; octave++)
|
||||
{
|
||||
var noise = MathF.Abs(CultMath.math.simplex_noise(point * frequency));
|
||||
sum += (octave < 4 ? 1f - noise : noise) * amplitude;
|
||||
frequency *= 2f;
|
||||
amplitude *= 0.7f;
|
||||
}
|
||||
return MathF.Pow(sum + 0.3f, 10f) * 0.01f;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> PlaceBosses(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
int bossCount,
|
||||
int entrance,
|
||||
int exit,
|
||||
IReadOnlySet<AetheriaDaemonTutorialTopologyGenerator.Edge> links,
|
||||
int[,] distances)
|
||||
{
|
||||
var path = ShortestPath(entrance, exit, links);
|
||||
var chokepoints = path
|
||||
.Where(zone => AetheriaDaemonTutorialTopologyGenerator.Neighbors(zone, links).Count() > 2)
|
||||
.Where(zone => !ConnectedWithoutZone(entrance, exit, zone, links, distances.GetLength(0)))
|
||||
.ToArray();
|
||||
var bossZones = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var faction in factions
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.BossHullItemKey))
|
||||
.Take(Math.Max(0, bossCount)))
|
||||
{
|
||||
var candidates = chokepoints.Length == 0 ? path : chokepoints;
|
||||
bossZones[faction.CorporationKey] = AetheriaDaemonTutorialTopologyGenerator.MaxZone(candidates, zone =>
|
||||
{
|
||||
var score = (double)distances[exit, zone] * distances[entrance, zone];
|
||||
foreach (var occupied in bossZones.Values)
|
||||
score *= distances[occupied, zone];
|
||||
return score;
|
||||
});
|
||||
}
|
||||
return bossZones;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> PlaceHomes(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
int entrance,
|
||||
int exit,
|
||||
IReadOnlyDictionary<string, int> bossZones,
|
||||
int[,] distances)
|
||||
{
|
||||
var homes = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var faction in factions.Where(value => bossZones.ContainsKey(value.CorporationKey)))
|
||||
{
|
||||
var candidates = Enumerable.Range(0, distances.GetLength(0))
|
||||
.Where(zone => distances[zone, bossZones[faction.CorporationKey]] <= faction.InfluenceDistance);
|
||||
homes[faction.CorporationKey] = AetheriaDaemonTutorialTopologyGenerator.MaxZone(candidates, zone =>
|
||||
{
|
||||
var score = (double)AetheriaDaemonTutorialTopologyGenerator.RegionSize(
|
||||
zone, faction.InfluenceDistance, distances);
|
||||
foreach (var occupied in homes.Values)
|
||||
score *= Math.Sqrt(distances[occupied, zone]);
|
||||
return score;
|
||||
});
|
||||
}
|
||||
foreach (var faction in factions.Where(value => !homes.ContainsKey(value.CorporationKey)))
|
||||
{
|
||||
homes[faction.CorporationKey] = AetheriaDaemonTutorialTopologyGenerator.MaxZone(
|
||||
distances.GetLength(0), zone =>
|
||||
{
|
||||
var score = Math.Pow(
|
||||
AetheriaDaemonTutorialTopologyGenerator.RegionSize(
|
||||
zone, faction.InfluenceDistance, distances), homes.Count);
|
||||
score *= distances[exit, zone] * distances[entrance, zone];
|
||||
foreach (var occupied in homes.Values)
|
||||
score *= Math.Sqrt(distances[occupied, zone]);
|
||||
foreach (var boss in bossZones.Values)
|
||||
score *= Math.Sqrt(distances[boss, zone]);
|
||||
return score;
|
||||
});
|
||||
}
|
||||
return homes;
|
||||
}
|
||||
|
||||
private static int[] ShortestPath(
|
||||
int source,
|
||||
int target,
|
||||
IReadOnlySet<AetheriaDaemonTutorialTopologyGenerator.Edge> links)
|
||||
{
|
||||
var queue = new Queue<int>();
|
||||
var parents = new Dictionary<int, int>();
|
||||
queue.Enqueue(source);
|
||||
parents[source] = -1;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (current == target) break;
|
||||
foreach (var neighbor in AetheriaDaemonTutorialTopologyGenerator.Neighbors(current, links).OrderBy(value => value))
|
||||
{
|
||||
if (parents.ContainsKey(neighbor)) continue;
|
||||
parents[neighbor] = current;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
var path = new List<int>();
|
||||
for (var cursor = target; cursor >= 0; cursor = parents[cursor])
|
||||
path.Add(cursor);
|
||||
path.Reverse();
|
||||
return path.ToArray();
|
||||
}
|
||||
|
||||
private static bool ConnectedWithoutZone(
|
||||
int source,
|
||||
int target,
|
||||
int ignored,
|
||||
IReadOnlySet<AetheriaDaemonTutorialTopologyGenerator.Edge> links,
|
||||
int zoneCount)
|
||||
{
|
||||
if (source == ignored || target == ignored) return false;
|
||||
var queue = new Queue<int>();
|
||||
var visited = new bool[zoneCount];
|
||||
queue.Enqueue(source);
|
||||
visited[source] = true;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var neighbor in AetheriaDaemonTutorialTopologyGenerator.Neighbors(current, links))
|
||||
{
|
||||
if (neighbor == ignored || visited[neighbor]) continue;
|
||||
if (neighbor == target) return true;
|
||||
visited[neighbor] = true;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
|
||||
public static class AetheriaDaemonRunFactory
|
||||
{
|
||||
public static async Task<AetheriaDaemonWrittenRun> WriteAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now,
|
||||
string generationIdentity,
|
||||
AetheriaDaemonRegularTopologySettings? regularTopologySettings = null)
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
if (!settings.TutorialPassed)
|
||||
return await AetheriaDaemonTutorialRunWriter.WriteAsync(node, catalog, now).ConfigureAwait(false);
|
||||
|
||||
return await AetheriaDaemonRegularRunWriter.WriteAsync(
|
||||
node,
|
||||
catalog,
|
||||
now,
|
||||
StableSeed(generationIdentity),
|
||||
regularTopologySettings).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static uint StableSeed(string value)
|
||||
{
|
||||
var hash = 2166136261u;
|
||||
foreach (var character in value ?? "")
|
||||
{
|
||||
hash ^= character;
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return hash == 0 ? 1u : hash;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
public sealed record AetheriaDaemonWrittenRun(
|
||||
string RunId,
|
||||
string RunKey,
|
||||
string CurrentEntityKey,
|
||||
bool IsTutorial)
|
||||
{
|
||||
public string SessionMode { get; init; } = AetheriaGameSessionState.AetheriaMode;
|
||||
}
|
||||
|
||||
public static class AetheriaDaemonTutorialRunWriter
|
||||
{
|
||||
public const string RunId = "tutorial";
|
||||
public const uint GenerationSeed = 0xA37E_2026u;
|
||||
|
||||
public static async Task<AetheriaDaemonWrittenRun> WriteAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now)
|
||||
{
|
||||
var factions = AetheriaDaemonTutorialTopologyGenerator.ResolveFossilFactions(catalog);
|
||||
var world = AetheriaDaemonTutorialWorldGenerator.Generate(factions, GenerationSeed);
|
||||
var materialized = AetheriaDaemonTutorialWorldMaterializer.Materialize(world, factions, catalog);
|
||||
var runKey = RunKey();
|
||||
var zoneKeys = materialized.Topology.Zones
|
||||
.OrderBy(zone => zone.ZoneIndex)
|
||||
.Select(zone => ZoneKey(zone.ZoneIndex))
|
||||
.ToArray();
|
||||
var corporationIndices = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
|
||||
.Select((corporation, index) => (corporation.CorporationKey, index))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.CorporationKey))
|
||||
.ToDictionary(value => value.CorporationKey, value => value.index, StringComparer.Ordinal);
|
||||
var playerEntityKey = EntityKey(materialized.PlayerZoneIndex, materialized.PlayerEntityIndex);
|
||||
|
||||
foreach (var zone in materialized.Zones.Values.OrderBy(zone => zone.Topology.ZoneIndex))
|
||||
{
|
||||
var entityKeys = Enumerable.Range(0, zone.Entities.Length)
|
||||
.Select(index => EntityKey(zone.Topology.ZoneIndex, index))
|
||||
.ToArray();
|
||||
await node.MutableDocument<AetheriaZoneState>(new CultRecordKey(ZoneKey(zone.Topology.ZoneIndex)))
|
||||
.ReplaceAsync(new AetheriaZoneState
|
||||
{
|
||||
Name = zone.Topology.Name,
|
||||
Position = new AetheriaVector2 { X = zone.Topology.X, Y = zone.Topology.Y },
|
||||
AdjacentZoneIndices = zone.Topology.AdjacentZoneIndices.ToArray(),
|
||||
FactionIndices = zone.Topology.FactionKeys
|
||||
.Select(key => corporationIndices[key])
|
||||
.ToArray(),
|
||||
OwnerFactionIndex = string.IsNullOrWhiteSpace(zone.Topology.OwnerFactionKey)
|
||||
? -1
|
||||
: corporationIndices[zone.Topology.OwnerFactionKey],
|
||||
EntityKeys = entityKeys,
|
||||
Orbits = zone.Orbits,
|
||||
Bodies = zone.CelestialPlan.Bodies,
|
||||
GravityTerrainRadius = zone.CelestialPlan.Radius,
|
||||
GravityTerrainDepth = zone.CelestialPlan.GravityTerrainDepth,
|
||||
GravityTerrainDepthExponent = zone.CelestialPlan.GravityTerrainDepthExponent,
|
||||
GravityTerrainBoundaryFog = zone.CelestialPlan.GravityTerrainBoundaryFog,
|
||||
GravityTerrainWaveFrequency = 1,
|
||||
DroppedPickups = [],
|
||||
NextPickupIndex = 0
|
||||
}).ConfigureAwait(false);
|
||||
for (var entityIndex = 0; entityIndex < zone.Entities.Length; entityIndex++)
|
||||
{
|
||||
await node.MutableDocument<AetheriaEntitySnapshot>(new CultRecordKey(entityKeys[entityIndex]))
|
||||
.ReplaceAsync(zone.Entities[entityIndex])
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await node.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey)).ReplaceAsync(new AetheriaRunState
|
||||
{
|
||||
RunId = RunId,
|
||||
IsTutorial = true,
|
||||
EntranceZoneIndex = materialized.Topology.EntranceZoneIndex,
|
||||
ExitZoneIndex = -1,
|
||||
CurrentZoneIndex = materialized.PlayerZoneIndex,
|
||||
DiscoveredZoneIndices = materialized.Topology.DiscoveredZoneIndices.ToArray(),
|
||||
ZoneKeys = zoneKeys,
|
||||
FactionRelationships = factions.Select(faction => new AetheriaFactionRelationshipState
|
||||
{
|
||||
FactionKey = faction.CorporationKey,
|
||||
Relationship = "Neutral",
|
||||
Standing = 0
|
||||
}).ToArray(),
|
||||
HomeZones = materialized.Topology.HomeZoneByFactionKey
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new AetheriaFactionZoneState
|
||||
{
|
||||
FactionIndex = corporationIndices[pair.Key],
|
||||
ZoneIndex = pair.Value
|
||||
})
|
||||
.ToArray(),
|
||||
BossZones = materialized.Topology.BossZoneByFactionKey
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new AetheriaFactionZoneState
|
||||
{
|
||||
FactionIndex = corporationIndices[pair.Key],
|
||||
ZoneIndex = pair.Value
|
||||
})
|
||||
.ToArray(),
|
||||
GenerationSeed = GenerationSeed,
|
||||
CurrentEntityKey = playerEntityKey,
|
||||
LifecyclePhase = AetheriaRuntimeRunLifecycle.Active,
|
||||
TerminalFrameId = -1,
|
||||
GameMode = AetheriaGameSessionState.AetheriaMode,
|
||||
AgentTasks = materialized.Zones.Values
|
||||
.OrderBy(zone => zone.Topology.ZoneIndex)
|
||||
.SelectMany(zone => zone.AgentTasks)
|
||||
.ToArray(),
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
settings.ActiveRunKey = runKey;
|
||||
settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Pilot" : settings.PlayerName;
|
||||
settings.LastUpdatedAtUtc = now;
|
||||
await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReplaceAsync(settings)
|
||||
.ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new AetheriaDaemonWrittenRun(RunId, runKey, playerEntityKey, true);
|
||||
}
|
||||
|
||||
public static string RunKey() => $"global:aetheria.run_state.{RunId}.v1";
|
||||
public static string ZoneKey(int zoneIndex) => $"global:aetheria.zone_state.{RunId}.{zoneIndex}.v1";
|
||||
public static string EntityKey(int zoneIndex, int entityIndex) =>
|
||||
$"global:aetheria.run_state.{RunId}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
}
|
||||
|
|
@ -1,725 +0,0 @@
|
|||
using GameCult.Aetheria.State.Verse;
|
||||
|
||||
public sealed record AetheriaDaemonTutorialFactionInput(
|
||||
string CorporationKey,
|
||||
string Name,
|
||||
string ShortName,
|
||||
int InfluenceDistance)
|
||||
{
|
||||
public IReadOnlyList<string> TrainingNames { get; init; } = Array.Empty<string>();
|
||||
public string BossHullItemKey { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record AetheriaDaemonTutorialTopologySettings(
|
||||
int ZoneCount,
|
||||
float LinkDensity,
|
||||
string ProtagonistFaction,
|
||||
string AntagonistFaction,
|
||||
string BufferFaction,
|
||||
IReadOnlyList<string> NeutralFactions,
|
||||
string QuestFaction)
|
||||
{
|
||||
public static AetheriaDaemonTutorialTopologySettings Fossil { get; } = new(
|
||||
64,
|
||||
0.5f,
|
||||
"Miss",
|
||||
"Zhe",
|
||||
"Luc",
|
||||
new[] { "Aero", "Finch" },
|
||||
"Adras");
|
||||
}
|
||||
|
||||
public sealed record AetheriaDaemonTutorialZoneTopology(
|
||||
int ZoneIndex,
|
||||
string Name,
|
||||
float X,
|
||||
float Y,
|
||||
IReadOnlyList<int> AdjacentZoneIndices,
|
||||
IReadOnlyList<string> FactionKeys,
|
||||
string OwnerFactionKey);
|
||||
|
||||
public sealed record AetheriaDaemonTutorialTopology(
|
||||
uint GenerationSeed,
|
||||
float NoisePosition,
|
||||
int EntranceZoneIndex,
|
||||
IReadOnlyList<int> DiscoveredZoneIndices,
|
||||
IReadOnlyDictionary<string, int> HomeZoneByFactionKey,
|
||||
IReadOnlyList<AetheriaDaemonTutorialZoneTopology> Zones)
|
||||
{
|
||||
public int ExitZoneIndex { get; init; } = -1;
|
||||
public IReadOnlyDictionary<string, int> BossZoneByFactionKey { get; init; } =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Daemon-owned migration of the fossil tutorial galaxy topology. This organ
|
||||
/// deliberately stops before zone contents: materialization consumes this
|
||||
/// result, but cannot change its graph, faction placement, or discovery truth.
|
||||
/// </summary>
|
||||
public static class AetheriaDaemonTutorialTopologyGenerator
|
||||
{
|
||||
internal const int CandidateMultiplier = 8;
|
||||
|
||||
public static AetheriaDaemonTutorialTopology GenerateFossil(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
uint seed,
|
||||
AetheriaDaemonTutorialTopologySettings? settings = null)
|
||||
{
|
||||
var noiseRandom = new CultMath.Random(seed ^ 0x74A7_91D3u);
|
||||
float noisePosition;
|
||||
var attempts = 0;
|
||||
do
|
||||
{
|
||||
if (++attempts > 1_000_000)
|
||||
throw new InvalidOperationException("Tutorial cloud field did not produce an admissible entrance region.");
|
||||
noisePosition = noiseRandom.NextFloat(0, 1000);
|
||||
} while (TutorialCloudDensity(0.5f, 0.5f, noisePosition) < 0.5f);
|
||||
|
||||
return Generate(
|
||||
factions,
|
||||
seed,
|
||||
(x, y) => TutorialCloudDensity(x, y, noisePosition),
|
||||
settings) with
|
||||
{
|
||||
NoisePosition = noisePosition
|
||||
};
|
||||
}
|
||||
|
||||
public static float TutorialCloudDensity(float x, float y, float noisePosition)
|
||||
{
|
||||
var point = new CultMath.float2(x + noisePosition, y + noisePosition);
|
||||
var frequency = 0.1f;
|
||||
var amplitude = 0.5f;
|
||||
var sum = 0f;
|
||||
for (var octave = 0; octave < 10; octave++)
|
||||
{
|
||||
var noise = MathF.Abs(CultMath.math.simplex_noise(point * frequency));
|
||||
sum += (octave < 4 ? 1f - noise : noise) * amplitude;
|
||||
frequency *= 2f;
|
||||
amplitude *= 0.7f;
|
||||
}
|
||||
return MathF.Pow(sum + 0.3f, 10f) * 0.01f;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AetheriaDaemonTutorialFactionInput> ResolveFossilFactions(
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
AetheriaDaemonTutorialTopologySettings? settings = null)
|
||||
{
|
||||
settings ??= AetheriaDaemonTutorialTopologySettings.Fossil;
|
||||
var requested = new[]
|
||||
{
|
||||
settings.ProtagonistFaction,
|
||||
settings.AntagonistFaction,
|
||||
settings.BufferFaction
|
||||
}
|
||||
.Concat(settings.NeutralFactions)
|
||||
.Append(settings.QuestFaction)
|
||||
.ToArray();
|
||||
|
||||
return requested.Select(name =>
|
||||
{
|
||||
var matches = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
|
||||
.Where(value => MatchesAuthoredSelector(value, name))
|
||||
.ToArray();
|
||||
if (matches.Length == 0)
|
||||
throw new InvalidDataException($"Tutorial generation requires the authored faction '{name}'.");
|
||||
if (matches.Length > 1)
|
||||
throw new InvalidDataException(
|
||||
$"Tutorial faction selector '{name}' is ambiguous: {string.Join(", ", matches.Select(value => value.Name))}.");
|
||||
var match = matches[0];
|
||||
if (string.IsNullOrWhiteSpace(match.CorporationKey))
|
||||
throw new InvalidDataException($"Tutorial faction '{name}' has no stable corporation key.");
|
||||
var nameFile = catalog.FindNameFile(match.GeonameFileKey);
|
||||
if (nameFile == null || nameFile.Names.Count == 0)
|
||||
throw new InvalidDataException($"Tutorial faction '{name}' has no typed geoname corpus.");
|
||||
return new AetheriaDaemonTutorialFactionInput(
|
||||
match.CorporationKey,
|
||||
match.Name ?? "",
|
||||
match.ShortName ?? "",
|
||||
match.InfluenceDistance)
|
||||
{
|
||||
TrainingNames = nameFile.Names
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchesAuthoredSelector(AetheriaRuntimeCorporation corporation, string selector)
|
||||
{
|
||||
return string.Equals(corporation.CorporationKey, selector, StringComparison.OrdinalIgnoreCase) ||
|
||||
StartsWithSelector(corporation.ShortName, selector) ||
|
||||
StartsWithSelector(corporation.Name, selector);
|
||||
}
|
||||
|
||||
private static bool StartsWithSelector(string? value, string selector) =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
value.StartsWith(selector, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static AetheriaDaemonTutorialTopology Generate(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
uint seed,
|
||||
Func<float, float, float> cloudDensity,
|
||||
AetheriaDaemonTutorialTopologySettings? settings = null)
|
||||
{
|
||||
settings ??= AetheriaDaemonTutorialTopologySettings.Fossil;
|
||||
if (settings.ZoneCount < 3)
|
||||
throw new ArgumentOutOfRangeException(nameof(settings), "Tutorial topology requires at least three zones.");
|
||||
if (cloudDensity == null)
|
||||
throw new ArgumentNullException(nameof(cloudDensity));
|
||||
|
||||
var roles = ResolveRoles(factions, settings);
|
||||
var random = new CultMath.Random(seed);
|
||||
var points = GeneratePoints(settings.ZoneCount, ref random, cloudDensity);
|
||||
var links = DelaunayLinks(points);
|
||||
PruneLinks(points, links, settings.LinkDensity, cloudDensity);
|
||||
var distances = BuildDistances(points.Length, links);
|
||||
|
||||
var home = PlaceFactions(points.Length, links, distances, roles);
|
||||
var influence = roles.All.ToDictionary(
|
||||
faction => faction.CorporationKey,
|
||||
faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2),
|
||||
StringComparer.Ordinal);
|
||||
var zoneFactions = new string[points.Length][];
|
||||
var owners = new string[points.Length];
|
||||
for (var zoneIndex = 0; zoneIndex < points.Length; zoneIndex++)
|
||||
{
|
||||
zoneFactions[zoneIndex] = roles.All
|
||||
.Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
|
||||
.Select(faction => faction.CorporationKey)
|
||||
.ToArray();
|
||||
owners[zoneIndex] = roles.All
|
||||
.Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
|
||||
.OrderBy(faction => distances[zoneIndex, home[faction.CorporationKey]])
|
||||
.ThenBy(faction => Array.IndexOf(roles.All, faction))
|
||||
.Select(faction => faction.CorporationKey)
|
||||
.FirstOrDefault() ?? "";
|
||||
}
|
||||
|
||||
var entranceCandidates = Enumerable.Range(0, points.Length)
|
||||
.Where(index => owners[index].Length == 0)
|
||||
.ToArray();
|
||||
if (entranceCandidates.Length == 0)
|
||||
throw new InvalidOperationException("Tutorial topology produced no unowned entrance zone.");
|
||||
var entrance = entranceCandidates
|
||||
.OrderBy(index => distances[index, home[roles.Protagonist.CorporationKey]])
|
||||
.ThenBy(index => index)
|
||||
.First();
|
||||
var discovered = new[] { entrance }
|
||||
.Concat(Neighbors(entrance, links))
|
||||
.Distinct()
|
||||
.OrderBy(index => index)
|
||||
.ToArray();
|
||||
|
||||
var nameSeed = (uint)random.NextInt(1, int.MaxValue);
|
||||
var zoneNames = GenerateNames(roles.All, owners, nameSeed);
|
||||
var zones = Enumerable.Range(0, points.Length)
|
||||
.Select(index => new AetheriaDaemonTutorialZoneTopology(
|
||||
index,
|
||||
zoneNames[index],
|
||||
points[index].X,
|
||||
points[index].Y,
|
||||
Neighbors(index, links).OrderBy(value => value).ToArray(),
|
||||
zoneFactions[index],
|
||||
owners[index]))
|
||||
.ToArray();
|
||||
return new AetheriaDaemonTutorialTopology(seed, float.NaN, entrance, discovered, home, zones);
|
||||
}
|
||||
|
||||
private static TutorialRoles ResolveRoles(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
AetheriaDaemonTutorialTopologySettings settings)
|
||||
{
|
||||
AetheriaDaemonTutorialFactionInput Resolve(string name)
|
||||
{
|
||||
var matches = factions.Where(value =>
|
||||
string.Equals(value.CorporationKey, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
StartsWithSelector(value.ShortName, name) ||
|
||||
StartsWithSelector(value.Name, name))
|
||||
.ToArray();
|
||||
if (matches.Length == 0)
|
||||
throw new InvalidDataException($"Tutorial topology is missing faction '{name}'.");
|
||||
if (matches.Length > 1)
|
||||
throw new InvalidDataException(
|
||||
$"Tutorial topology faction selector '{name}' is ambiguous: {string.Join(", ", matches.Select(value => value.Name))}.");
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
var protagonist = Resolve(settings.ProtagonistFaction);
|
||||
var antagonist = Resolve(settings.AntagonistFaction);
|
||||
var buffer = Resolve(settings.BufferFaction);
|
||||
var neutrals = settings.NeutralFactions.Select(Resolve).ToArray();
|
||||
var quest = Resolve(settings.QuestFaction);
|
||||
var all = new[] { protagonist, antagonist, buffer }
|
||||
.Concat(neutrals)
|
||||
.Append(quest)
|
||||
.DistinctBy(value => value.CorporationKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (all.Length != 4 + neutrals.Length)
|
||||
throw new InvalidDataException("Tutorial faction roles must resolve to distinct corporations.");
|
||||
return new TutorialRoles(protagonist, antagonist, buffer, neutrals, quest, all);
|
||||
}
|
||||
|
||||
internal static string[] GenerateNames(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
IReadOnlyList<string> ownerKeys,
|
||||
uint seed,
|
||||
int order = 3)
|
||||
{
|
||||
var generators = new Dictionary<string, TutorialMarkovNameGenerator>(StringComparer.Ordinal);
|
||||
foreach (var faction in factions)
|
||||
{
|
||||
if (faction.TrainingNames.Count == 0)
|
||||
throw new InvalidDataException($"Tutorial faction '{faction.ShortName}' has no geoname training rows.");
|
||||
generators[faction.CorporationKey] = new TutorialMarkovNameGenerator(
|
||||
seed,
|
||||
faction.TrainingNames,
|
||||
order: order,
|
||||
minimumLength: 5,
|
||||
maximumLength: 10);
|
||||
}
|
||||
|
||||
var catalogRandom = new CultMath.Random(seed);
|
||||
var used = new HashSet<string>(StringComparer.Ordinal);
|
||||
var output = new string[ownerKeys.Count];
|
||||
for (var zoneIndex = 0; zoneIndex < ownerKeys.Count; zoneIndex++)
|
||||
{
|
||||
var owner = ownerKeys[zoneIndex];
|
||||
if (owner.Length == 0)
|
||||
{
|
||||
string candidate;
|
||||
do candidate = $"EAC-{catalogRandom.NextInt(9999)}";
|
||||
while (!used.Add(candidate));
|
||||
output[zoneIndex] = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
output[zoneIndex] = generators[owner].Next(used);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
internal static Point[] GeneratePoints(
|
||||
int count,
|
||||
ref CultMath.Random random,
|
||||
Func<float, float, float> density)
|
||||
{
|
||||
var candidates = new Point[count * CandidateMultiplier];
|
||||
var accepted = 0;
|
||||
var accumulator = 0f;
|
||||
var attempts = 0;
|
||||
while (accepted < candidates.Length)
|
||||
{
|
||||
if (++attempts > candidates.Length * 1_000_000)
|
||||
throw new InvalidOperationException("Tutorial density did not admit enough topology samples.");
|
||||
var point = new Point(random.NextFloat(), random.NextFloat());
|
||||
var dx = point.X - 0.5f;
|
||||
var dy = point.Y - 0.5f;
|
||||
var envelope = Saturate((0.2f - (dx * dx + dy * dy)) * 4f);
|
||||
var sampleDensity = Saturate(density(point.X, point.Y));
|
||||
accumulator += sampleDensity * sampleDensity * envelope;
|
||||
if (accumulator <= 0.5f)
|
||||
continue;
|
||||
accumulator = 0;
|
||||
candidates[accepted++] = point;
|
||||
}
|
||||
|
||||
var dMax = 2f * MathF.Sqrt((1f / count) / (2f * MathF.Sqrt(3f)));
|
||||
var dMaxSquared = dMax * dMax;
|
||||
var weights = new float[candidates.Length];
|
||||
var densities = candidates.Select(point => density(point.X, point.Y)).ToArray();
|
||||
var active = Enumerable.Repeat(true, candidates.Length).ToArray();
|
||||
for (var i = 0; i < candidates.Length; i++)
|
||||
for (var j = 0; j < candidates.Length; j++)
|
||||
{
|
||||
var distanceSquared = DistanceSquared(candidates[i], candidates[j]);
|
||||
if (distanceSquared > dMaxSquared)
|
||||
continue;
|
||||
weights[i] += Weight(distanceSquared, dMax, densities[i]);
|
||||
}
|
||||
|
||||
for (var remaining = candidates.Length; remaining > count; remaining--)
|
||||
{
|
||||
var eliminated = Enumerable.Range(0, candidates.Length)
|
||||
.Where(index => active[index])
|
||||
.OrderByDescending(index => weights[index])
|
||||
.ThenBy(index => index)
|
||||
.First();
|
||||
active[eliminated] = false;
|
||||
for (var neighbor = 0; neighbor < candidates.Length; neighbor++)
|
||||
{
|
||||
if (!active[neighbor])
|
||||
continue;
|
||||
var distanceSquared = DistanceSquared(candidates[neighbor], candidates[eliminated]);
|
||||
if (distanceSquared <= dMaxSquared)
|
||||
weights[neighbor] -= Weight(distanceSquared, dMax, densities[neighbor]);
|
||||
}
|
||||
}
|
||||
|
||||
return Enumerable.Range(0, candidates.Length)
|
||||
.Where(index => active[index])
|
||||
.Select(index => candidates[index])
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static float Weight(float distanceSquared, float dMax, float density)
|
||||
{
|
||||
var distance = MathF.Sqrt(distanceSquared);
|
||||
return MathF.Pow(1f - distance / dMax, 5f + 6f * density);
|
||||
}
|
||||
|
||||
internal static HashSet<Edge> DelaunayLinks(IReadOnlyList<Point> points)
|
||||
{
|
||||
var vertices = points.Concat(new[]
|
||||
{
|
||||
new Point(-16, -8),
|
||||
new Point(16, -8),
|
||||
new Point(0, 16)
|
||||
}).ToArray();
|
||||
var superA = points.Count;
|
||||
var superB = points.Count + 1;
|
||||
var superC = points.Count + 2;
|
||||
var triangles = new List<Triangle> { Orient(vertices, new Triangle(superA, superB, superC)) };
|
||||
|
||||
for (var pointIndex = 0; pointIndex < points.Count; pointIndex++)
|
||||
{
|
||||
var bad = triangles.Where(triangle => ContainsInCircumcircle(vertices, triangle, vertices[pointIndex])).ToArray();
|
||||
var boundary = bad
|
||||
.SelectMany(TriangleEdges)
|
||||
.GroupBy(edge => edge)
|
||||
.Where(group => group.Count() == 1)
|
||||
.Select(group => group.Key)
|
||||
.ToArray();
|
||||
foreach (var triangle in bad)
|
||||
triangles.Remove(triangle);
|
||||
triangles.AddRange(boundary.Select(edge => Orient(vertices, new Triangle(edge.A, edge.B, pointIndex))));
|
||||
}
|
||||
|
||||
return triangles
|
||||
.Where(triangle => triangle.A < points.Count && triangle.B < points.Count && triangle.C < points.Count)
|
||||
.SelectMany(TriangleEdges)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static Triangle Orient(IReadOnlyList<Point> points, Triangle triangle)
|
||||
{
|
||||
return Cross(points[triangle.A], points[triangle.B], points[triangle.C]) >= 0
|
||||
? triangle
|
||||
: new Triangle(triangle.B, triangle.A, triangle.C);
|
||||
}
|
||||
|
||||
private static bool ContainsInCircumcircle(IReadOnlyList<Point> points, Triangle triangle, Point point)
|
||||
{
|
||||
var a = points[triangle.A];
|
||||
var b = points[triangle.B];
|
||||
var c = points[triangle.C];
|
||||
var ax = a.X - point.X;
|
||||
var ay = a.Y - point.Y;
|
||||
var bx = b.X - point.X;
|
||||
var by = b.Y - point.Y;
|
||||
var cx = c.X - point.X;
|
||||
var cy = c.Y - point.Y;
|
||||
var determinant = (ax * ax + ay * ay) * (bx * cy - cx * by)
|
||||
- (bx * bx + by * by) * (ax * cy - cx * ay)
|
||||
+ (cx * cx + cy * cy) * (ax * by - bx * ay);
|
||||
return determinant > 1e-7f;
|
||||
}
|
||||
|
||||
private static IEnumerable<Edge> TriangleEdges(Triangle triangle)
|
||||
{
|
||||
yield return new Edge(triangle.A, triangle.B);
|
||||
yield return new Edge(triangle.B, triangle.C);
|
||||
yield return new Edge(triangle.C, triangle.A);
|
||||
}
|
||||
|
||||
internal static void PruneLinks(
|
||||
IReadOnlyList<Point> points,
|
||||
HashSet<Edge> links,
|
||||
float linkDensity,
|
||||
Func<float, float, float> density)
|
||||
{
|
||||
var target = (int)MathF.Ceiling(Saturate(linkDensity) * links.Count);
|
||||
while (links.Count > target)
|
||||
{
|
||||
var degrees = Enumerable.Range(0, points.Count)
|
||||
.Select(index => links.Count(edge => edge.Contains(index)))
|
||||
.ToArray();
|
||||
var removable = links
|
||||
.Where(edge => IsConnectedWithout(points.Count, links, edge.A, edge.B, edge))
|
||||
.Select(edge => new { Edge = edge, Weight = LinkWeight(points, edge, degrees, density) })
|
||||
.OrderByDescending(value => value.Weight)
|
||||
.ThenBy(value => value.Edge.A)
|
||||
.ThenBy(value => value.Edge.B)
|
||||
.FirstOrDefault();
|
||||
if (removable == null)
|
||||
break;
|
||||
links.Remove(removable.Edge);
|
||||
}
|
||||
}
|
||||
|
||||
private static float LinkWeight(
|
||||
IReadOnlyList<Point> points,
|
||||
Edge edge,
|
||||
IReadOnlyList<int> degrees,
|
||||
Func<float, float, float> density)
|
||||
{
|
||||
var a = points[edge.A];
|
||||
var b = points[edge.B];
|
||||
var midpointDensity = Saturate(density((a.X + b.X) * 0.5f, (a.Y + b.Y) * 0.5f));
|
||||
var densityFactor = midpointDensity <= 0 ? float.PositiveInfinity : 1f / midpointDensity;
|
||||
return densityFactor * DistanceSquared(a, b) * (degrees[edge.A] - 1) * (degrees[edge.B] - 1);
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> PlaceFactions(
|
||||
int zoneCount,
|
||||
HashSet<Edge> links,
|
||||
int[,] distances,
|
||||
TutorialRoles roles)
|
||||
{
|
||||
var home = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var influence = roles.All.ToDictionary(
|
||||
faction => faction.CorporationKey,
|
||||
faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone =>
|
||||
RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances));
|
||||
home[roles.Antagonist.CorporationKey] = MaxZone(zoneCount, zone =>
|
||||
RegionSize(zone, influence[roles.Antagonist.CorporationKey], distances) *
|
||||
distances[zone, home[roles.Protagonist.CorporationKey]]);
|
||||
home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone =>
|
||||
RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances) *
|
||||
Math.Sqrt(distances[zone, home[roles.Antagonist.CorporationKey]]));
|
||||
|
||||
var minimumDifference = Enumerable.Range(0, zoneCount)
|
||||
.Min(zone => Math.Abs(
|
||||
distances[zone, home[roles.Antagonist.CorporationKey]] -
|
||||
distances[zone, home[roles.Protagonist.CorporationKey]]));
|
||||
home[roles.Buffer.CorporationKey] = MaxZone(
|
||||
Enumerable.Range(0, zoneCount).Where(zone => Math.Abs(
|
||||
distances[zone, home[roles.Antagonist.CorporationKey]] -
|
||||
distances[zone, home[roles.Protagonist.CorporationKey]]) == minimumDifference),
|
||||
zone => RegionSize(zone, influence[roles.Buffer.CorporationKey], distances));
|
||||
|
||||
foreach (var neutral in roles.Neutrals)
|
||||
{
|
||||
home[neutral.CorporationKey] = MaxZone(zoneCount, zone =>
|
||||
{
|
||||
var score = (double)RegionSize(zone, influence[neutral.CorporationKey], distances);
|
||||
foreach (var occupied in home.Values)
|
||||
score *= Math.Sqrt(distances[zone, occupied]);
|
||||
return score;
|
||||
});
|
||||
}
|
||||
|
||||
var present = BuildPresence(zoneCount, roles.All.Where(value => value != roles.Quest), home, influence, distances);
|
||||
var preferredQuestZones = Enumerable.Range(0, zoneCount)
|
||||
.Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey) && present[zone].Contains(roles.Buffer.CorporationKey))
|
||||
.ToArray();
|
||||
if (preferredQuestZones.Length > 0)
|
||||
{
|
||||
home[roles.Quest.CorporationKey] = MaxZone(preferredQuestZones, zone =>
|
||||
distances[zone, home[roles.Antagonist.CorporationKey]] *
|
||||
RegionSize(zone, influence[roles.Quest.CorporationKey], distances));
|
||||
}
|
||||
else
|
||||
{
|
||||
var antagonistZones = Enumerable.Range(0, zoneCount)
|
||||
.Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey));
|
||||
home[roles.Quest.CorporationKey] = antagonistZones
|
||||
.OrderBy(zone => distances[zone, home[roles.Buffer.CorporationKey]])
|
||||
.ThenBy(zone => zone)
|
||||
.First();
|
||||
}
|
||||
|
||||
return home;
|
||||
}
|
||||
|
||||
private static HashSet<string>[] BuildPresence(
|
||||
int zoneCount,
|
||||
IEnumerable<AetheriaDaemonTutorialFactionInput> factions,
|
||||
IReadOnlyDictionary<string, int> home,
|
||||
IReadOnlyDictionary<string, int> influence,
|
||||
int[,] distances)
|
||||
{
|
||||
return Enumerable.Range(0, zoneCount)
|
||||
.Select(zone => factions
|
||||
.Where(faction => distances[zone, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
|
||||
.Select(faction => faction.CorporationKey)
|
||||
.ToHashSet(StringComparer.Ordinal))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static int[,] BuildDistances(int zoneCount, IReadOnlySet<Edge> links)
|
||||
{
|
||||
var output = new int[zoneCount, zoneCount];
|
||||
for (var source = 0; source < zoneCount; source++)
|
||||
{
|
||||
var queue = new Queue<int>();
|
||||
var visited = new bool[zoneCount];
|
||||
queue.Enqueue(source);
|
||||
visited[source] = true;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var neighbor in Neighbors(current, links))
|
||||
{
|
||||
if (visited[neighbor])
|
||||
continue;
|
||||
visited[neighbor] = true;
|
||||
output[source, neighbor] = output[source, current] + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
if (visited.Any(value => !value))
|
||||
throw new InvalidOperationException("Tutorial topology graph is disconnected.");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
internal static bool IsConnectedWithout(
|
||||
int zoneCount,
|
||||
IReadOnlySet<Edge> links,
|
||||
int source,
|
||||
int target,
|
||||
Edge ignored)
|
||||
{
|
||||
var queue = new Queue<int>();
|
||||
var visited = new bool[zoneCount];
|
||||
queue.Enqueue(source);
|
||||
visited[source] = true;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var edge in links)
|
||||
{
|
||||
if (edge == ignored || !edge.Contains(current))
|
||||
continue;
|
||||
var neighbor = edge.Other(current);
|
||||
if (neighbor == target)
|
||||
return true;
|
||||
if (visited[neighbor])
|
||||
continue;
|
||||
visited[neighbor] = true;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static IEnumerable<int> Neighbors(int zone, IEnumerable<Edge> links)
|
||||
{
|
||||
return links.Where(edge => edge.Contains(zone)).Select(edge => edge.Other(zone));
|
||||
}
|
||||
|
||||
internal static int RegionSize(int zone, int maxDistance, int[,] distances)
|
||||
{
|
||||
return Enumerable.Range(0, distances.GetLength(0)).Count(other => distances[zone, other] <= maxDistance);
|
||||
}
|
||||
|
||||
internal static int MaxZone(int zoneCount, Func<int, double> score) =>
|
||||
MaxZone(Enumerable.Range(0, zoneCount), score);
|
||||
|
||||
internal static int MaxZone(IEnumerable<int> zones, Func<int, double> score) =>
|
||||
zones.Select(zone => new { Zone = zone, Score = score(zone) })
|
||||
.OrderByDescending(value => value.Score)
|
||||
.ThenBy(value => value.Zone)
|
||||
.First().Zone;
|
||||
|
||||
private static float DistanceSquared(Point a, Point b)
|
||||
{
|
||||
var x = a.X - b.X;
|
||||
var y = a.Y - b.Y;
|
||||
return x * x + y * y;
|
||||
}
|
||||
|
||||
private static float Cross(Point a, Point b, Point c) =>
|
||||
(b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
|
||||
|
||||
private static float Saturate(float value) => value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
|
||||
internal readonly record struct Point(float X, float Y);
|
||||
private readonly record struct Triangle(int A, int B, int C);
|
||||
internal readonly record struct Edge
|
||||
{
|
||||
public Edge(int a, int b)
|
||||
{
|
||||
A = Math.Min(a, b);
|
||||
B = Math.Max(a, b);
|
||||
}
|
||||
|
||||
public int A { get; }
|
||||
public int B { get; }
|
||||
public bool Contains(int value) => A == value || B == value;
|
||||
public int Other(int value) => A == value ? B : B == value ? A : throw new ArgumentOutOfRangeException(nameof(value));
|
||||
}
|
||||
|
||||
private sealed record TutorialRoles(
|
||||
AetheriaDaemonTutorialFactionInput Protagonist,
|
||||
AetheriaDaemonTutorialFactionInput Antagonist,
|
||||
AetheriaDaemonTutorialFactionInput Buffer,
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> Neutrals,
|
||||
AetheriaDaemonTutorialFactionInput Quest,
|
||||
AetheriaDaemonTutorialFactionInput[] All);
|
||||
|
||||
private sealed class TutorialMarkovNameGenerator
|
||||
{
|
||||
private readonly Dictionary<string, List<char>> _chains = new(StringComparer.Ordinal);
|
||||
private readonly List<string> _samples = [];
|
||||
private readonly int _order;
|
||||
private readonly int _minimumLength;
|
||||
private readonly int _maximumLength;
|
||||
private CultMath.Random _random;
|
||||
|
||||
public TutorialMarkovNameGenerator(
|
||||
uint seed,
|
||||
IEnumerable<string> sampleNames,
|
||||
int order,
|
||||
int minimumLength,
|
||||
int maximumLength)
|
||||
{
|
||||
_random = new CultMath.Random(seed);
|
||||
_order = Math.Max(1, order);
|
||||
_minimumLength = Math.Max(1, minimumLength);
|
||||
_maximumLength = maximumLength;
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var line in sampleNames)
|
||||
foreach (var word in (line ?? "").ToUpperInvariant().Split(' ', ',', '.', '"'))
|
||||
if (word.Length >= _minimumLength) names.Add(word);
|
||||
|
||||
foreach (var line in names)
|
||||
foreach (var word in line.Split(' ', '\'', '(', ')'))
|
||||
{
|
||||
var lower = word.Trim().ToLowerInvariant();
|
||||
if (lower.Length >= _order + 1) _samples.Add(lower + "|");
|
||||
}
|
||||
foreach (var word in _samples)
|
||||
for (var letter = 0; letter < word.Length - _order; letter++)
|
||||
{
|
||||
var token = word.Substring(letter, _order);
|
||||
if (!_chains.TryGetValue(token, out var row))
|
||||
_chains[token] = row = [];
|
||||
row.Add(word[letter + _order]);
|
||||
}
|
||||
if (_samples.Count == 0)
|
||||
throw new InvalidDataException("Tutorial geoname corpus contains no usable Markov samples.");
|
||||
}
|
||||
|
||||
public string Next(ISet<string> used)
|
||||
{
|
||||
for (var attempt = 0; attempt < 1_000_000; attempt++)
|
||||
{
|
||||
var sample = _samples[_random.NextInt(0, _samples.Count)];
|
||||
var value = sample.Substring(0, _order);
|
||||
while (_chains.TryGetValue(value.Substring(value.Length - _order, _order), out var row))
|
||||
{
|
||||
var next = row[_random.NextInt(0, row.Count)];
|
||||
if (next == '|') break;
|
||||
value += next;
|
||||
}
|
||||
value = char.ToUpperInvariant(value[0]) + value.Substring(1);
|
||||
if (value.Length >= _minimumLength && value.Length <= _maximumLength && used.Add(value))
|
||||
return value;
|
||||
}
|
||||
throw new InvalidOperationException("Tutorial Markov corpus could not produce another unique zone name.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
|
||||
public sealed record AetheriaDaemonMaterializedTutorialZone(
|
||||
AetheriaDaemonTutorialZoneTopology Topology,
|
||||
AetheriaDaemonGeneratedZonePlan CelestialPlan,
|
||||
string NearestFactionKey,
|
||||
int FactionPresence,
|
||||
AetheriaOrbitSnapshot[] Orbits,
|
||||
AetheriaEntitySnapshot[] Entities,
|
||||
AetheriaAgentTaskState[] AgentTasks,
|
||||
int PlayerEntityIndex);
|
||||
|
||||
public sealed record AetheriaDaemonMaterializedTutorialWorld(
|
||||
AetheriaDaemonTutorialTopology Topology,
|
||||
IReadOnlyDictionary<int, AetheriaDaemonMaterializedTutorialZone> Zones,
|
||||
int PlayerZoneIndex,
|
||||
int PlayerEntityIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Daemon-owned port of the fossil ZoneGenerator population pass. Celestial
|
||||
/// generation hands its exact remaining random state to this organ, so adding
|
||||
/// presentation work cannot perturb station, turret, or enemy placement.
|
||||
/// </summary>
|
||||
public static class AetheriaDaemonTutorialWorldMaterializer
|
||||
{
|
||||
public static AetheriaDaemonMaterializedTutorialWorld Materialize(
|
||||
AetheriaDaemonTutorialWorldPlan world,
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
bool isPrelude = true,
|
||||
string identityPrefix = "tutorial")
|
||||
{
|
||||
if (world == null) throw new ArgumentNullException(nameof(world));
|
||||
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
||||
var homes = world.Topology.HomeZoneByFactionKey;
|
||||
factions = homes.Keys
|
||||
.Select(key => factions.First(faction =>
|
||||
string.Equals(faction.CorporationKey, key, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
var adjacency = world.Topology.Zones.ToDictionary(
|
||||
zone => zone.ZoneIndex,
|
||||
zone => zone.AdjacentZoneIndices,
|
||||
EqualityComparer<int>.Default);
|
||||
var distances = factions.ToDictionary(
|
||||
faction => faction.CorporationKey,
|
||||
faction => DistancesFrom(homes[faction.CorporationKey], adjacency),
|
||||
StringComparer.Ordinal);
|
||||
var zones = new Dictionary<int, AetheriaDaemonMaterializedTutorialZone>();
|
||||
var playerEntityIndex = -1;
|
||||
|
||||
foreach (var topology in world.Topology.Zones.OrderBy(zone => zone.ZoneIndex))
|
||||
{
|
||||
var celestial = world.Zones[topology.ZoneIndex];
|
||||
var random = new CultMath.Random(celestial.PostBodyRandomState);
|
||||
var spawnRandom = new CultMath.Random(celestial.PostBodyRandomState ^ 0x51A7_10C5u);
|
||||
var nearest = factions
|
||||
.OrderBy(faction => distances[faction.CorporationKey][topology.ZoneIndex])
|
||||
.First();
|
||||
var homeDistance = distances[nearest.CorporationKey][topology.ZoneIndex];
|
||||
var influence = isPrelude
|
||||
? Math.Max(0, (nearest.InfluenceDistance + 1) / 2)
|
||||
: Math.Max(0, nearest.InfluenceDistance);
|
||||
var factionPresence = influence - homeDistance + 1;
|
||||
var stationCount = (int)(random.NextFloat() * (factionPresence + 1));
|
||||
var orbits = celestial.Orbits.ToList();
|
||||
var entities = new List<AetheriaEntitySnapshot>();
|
||||
var agentTasks = new List<AetheriaAgentTaskState>();
|
||||
var generators = new Dictionary<string, AetheriaDaemonLoadoutGenerator>(StringComparer.Ordinal);
|
||||
|
||||
AetheriaDaemonLoadoutGenerator Generator(string factionKey)
|
||||
{
|
||||
if (!generators.TryGetValue(factionKey, out var generator))
|
||||
{
|
||||
generator = new AetheriaDaemonLoadoutGenerator(
|
||||
catalog,
|
||||
(uint)random.NextInt(1, int.MaxValue),
|
||||
topology.ZoneIndex,
|
||||
homes,
|
||||
adjacency,
|
||||
isPrelude: isPrelude);
|
||||
generators.Add(factionKey, generator);
|
||||
}
|
||||
return generator;
|
||||
}
|
||||
|
||||
var potential = celestial.Orbits
|
||||
.Where(orbit => !string.IsNullOrWhiteSpace(orbit.ParentOrbitKey))
|
||||
.Where(orbit => !celestial.Orbits.Any(sibling =>
|
||||
!ReferenceEquals(sibling, orbit) &&
|
||||
string.Equals(sibling.ParentOrbitKey, orbit.ParentOrbitKey, StringComparison.Ordinal) &&
|
||||
Math.Abs(sibling.Distance - orbit.Distance) < 0.1))
|
||||
.OrderBy(orbit => orbit.Distance)
|
||||
.ToArray();
|
||||
var selected = potential.Skip(potential.Length / 2).Take(Math.Max(0, stationCount)).ToArray();
|
||||
for (var stationIndex = 0; stationIndex < selected.Length; stationIndex++)
|
||||
{
|
||||
var baseOrbit = selected[stationIndex];
|
||||
var stationOrbit = new AetheriaOrbitSnapshot
|
||||
{
|
||||
OrbitKey = $"{identityPrefix}.zone.{topology.ZoneIndex}.station.{stationIndex}.orbit",
|
||||
ParentOrbitKey = baseOrbit.ParentOrbitKey,
|
||||
Distance = baseOrbit.Distance,
|
||||
Phase = baseOrbit.Phase + MathF.PI / 3 * Sign(random.NextFloat() - 0.5f),
|
||||
Period = baseOrbit.Distance,
|
||||
FixedPosition = new AetheriaVector2()
|
||||
};
|
||||
orbits.Add(stationOrbit);
|
||||
var security = (int)((1f - MathF.Pow(random.NextFloat(), factionPresence / 2f)) * 3f);
|
||||
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
||||
$"{topology.Name} Station {stationIndex + 1}",
|
||||
"station",
|
||||
nearest.CorporationKey,
|
||||
Generator(nearest.CorporationKey).Build("station", nearest.CorporationKey),
|
||||
orbitKey: stationOrbit.OrbitKey,
|
||||
securityLevel: security,
|
||||
securityRadius: celestial.Radius));
|
||||
|
||||
for (var turretIndex = 0; turretIndex < 2; turretIndex++)
|
||||
{
|
||||
var distanceMultiplier = turretIndex / 2;
|
||||
if (turretIndex % 2 == 0) distanceMultiplier = -distanceMultiplier;
|
||||
var turretOrbit = new AetheriaOrbitSnapshot
|
||||
{
|
||||
OrbitKey = $"{identityPrefix}.zone.{topology.ZoneIndex}.station.{stationIndex}.turret.{turretIndex}.orbit",
|
||||
ParentOrbitKey = stationOrbit.ParentOrbitKey,
|
||||
Distance = stationOrbit.Distance,
|
||||
Phase = stationOrbit.Phase + 20f * distanceMultiplier / stationOrbit.Distance,
|
||||
Period = stationOrbit.Distance,
|
||||
FixedPosition = new AetheriaVector2()
|
||||
};
|
||||
orbits.Add(turretOrbit);
|
||||
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
||||
$"{topology.Name} Turret {stationIndex + 1}-{turretIndex + 1}",
|
||||
"turret",
|
||||
nearest.CorporationKey,
|
||||
Generator(nearest.CorporationKey).Build("turret", nearest.CorporationKey),
|
||||
orbitKey: turretOrbit.OrbitKey,
|
||||
securityLevel: security,
|
||||
securityRadius: celestial.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount;
|
||||
for (var enemyIndex = 0; enemyIndex < Math.Max(0, enemyCount); enemyIndex++)
|
||||
{
|
||||
var halfRadius = celestial.Radius * 0.5;
|
||||
var enemy = AetheriaDaemonGeneratedEntityFactory.Create(
|
||||
$"{nearest.ShortName} Ship {enemyIndex + 1}",
|
||||
"ship",
|
||||
nearest.CorporationKey,
|
||||
Generator(nearest.CorporationKey).Build("ship", nearest.CorporationKey),
|
||||
spawnRandom.NextFloat((float)-halfRadius, (float)halfRadius),
|
||||
spawnRandom.NextFloat((float)-halfRadius, (float)halfRadius));
|
||||
var entityIndex = entities.Count;
|
||||
var taskId = $"{identityPrefix}.zone.{topology.ZoneIndex}.entity.{entityIndex}.patrol";
|
||||
enemy.AgentTaskCapabilities =
|
||||
[
|
||||
AetheriaRuntimeAgentTaskTypes.Attack,
|
||||
AetheriaRuntimeAgentTaskTypes.Defend,
|
||||
AetheriaRuntimeAgentTaskTypes.Explore,
|
||||
AetheriaRuntimeAgentTaskTypes.Patrol
|
||||
];
|
||||
enemy.AssignedAgentTaskId = taskId;
|
||||
agentTasks.Add(new AetheriaAgentTaskState
|
||||
{
|
||||
TaskId = taskId,
|
||||
CorporationKey = nearest.CorporationKey,
|
||||
TaskType = AetheriaRuntimeAgentTaskTypes.Patrol,
|
||||
Priority = 0,
|
||||
ZoneIndex = topology.ZoneIndex,
|
||||
Status = AetheriaRuntimeAgentTaskStatuses.Assigned,
|
||||
AssignedEntityIndex = entityIndex,
|
||||
CompletionRadius = 10,
|
||||
TargetOrbitKeys = orbits
|
||||
.OrderBy(_ => spawnRandom.NextFloat())
|
||||
.Take(4)
|
||||
.Select(orbit => orbit.OrbitKey)
|
||||
.ToArray()
|
||||
});
|
||||
entities.Add(enemy);
|
||||
}
|
||||
|
||||
var localPlayerIndex = -1;
|
||||
if (topology.ZoneIndex == world.Topology.EntranceZoneIndex)
|
||||
{
|
||||
var protagonist = factions[0];
|
||||
var starter = new AetheriaDaemonLoadoutGenerator(
|
||||
catalog,
|
||||
world.Topology.GenerationSeed ^ 0x50A7_EA11u,
|
||||
topology.ZoneIndex,
|
||||
homes,
|
||||
adjacency,
|
||||
isPrelude: isPrelude).Build("ship", protagonist.CorporationKey);
|
||||
localPlayerIndex = entities.Count;
|
||||
entities.Add(AetheriaDaemonGeneratedEntityFactory.Create(
|
||||
"Pilot",
|
||||
"ship",
|
||||
protagonist.CorporationKey,
|
||||
starter));
|
||||
playerEntityIndex = localPlayerIndex;
|
||||
}
|
||||
|
||||
ApplyOrbitalPositions(orbits, entities);
|
||||
|
||||
zones.Add(topology.ZoneIndex, new AetheriaDaemonMaterializedTutorialZone(
|
||||
topology,
|
||||
celestial,
|
||||
nearest.CorporationKey,
|
||||
factionPresence,
|
||||
orbits.ToArray(),
|
||||
entities.ToArray(),
|
||||
agentTasks.ToArray(),
|
||||
localPlayerIndex));
|
||||
}
|
||||
|
||||
if (playerEntityIndex < 0)
|
||||
throw new InvalidOperationException("Tutorial materialization produced no player entry ship.");
|
||||
return new AetheriaDaemonMaterializedTutorialWorld(
|
||||
world.Topology,
|
||||
zones,
|
||||
world.Topology.EntranceZoneIndex,
|
||||
playerEntityIndex);
|
||||
}
|
||||
|
||||
private static void ApplyOrbitalPositions(
|
||||
IReadOnlyList<AetheriaOrbitSnapshot> orbits,
|
||||
IReadOnlyList<AetheriaEntitySnapshot> entities)
|
||||
{
|
||||
var runtimeZone = new AetheriaRuntimeZoneSnapshotCommit
|
||||
{
|
||||
Orbits = orbits.Select(orbit => new AetheriaRuntimeOrbitSnapshotCommit
|
||||
{
|
||||
OrbitKey = orbit.OrbitKey,
|
||||
ParentOrbitKey = orbit.ParentOrbitKey,
|
||||
Distance = orbit.Distance,
|
||||
Phase = orbit.Phase,
|
||||
Period = orbit.Period,
|
||||
FixedPositionX = orbit.FixedPosition?.X ?? 0,
|
||||
FixedPositionY = orbit.FixedPosition?.Y ?? 0
|
||||
}).ToArray()
|
||||
};
|
||||
var positions = AetheriaRuntimeOrbitQueries.BuildPositions(runtimeZone);
|
||||
foreach (var entity in entities.Where(entity => !string.IsNullOrWhiteSpace(entity.OrbitKey)))
|
||||
{
|
||||
if (!positions.TryGetValue(entity.OrbitKey, out var position))
|
||||
throw new InvalidDataException(
|
||||
$"Generated orbital entity '{entity.Name}' references missing orbit '{entity.OrbitKey}'.");
|
||||
entity.Position.X = position.x;
|
||||
entity.Position.Z = position.z;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<int, int> DistancesFrom(
|
||||
int start,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<int>> adjacency)
|
||||
{
|
||||
var result = adjacency.Keys.ToDictionary(index => index, _ => int.MaxValue);
|
||||
var queue = new Queue<int>();
|
||||
result[start] = 0;
|
||||
queue.Enqueue(start);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var neighbor in adjacency[current])
|
||||
{
|
||||
if (result[neighbor] <= result[current] + 1) continue;
|
||||
result[neighbor] = result[current] + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int Sign(float value) => value < 0 ? -1 : value > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
|
||||
public sealed record AetheriaDaemonGeneratedZonePlan(
|
||||
uint GenerationSeed,
|
||||
uint PostBodyRandomState,
|
||||
double Radius,
|
||||
double Mass,
|
||||
double HierarchyMass,
|
||||
int EmptyOrbitCount,
|
||||
AetheriaOrbitSnapshot[] Orbits,
|
||||
AetheriaBodySnapshot[] Bodies)
|
||||
{
|
||||
public double GravityTerrainDepth { get; init; } = 64;
|
||||
public double GravityTerrainDepthExponent { get; init; } = 2;
|
||||
public double GravityTerrainBoundaryFog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AetheriaDaemonTutorialWorldPlan(
|
||||
AetheriaDaemonTutorialTopology Topology,
|
||||
IReadOnlyDictionary<int, AetheriaDaemonGeneratedZonePlan> Zones);
|
||||
|
||||
public static class AetheriaDaemonTutorialWorldGenerator
|
||||
{
|
||||
public static AetheriaDaemonTutorialWorldPlan Generate(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
uint seed,
|
||||
AetheriaDaemonTutorialTopologySettings? settings = null)
|
||||
{
|
||||
var topology = AetheriaDaemonTutorialTopologyGenerator.GenerateFossil(factions, seed, settings);
|
||||
var zones = topology.Zones.ToDictionary(
|
||||
zone => zone.ZoneIndex,
|
||||
zone => AetheriaDaemonZoneBodyGenerator.Generate(
|
||||
seed,
|
||||
zone,
|
||||
AetheriaDaemonTutorialTopologyGenerator.TutorialCloudDensity(
|
||||
zone.X,
|
||||
zone.Y,
|
||||
topology.NoisePosition)));
|
||||
return new AetheriaDaemonTutorialWorldPlan(topology, zones);
|
||||
}
|
||||
|
||||
public static AetheriaDaemonTutorialWorldPlan GenerateRegular(
|
||||
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
|
||||
uint seed,
|
||||
AetheriaDaemonRegularTopologySettings? settings = null)
|
||||
{
|
||||
var topology = AetheriaDaemonRegularTopologyGenerator.GenerateFossil(factions, seed, settings);
|
||||
var zones = topology.Zones.ToDictionary(
|
||||
zone => zone.ZoneIndex,
|
||||
zone => AetheriaDaemonZoneBodyGenerator.Generate(
|
||||
seed,
|
||||
zone,
|
||||
AetheriaDaemonRegularTopologyGenerator.MainCloudDensity(zone.X, zone.Y),
|
||||
"regular"));
|
||||
return new AetheriaDaemonTutorialWorldPlan(topology, zones);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Daemon-owned port of the fossil ZoneGenerator celestial hierarchy. Entity
|
||||
/// population consumes this plan later; it cannot regenerate bodies or orbits.
|
||||
/// </summary>
|
||||
public static class AetheriaDaemonZoneBodyGenerator
|
||||
{
|
||||
private const int MaximumPlacementSamples = 32;
|
||||
|
||||
public static AetheriaDaemonGeneratedZonePlan Generate(
|
||||
uint galaxySeed,
|
||||
AetheriaDaemonTutorialZoneTopology zone,
|
||||
float cloudDensity,
|
||||
string identityPrefix = "tutorial")
|
||||
{
|
||||
var density = Saturate(cloudDensity / 2f);
|
||||
var radius = ExponentialLerp(density, 1.5f, 1000, 10000);
|
||||
var mass = ExponentialLerp(density, 1.5f, 10000, 500000);
|
||||
var targetSubzoneCount = ExponentialLerp(density, 1.5f, 0, 8);
|
||||
var zoneSeed = StableZoneSeed(galaxySeed, zone);
|
||||
var random = new CultMath.Random(zoneSeed);
|
||||
var planets = new List<PlanetNode>();
|
||||
|
||||
if (targetSubzoneCount > 1)
|
||||
{
|
||||
var boundary = new Circle(new CultMath.float2(0, 0), radius * 0.9f);
|
||||
var occupied = new List<Circle>();
|
||||
var start = random.NextFloat(radius * 0.25f, radius * 0.5f) * random.NextFloat2Direction();
|
||||
occupied.Add(new Circle(start, -boundary.DistanceTo(start)));
|
||||
|
||||
var samples = 0;
|
||||
while (occupied.Count < targetSubzoneCount && samples < MaximumPlacementSamples)
|
||||
{
|
||||
samples = 0;
|
||||
for (var i = 0; i < MaximumPlacementSamples; i++)
|
||||
{
|
||||
var point = random.NextFloat2(-radius, radius);
|
||||
var tangentRadius = MathF.Min(
|
||||
-boundary.DistanceTo(point),
|
||||
occupied.Min(circle => circle.DistanceTo(point)));
|
||||
if (tangentRadius > 0)
|
||||
{
|
||||
occupied.Add(new Circle(point, tangentRadius));
|
||||
break;
|
||||
}
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
var totalArea = occupied.Sum(circle => circle.Area);
|
||||
foreach (var circle in occupied)
|
||||
planets.AddRange(GenerateSystem(
|
||||
ref random,
|
||||
circle.Area / totalArea * mass,
|
||||
circle.Radius,
|
||||
circle.Center));
|
||||
}
|
||||
else
|
||||
{
|
||||
planets.AddRange(GenerateSystem(ref random, mass, radius, new CultMath.float2(0, 0)));
|
||||
}
|
||||
|
||||
var orbitKeys = planets.Select((_, index) => $"{identityPrefix}.zone.{zone.ZoneIndex}.orbit.{index}").ToArray();
|
||||
var nodeIndices = planets.Select((node, index) => (node, index)).ToDictionary(value => value.node, value => value.index);
|
||||
var orbits = planets.Select((planet, index) => new AetheriaOrbitSnapshot
|
||||
{
|
||||
OrbitKey = orbitKeys[index],
|
||||
ParentOrbitKey = planet.Parent == null ? "" : orbitKeys[nodeIndices[planet.Parent]],
|
||||
FixedPosition = new AetheriaVector2 { X = planet.FixedPosition.x, Y = planet.FixedPosition.y },
|
||||
Distance = planet.Distance,
|
||||
Phase = planet.Phase,
|
||||
Period = planet.Distance
|
||||
}).ToArray();
|
||||
|
||||
var bodies = new List<AetheriaBodySnapshot>();
|
||||
for (var nodeIndex = 0; nodeIndex < planets.Count; nodeIndex++)
|
||||
{
|
||||
var planet = planets[nodeIndex];
|
||||
if (planet.Empty)
|
||||
continue;
|
||||
bodies.Add(ProjectBody(
|
||||
identityPrefix, zone.ZoneIndex, bodies.Count, planet, orbits[nodeIndex], ref random));
|
||||
}
|
||||
|
||||
return new AetheriaDaemonGeneratedZonePlan(
|
||||
zoneSeed,
|
||||
random.state,
|
||||
radius,
|
||||
mass,
|
||||
planets.Sum(planet => (double)planet.Mass),
|
||||
planets.Count(planet => planet.Empty),
|
||||
orbits,
|
||||
bodies.ToArray());
|
||||
}
|
||||
|
||||
private static PlanetNode[] GenerateSystem(
|
||||
ref CultMath.Random random,
|
||||
float mass,
|
||||
float radius,
|
||||
CultMath.float2 fixedPosition)
|
||||
{
|
||||
var root = new PlanetNode
|
||||
{
|
||||
Mass = mass,
|
||||
ChildDistanceMaximum = radius * 0.75f,
|
||||
ChildDistanceMinimum = PlanetSafetyRadius(mass),
|
||||
FixedPosition = fixedPosition
|
||||
};
|
||||
var rosette = random.NextFloat() < 0.1f;
|
||||
if (rosette)
|
||||
{
|
||||
root.ExpandRosette(ref random, (int)(random.NextFloat(1, 5) + random.NextFloat(1, 5)));
|
||||
root.ExpandSolar(
|
||||
ref random,
|
||||
(int)(random.NextFloat(1, 3) * random.NextFloat(1, 2)),
|
||||
0.6f, 0.8f, 1.25f, 1.75f, 1, 0.1f);
|
||||
var averageChildMass = root.Children.Sum(value => value.Mass) / root.Children.Count;
|
||||
foreach (var planet in root.Children.Where(value => value.Mass > 1000).ToArray())
|
||||
{
|
||||
var scale = planet.Mass / averageChildMass;
|
||||
planet.ExpandSolar(
|
||||
ref random,
|
||||
(int)(random.NextFloat(1, 3 * scale) + random.NextFloat(1, 3 * scale)),
|
||||
0.75f, 2.5f, 1 + scale * 0.25f, 1.05f + scale * 0.5f,
|
||||
random.NextFloat() * random.NextFloat() * 10 + 1,
|
||||
0.5f);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
root.ExpandSolar(
|
||||
ref random,
|
||||
random.NextInt(5, 15),
|
||||
0.75f, 2.5f, 1.1f, 1.25f,
|
||||
random.NextFloat() * random.NextFloat() * 10 + 1,
|
||||
0.25f);
|
||||
}
|
||||
|
||||
var expanded = new HashSet<PlanetNode>();
|
||||
var binaries = new HashSet<PlanetNode>();
|
||||
for (var pass = 0; pass < 5; pass++)
|
||||
{
|
||||
var candidates = root.All()
|
||||
.Where(planet => planet != root &&
|
||||
(!rosette || planet.Parent != root) &&
|
||||
planet.Mass > 100 &&
|
||||
!expanded.Contains(planet))
|
||||
.ToArray();
|
||||
foreach (var planet in candidates)
|
||||
{
|
||||
if (random.NextFloat() >= 0.25f)
|
||||
continue;
|
||||
if (random.NextFloat() < 0.1f)
|
||||
{
|
||||
planet.ExpandRosette(ref random, 2);
|
||||
binaries.UnionWith(planet.Children);
|
||||
}
|
||||
else
|
||||
{
|
||||
planet.ExpandSolar(
|
||||
ref random,
|
||||
planet.Mass < 1000 ? random.NextInt(1, 3) : random.NextInt(2, 6),
|
||||
0.75f, 1.5f, 1.05f, 1.25f, 1, 0.15f);
|
||||
}
|
||||
expanded.Add(planet);
|
||||
}
|
||||
}
|
||||
|
||||
var beltCandidates = root.All()
|
||||
.Where(planet => planet != root &&
|
||||
(!rosette || planet.Parent != root) &&
|
||||
planet.Mass < 500 &&
|
||||
!binaries.Contains(planet) &&
|
||||
planet.Children.Count == 0)
|
||||
.Reverse()
|
||||
.ToArray();
|
||||
foreach (var planet in beltCandidates)
|
||||
{
|
||||
if (random.NextFloat() < 0.25f && planet.Parent!.Children.All(sibling => !sibling.Belt))
|
||||
planet.Belt = true;
|
||||
}
|
||||
|
||||
var all = root.All().ToArray();
|
||||
var totalMass = all.Sum(planet => planet.Mass);
|
||||
foreach (var planet in all)
|
||||
planet.Mass = planet.Mass / totalMass * mass;
|
||||
return all;
|
||||
}
|
||||
|
||||
private static AetheriaBodySnapshot ProjectBody(
|
||||
string identityPrefix,
|
||||
int zoneIndex,
|
||||
int bodyIndex,
|
||||
PlanetNode planet,
|
||||
AetheriaOrbitSnapshot orbit,
|
||||
ref CultMath.Random random)
|
||||
{
|
||||
var kind = planet.Belt ? "asteroid_belt" :
|
||||
planet.Mass > 5000 ? "sun" :
|
||||
planet.Mass > 1000 ? "gas_giant" :
|
||||
planet.Mass > 100 ? "planet" : "planetoid";
|
||||
var body = new AetheriaBodySnapshot
|
||||
{
|
||||
BodyKey = $"{identityPrefix}.zone.{zoneIndex}.body.{bodyIndex}",
|
||||
Kind = kind,
|
||||
Name = $"Z{zoneIndex:D2}-{bodyIndex:D3}",
|
||||
OrbitKey = orbit.OrbitKey,
|
||||
Mass = planet.Mass,
|
||||
Resources = [],
|
||||
BodyRadiusMultiplier = 1,
|
||||
GravityRadiusMultiplier = 1,
|
||||
GravityDepthMultiplier = 1,
|
||||
GravityDepthExponent = 2,
|
||||
GravityInfluenceCenterX = double.NaN,
|
||||
GravityInfluenceCenterZ = double.NaN,
|
||||
GravityInfluenceRadius = 500 * Math.Pow(planet.Mass, 0.25),
|
||||
GravityWellDepth = 30 * Math.Pow(planet.Mass, 0.175),
|
||||
GravityWaveRadius = 500 * Math.Pow(planet.Mass, 0.25),
|
||||
GravityWaveDepth = 0.5 * Math.Pow(planet.Mass, 0.3),
|
||||
GravityWaveSpeed = 0.025,
|
||||
Asteroids = [],
|
||||
GasGiantVisual = new AetheriaGasGiantVisualState(),
|
||||
SunVisual = new AetheriaSunVisualState()
|
||||
};
|
||||
|
||||
if (kind == "asteroid_belt")
|
||||
{
|
||||
var count = Math.Max(0, (int)(MathF.Pow(planet.Mass * planet.Distance, 0.5f) + 11));
|
||||
var asteroids = new AetheriaAsteroidSnapshot[count];
|
||||
for (var asteroidIndex = 0; asteroidIndex < count; asteroidIndex++)
|
||||
{
|
||||
asteroids[asteroidIndex] = new AetheriaAsteroidSnapshot
|
||||
{
|
||||
Distance = planet.Distance + random.NextFloat() * (random.NextFloat() - 0.5f) *
|
||||
(5 * MathF.Pow(planet.Distance, 0.666f) + 50),
|
||||
Phase = random.NextFloat(),
|
||||
Size = random.NextFloat(),
|
||||
RotationSpeed = ExponentialLerp(random.NextFloat(), 2, 0.1f, 0.5f),
|
||||
MiningAccumulators = []
|
||||
};
|
||||
}
|
||||
body.Asteroids = asteroids;
|
||||
}
|
||||
else if (kind is "gas_giant" or "sun")
|
||||
{
|
||||
var colors = new List<AetheriaColor>();
|
||||
if (kind == "sun")
|
||||
{
|
||||
var primary = random.NextFloat();
|
||||
var secondary = Fraction(primary + 1 + 0.33f * (random.NextFloat() > 0.5f ? 1 : -1));
|
||||
colors.Add(Color(Hsv(primary, 0.85f, 0.5f), 0));
|
||||
colors.Add(Color(Hsv(secondary, 0.85f, 1), 1));
|
||||
var fog = Hsv(primary, 0.55f, 1);
|
||||
var light = Hsv(primary, 0.5f, 1);
|
||||
body.SunVisual = new AetheriaSunVisualState
|
||||
{
|
||||
FogTintColor = Vector(fog),
|
||||
LightColor = Vector(light),
|
||||
LightRadiusMultiplier = 1
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var primary = random.NextFloat();
|
||||
var right = Fraction(primary + 0.25f);
|
||||
var left = Fraction(primary + 0.75f);
|
||||
var bandCount = (int)(ExponentialLerp(random.NextFloat(), 2, 5, 8) + 0.5f);
|
||||
for (var band = 0; band < bandCount; band++)
|
||||
{
|
||||
var time = (float)band / (bandCount - 1);
|
||||
var hue = random.NextFloat() > 0.25f ? primary : random.NextFloat() > 0.5f ? right : left;
|
||||
colors.Add(Color(Hsv(
|
||||
hue,
|
||||
ExponentialLerp(random.NextFloat(), 0.5f, 0, 0.8f),
|
||||
ExponentialLerp(random.NextFloat(), 0.5f, 0, 0.8f)), time));
|
||||
}
|
||||
}
|
||||
body.GasGiantVisual = new AetheriaGasGiantVisualState
|
||||
{
|
||||
FirstOffsetDomainRotationSpeed = kind == "sun" ? 5 : 0,
|
||||
FirstOffsetRotationSpeed = 5,
|
||||
SecondOffsetDomainRotationSpeed = -25,
|
||||
SecondOffsetRotationSpeed = 10,
|
||||
AlbedoRotationSpeed = -3,
|
||||
Colors = colors.ToArray(),
|
||||
MaterialOverrides = []
|
||||
};
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private static uint StableZoneSeed(uint galaxySeed, AetheriaDaemonTutorialZoneTopology zone)
|
||||
{
|
||||
var hash = 2166136261u ^ galaxySeed;
|
||||
foreach (var value in System.Text.Encoding.UTF8.GetBytes(zone.Name ?? ""))
|
||||
hash = (hash ^ value) * 16777619u;
|
||||
hash = (hash ^ unchecked((uint)BitConverter.SingleToInt32Bits(zone.X))) * 16777619u;
|
||||
hash = (hash ^ unchecked((uint)BitConverter.SingleToInt32Bits(zone.Y))) * 16777619u;
|
||||
hash = (hash ^ unchecked((uint)zone.ZoneIndex)) * 16777619u;
|
||||
return hash == 0 ? 0x6E62_4EB7u : hash;
|
||||
}
|
||||
|
||||
private static float PlanetSafetyRadius(float mass) => 2.5f * MathF.Pow(mass, 0.25f);
|
||||
private static float ExponentialLerp(float value, float exponent, float minimum, float maximum) =>
|
||||
minimum + MathF.Pow(Saturate(value), exponent) * (maximum - minimum);
|
||||
private static float Saturate(float value) => value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
private static float Fraction(float value) => value - MathF.Floor(value);
|
||||
|
||||
private static CultMath.float3 Hsv(float hue, float saturation, float value)
|
||||
{
|
||||
hue = Fraction(hue);
|
||||
var sector = hue * 6;
|
||||
var index = (int)MathF.Floor(sector);
|
||||
var fraction = sector - index;
|
||||
var p = value * (1 - saturation);
|
||||
var q = value * (1 - saturation * fraction);
|
||||
var t = value * (1 - saturation * (1 - fraction));
|
||||
return index switch
|
||||
{
|
||||
0 => new CultMath.float3(value, t, p),
|
||||
1 => new CultMath.float3(q, value, p),
|
||||
2 => new CultMath.float3(p, value, t),
|
||||
3 => new CultMath.float3(p, q, value),
|
||||
4 => new CultMath.float3(t, p, value),
|
||||
_ => new CultMath.float3(value, p, q)
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaColor Color(CultMath.float3 value, float time) => new()
|
||||
{
|
||||
X = value.x,
|
||||
Y = value.y,
|
||||
Z = value.z,
|
||||
W = time
|
||||
};
|
||||
|
||||
private static AetheriaVector3 Vector(CultMath.float3 value) => new()
|
||||
{
|
||||
X = value.x,
|
||||
Y = value.y,
|
||||
Z = value.z
|
||||
};
|
||||
|
||||
private sealed record Circle(CultMath.float2 Center, float Radius)
|
||||
{
|
||||
public float Area => MathF.PI * Radius * Radius;
|
||||
public float DistanceTo(CultMath.float2 point) => CultMath.math.length(point - Center) - Radius;
|
||||
}
|
||||
|
||||
private sealed class PlanetNode
|
||||
{
|
||||
public float Distance;
|
||||
public float Phase;
|
||||
public float Mass;
|
||||
public float ChildDistanceMinimum;
|
||||
public float ChildDistanceMaximum;
|
||||
public bool Empty;
|
||||
public bool Belt;
|
||||
public List<PlanetNode> Children { get; } = [];
|
||||
public PlanetNode? Parent;
|
||||
public CultMath.float2 FixedPosition;
|
||||
|
||||
public IEnumerable<PlanetNode> All() => new[] { this }.Concat(Children.SelectMany(child => child.All()));
|
||||
|
||||
public void ExpandRosette(ref CultMath.Random random, int vertices)
|
||||
{
|
||||
Empty = true;
|
||||
var sharedMass = Mass / vertices * 2;
|
||||
var proportion = vertices % 2 == 0 ? random.NextFloat(0.5f, 0.95f) : 0.5f;
|
||||
var distance = (ChildDistanceMinimum + ChildDistanceMaximum) / 2;
|
||||
var p0 = new CultMath.float2(0, distance);
|
||||
var angle = 1f / vertices * MathF.PI * 2;
|
||||
var p1 = new CultMath.float2(MathF.Cos(angle), MathF.Sin(angle)) * distance;
|
||||
var neighborDistance = CultMath.math.length(p0 - p1);
|
||||
var p0ChildDistance = (neighborDistance * proportion - PlanetSafetyRadius(sharedMass * (1 - proportion))) * 0.75f;
|
||||
var p1ChildDistance = (neighborDistance * (1 - proportion) - PlanetSafetyRadius(sharedMass * proportion)) * 0.75f;
|
||||
for (var index = 0; index < vertices; index++)
|
||||
{
|
||||
var child = new PlanetNode
|
||||
{
|
||||
Parent = this,
|
||||
Mass = sharedMass * (index % 2 == 0 ? proportion : 1 - proportion),
|
||||
Distance = distance,
|
||||
Phase = (float)index / vertices,
|
||||
ChildDistanceMaximum = index % 2 == 0 ? p0ChildDistance : p1ChildDistance
|
||||
};
|
||||
child.ChildDistanceMinimum = PlanetSafetyRadius(child.Mass) * 2;
|
||||
Children.Add(child);
|
||||
}
|
||||
ChildDistanceMinimum = distance + p0ChildDistance;
|
||||
}
|
||||
|
||||
public void ExpandSolar(
|
||||
ref CultMath.Random random,
|
||||
int count,
|
||||
float massMultiplierMinimum,
|
||||
float massMultiplierMaximum,
|
||||
float distanceMultiplierMinimum,
|
||||
float distanceMultiplierMaximum,
|
||||
float jupiterJump,
|
||||
float massFraction)
|
||||
{
|
||||
if (count == 0 || ChildDistanceMaximum < ChildDistanceMinimum)
|
||||
return;
|
||||
var masses = new float[count];
|
||||
var distances = new float[count];
|
||||
float massTotal = distances[0] = masses[0] = 1;
|
||||
for (var index = 1; index < count; index++)
|
||||
{
|
||||
massTotal += masses[index] = masses[index - 1] *
|
||||
random.NextFloat(massMultiplierMinimum, massMultiplierMaximum) *
|
||||
(count / 2 == index ? jupiterJump : 1);
|
||||
distances[index] = distances[index - 1] *
|
||||
random.NextFloat(distanceMultiplierMinimum, distanceMultiplierMaximum);
|
||||
}
|
||||
for (var index = 0; index < count; index++)
|
||||
masses[index] *= random.NextFloat(0.1f, 1);
|
||||
for (var index = 0; index < count; index++)
|
||||
masses[index] = masses[index] / massTotal * Mass * massFraction;
|
||||
if (count > 1)
|
||||
{
|
||||
var original = distances.ToArray();
|
||||
for (var index = 0; index < count; index++)
|
||||
distances[index] = ChildDistanceMinimum +
|
||||
(ChildDistanceMaximum - ChildDistanceMinimum) *
|
||||
((original[index] - original[0]) / (original[^1] - original[0])) +
|
||||
PlanetSafetyRadius(masses[index]);
|
||||
}
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
if (masses[index] <= 1)
|
||||
continue;
|
||||
var child = new PlanetNode
|
||||
{
|
||||
Parent = this,
|
||||
Mass = masses[index],
|
||||
Distance = distances[index],
|
||||
Phase = random.NextFloat()
|
||||
};
|
||||
child.ChildDistanceMinimum = PlanetSafetyRadius(child.Mass) * 2;
|
||||
child.ChildDistanceMaximum = MathF.Min(
|
||||
index == 0 ? child.Distance - ChildDistanceMinimum : child.Distance - distances[index - 1],
|
||||
index < count - 1 ? distances[index + 1] - child.Distance : float.PositiveInfinity);
|
||||
if (float.IsNaN(child.Distance))
|
||||
throw new NotFiniteNumberException("Planet created with NaN distance.");
|
||||
if (child.ChildDistanceMaximum > child.ChildDistanceMinimum)
|
||||
Children.Add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
internal static class AetheriaDaemonTerminusScenarios
|
||||
{
|
||||
public const string Standard = "standard";
|
||||
public const string ReleasedClientProof = "released-client-proof";
|
||||
public const string CargoCapacityRejectionProof = "cargo-capacity-rejection-proof";
|
||||
|
||||
public static string Parse(string? value)
|
||||
{
|
||||
var scenario = string.IsNullOrWhiteSpace(value) ? Standard : value.Trim().ToLowerInvariant();
|
||||
return scenario switch
|
||||
{
|
||||
Standard => Standard,
|
||||
ReleasedClientProof => ReleasedClientProof,
|
||||
CargoCapacityRejectionProof => CargoCapacityRejectionProof,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown Terminus scenario '{value}'. Expected {Standard}, {ReleasedClientProof}, or {CargoCapacityRejectionProof}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AetheriaDaemonZoneGenerator
|
||||
{
|
||||
public const string RunId = "local-terminus";
|
||||
public const uint GenerationSeed = 0xA37E_2026u;
|
||||
|
||||
public static async Task WritePlayableRunAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now,
|
||||
string scenario,
|
||||
AetheriaDeploymentReceipt? deployment = null)
|
||||
{
|
||||
scenario = AetheriaDaemonTerminusScenarios.Parse(scenario);
|
||||
if (deployment != null && (!deployment.Accepted || !string.Equals(deployment.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException("A product Terminus run requires an accepted Terminus deployment.");
|
||||
var runId = deployment == null
|
||||
? RunIdFor(scenario)
|
||||
: $"terminus-{StableToken(deployment.DeploymentId)}";
|
||||
var generationSeed = deployment == null
|
||||
? GenerationSeed
|
||||
: AetheriaDaemonRunFactory.StableSeed(deployment.DeploymentId);
|
||||
var runKey = new CultRecordKey($"global:aetheria.run_state.{runId}.v1");
|
||||
var zoneKey = new CultRecordKey($"global:aetheria.zone_state.{runId}.0.v1");
|
||||
var corporationKeys = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
|
||||
.Select(value => value.CorporationKey)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
if (corporationKeys.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Terminus generation requires at least one typed corporation with a stable corporation key.");
|
||||
}
|
||||
var homeZones = corporationKeys.ToDictionary(value => value, _ => 0, StringComparer.Ordinal);
|
||||
var availabilityFactions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["player"] = corporationKeys.ElementAtOrDefault(0) ?? "",
|
||||
["raider"] = corporationKeys.ElementAtOrDefault(1) ?? corporationKeys.ElementAtOrDefault(0) ?? "",
|
||||
["neutral"] = corporationKeys.ElementAtOrDefault(2) ?? corporationKeys.ElementAtOrDefault(0) ?? ""
|
||||
};
|
||||
var rootRandom = new CultMath.Random(generationSeed);
|
||||
var adjacency = new Dictionary<int, IReadOnlyList<int>> { [0] = Array.Empty<int>() };
|
||||
var loadouts = availabilityFactions.Values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
value => value,
|
||||
_ => new AetheriaDaemonLoadoutGenerator(
|
||||
catalog,
|
||||
(uint)rootRandom.NextInt(1, int.MaxValue),
|
||||
0,
|
||||
homeZones,
|
||||
adjacency),
|
||||
StringComparer.Ordinal);
|
||||
var entities = GenerateEntities(loadouts, availabilityFactions, catalog, scenario, runId);
|
||||
if (deployment != null)
|
||||
ApplyDeployment(entities[1], deployment.Loadout);
|
||||
var entityKeys = Enumerable.Range(0, entities.Length)
|
||||
.Select(index => EntityKeyForRun(runId, 0, index))
|
||||
.ToArray();
|
||||
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync()
|
||||
.ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
settings.ActiveRunKey = runKey.ToString();
|
||||
settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Terminus Pilot" : settings.PlayerName;
|
||||
settings.LastUpdatedAtUtc = now;
|
||||
await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReplaceAsync(settings)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await node.MutableDocument<AetheriaRunState>(runKey).ReplaceAsync(new AetheriaRunState
|
||||
{
|
||||
RunId = runId,
|
||||
EntranceZoneIndex = 0,
|
||||
ExitZoneIndex = 0,
|
||||
CurrentZoneIndex = 0,
|
||||
DiscoveredZoneIndices = [0],
|
||||
ZoneKeys = [zoneKey.ToString()],
|
||||
GenerationSeed = generationSeed,
|
||||
CurrentEntityKey = entityKeys[1],
|
||||
LifecyclePhase = AetheriaRuntimeRunLifecycle.Active,
|
||||
TerminalFrameId = -1,
|
||||
GameMode = AetheriaGameSessionState.TerminusMode,
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await node.MutableDocument<AetheriaZoneState>(zoneKey).ReplaceAsync(new AetheriaZoneState
|
||||
{
|
||||
Name = "Daemon Generated Terminus",
|
||||
Position = Vec2(0, 0),
|
||||
EntityKeys = entityKeys,
|
||||
FactionIndices = [0, 1, 2],
|
||||
OwnerFactionIndex = 0,
|
||||
Orbits = GenerateOrbits(),
|
||||
Bodies = GenerateBodies(),
|
||||
GravityTerrainRadius = 1900,
|
||||
GravityTerrainDepth = 7,
|
||||
GravityTerrainDepthExponent = 1.18,
|
||||
GravityTerrainBoundaryFog = 0.25,
|
||||
GravityTerrainWaveFrequency = 0.55,
|
||||
NextPickupIndex = 0,
|
||||
DroppedPickups = Array.Empty<AetheriaDroppedPickupSnapshot>()
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
for (var i = 0; i < entities.Length; i++)
|
||||
{
|
||||
await node.MutableDocument<AetheriaEntitySnapshot>(new CultRecordKey(entityKeys[i]))
|
||||
.ReplaceAsync(entities[i])
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string RunIdFor(string scenario)
|
||||
{
|
||||
scenario = AetheriaDaemonTerminusScenarios.Parse(scenario);
|
||||
return string.Equals(scenario, AetheriaDaemonTerminusScenarios.Standard, StringComparison.Ordinal)
|
||||
? RunId
|
||||
: $"{RunId}-{scenario}";
|
||||
}
|
||||
|
||||
public static string EntityKey(string scenario, int zoneIndex, int entityIndex)
|
||||
{
|
||||
return $"global:aetheria.run_state.{RunIdFor(scenario)}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
}
|
||||
|
||||
private static string EntityKeyForRun(string runId, int zoneIndex, int entityIndex) =>
|
||||
$"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
|
||||
private static AetheriaOrbitSnapshot[] GenerateOrbits()
|
||||
{
|
||||
var bodies = BodyPlans();
|
||||
return bodies.Select(body => new AetheriaOrbitSnapshot
|
||||
{
|
||||
OrbitKey = $"{body.Key}.orbit",
|
||||
ParentOrbitKey = body.ParentKey.Length == 0 ? "" : $"{body.ParentKey}.orbit",
|
||||
Distance = body.Distance,
|
||||
Phase = body.Phase,
|
||||
FixedPosition = Vec2(body.X, body.Z)
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaBodySnapshot[] GenerateBodies()
|
||||
{
|
||||
return BodyPlans().Select(body => new AetheriaBodySnapshot
|
||||
{
|
||||
BodyKey = body.Key,
|
||||
Kind = body.Kind,
|
||||
Name = body.Name,
|
||||
OrbitKey = $"{body.Key}.orbit",
|
||||
Mass = body.Mass,
|
||||
BodyRadiusMultiplier = body.BodyRadius,
|
||||
GravityRadiusMultiplier = 1,
|
||||
GravityDepthMultiplier = 1,
|
||||
GravityDepthExponent = body.DepthExponent,
|
||||
GravityInfluenceCenterX = body.X,
|
||||
GravityInfluenceCenterZ = body.Z,
|
||||
GravityInfluenceRadius = body.GravityRadius,
|
||||
GravityWellDepth = body.GravityDepth,
|
||||
GravityWaveRadius = body.WaveRadius,
|
||||
GravityWaveDepth = body.WaveDepth,
|
||||
GravityWaveSpeed = body.WaveSpeed,
|
||||
Asteroids = body.Kind == "asteroid_belt" ? Asteroids(body.Distance) : [],
|
||||
GasGiantVisual = GasGiantVisual(body),
|
||||
SunVisual = SunVisual(body)
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static BodyPlan[] BodyPlans()
|
||||
{
|
||||
return
|
||||
[
|
||||
Body("local.sun", "", "sun", "Terminus", 0, 0, 1600, 2.8, 980, 92, 3.0, 480, 11, 1.8),
|
||||
Body("local.inner", "local.sun", "planet", "Cairn", 250, 0.45, 110, 0.78, 230, 20, 2.2, 92, 3, 1.0),
|
||||
Body("local.blue", "local.sun", "planet", "Blueglass", 440, 2.45, 260, 1.18, 360, 36, 2.55, 150, 5, 1.1),
|
||||
Body("local.blue.moon", "local.blue", "moon", "Latch", 520, 2.78, 70, 0.58, 170, 15, 2.8, 70, 2.2, 1.7),
|
||||
Body("local.ember", "local.sun", "planet", "Emberhook", 640, 3.78, 220, 1.05, 330, 31, 2.25, 128, 4.2, 1.3),
|
||||
Body("local.ember.moon", "local.ember", "moon", "Ashwake", 735, 3.45, 86, 0.62, 185, 16, 2.7, 72, 2.4, 1.45),
|
||||
Body("local.belt", "local.sun", "asteroid_belt", "Cinder Belt", 760, -0.28, 140, 1.0, 310, 24, 2.0, 122, 4, 0.8),
|
||||
Body("local.green", "local.sun", "planet", "Marrow", 930, 1.28, 180, 0.96, 300, 26, 2.35, 112, 3.6, 1.0),
|
||||
Body("local.outer", "local.sun", "gas_giant", "Vesper", 1120, 0.58, 420, 1.55, 500, 48, 2.4, 230, 7, 0.72),
|
||||
Body("local.outer.moon.a", "local.outer", "moon", "Iris", 1240, 0.88, 92, 0.66, 195, 17, 2.75, 78, 2.5, 1.2),
|
||||
Body("local.outer.moon.b", "local.outer", "moon", "Hush", 1025, 0.28, 76, 0.55, 180, 15, 2.9, 70, 2.1, 1.5),
|
||||
Body("local.deep", "local.sun", "gas_giant", "Nacre", 1420, 5.72, 340, 1.36, 440, 41, 2.55, 200, 5.8, 0.95)
|
||||
];
|
||||
}
|
||||
|
||||
private static BodyPlan Body(
|
||||
string key,
|
||||
string parentKey,
|
||||
string kind,
|
||||
string name,
|
||||
double distance,
|
||||
double phase,
|
||||
double mass,
|
||||
double bodyRadius,
|
||||
double gravityRadius,
|
||||
double gravityDepth,
|
||||
double depthExponent,
|
||||
double waveRadius,
|
||||
double waveDepth,
|
||||
double waveSpeed)
|
||||
{
|
||||
if (gravityDepth <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(gravityDepth), "Gravity depth is an unsigned well magnitude; Ymir positive radial strength attracts.");
|
||||
|
||||
return new BodyPlan(
|
||||
key,
|
||||
parentKey,
|
||||
kind,
|
||||
name,
|
||||
distance,
|
||||
phase,
|
||||
Math.Cos(phase) * distance,
|
||||
Math.Sin(phase) * distance,
|
||||
mass,
|
||||
bodyRadius,
|
||||
gravityRadius,
|
||||
gravityDepth,
|
||||
depthExponent,
|
||||
waveRadius,
|
||||
waveDepth,
|
||||
waveSpeed);
|
||||
}
|
||||
|
||||
private static AetheriaEntitySnapshot[] GenerateEntities(
|
||||
IReadOnlyDictionary<string, AetheriaDaemonLoadoutGenerator> loadouts,
|
||||
IReadOnlyDictionary<string, string> availabilityFactions,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string scenario,
|
||||
string runId)
|
||||
{
|
||||
var keys = Enumerable.Range(0, 12)
|
||||
.Select(index => EntityKeyForRun(runId, 0, index))
|
||||
.ToArray();
|
||||
|
||||
var entities = new[]
|
||||
{
|
||||
Entity(loadouts, availabilityFactions, "Anchor Station", "station", -50, -30, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], 2, 900, AetheriaDaemonNativeCatalog.DockyardHullItemKey),
|
||||
Entity(loadouts, availabilityFactions, "Vanguard One", "ship", -40, -30, 0, 0, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]]),
|
||||
Entity(loadouts, availabilityFactions, "Wing Two", "ship", 145, 125, -5, 7, "player", 450, keys[6], [keys[0], keys[1], keys[6], keys[8]]),
|
||||
Entity(loadouts, availabilityFactions, "Torch Three", "ship", -235, 210, 8, -4, "player", 500, keys[7], [keys[0], keys[1], keys[7]]),
|
||||
Entity(loadouts, availabilityFactions, "Foundry Tug", "ship", 330, -155, -2, 3, "player", 390, keys[6], [keys[0], keys[1], keys[6], keys[10]]),
|
||||
Entity(loadouts, availabilityFactions, "Derelict Relay", "station", -390, 270, 0, 0, "neutral", 160, "", [keys[0], keys[3]]),
|
||||
Entity(loadouts, availabilityFactions, "Ash Raider", "ship", 20, -30, -5, -2, "raider", 320, "", [keys[0], keys[1], keys[2], keys[4]]),
|
||||
Entity(loadouts, availabilityFactions, "Cinder Knife", "ship", 585, -115, -7, 1, "raider", 280, "", [keys[0], keys[3]]),
|
||||
Entity(loadouts, availabilityFactions, "Blackwake", "ship", 690, 300, -4, -3, "raider", 230, "", [keys[2]]),
|
||||
Entity(loadouts, availabilityFactions, "Vesper Sloop", "ship", 960, 620, -3, -5, "raider", 250, "", [keys[4]]),
|
||||
Entity(loadouts, availabilityFactions, "Survey Skiff", "ship", -610, -365, 3, 5, "neutral", 210, "", [keys[4], keys[5]]),
|
||||
Entity(loadouts, availabilityFactions, "Lagrange Beacon", "station", 105, 510, 0, 0, "neutral", 190, "", [keys[0], keys[1], keys[6]])
|
||||
};
|
||||
foreach (var index in new[] { 1, 2, 3, 4 })
|
||||
entities[index].HomeEntityKey = keys[0];
|
||||
if (!string.Equals(scenario, AetheriaDaemonTerminusScenarios.Standard, StringComparison.Ordinal))
|
||||
ApplyReleasedClientProofScenario(entities, catalog, scenario);
|
||||
entities[2].AgentTaskCapabilities = ["attack", "defend", "explore"];
|
||||
entities[3].AgentTaskCapabilities = ["attack", "defend"];
|
||||
entities[4].AgentTaskCapabilities = ["mine", "haul", "tow", "explore"];
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ApplyReleasedClientProofScenario(
|
||||
AetheriaEntitySnapshot[] entities,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string scenario)
|
||||
{
|
||||
// Start the proof pilot at rest on the station's authored center. The proof session owns
|
||||
// its paused initial clock, so client connection latency cannot alter this starting state.
|
||||
entities[1].Position.X = entities[0].Position.X;
|
||||
entities[1].Position.Z = entities[0].Position.Z;
|
||||
entities[1].Velocity = Vec2(0, 0);
|
||||
var playerFactionKey = entities[1].FactionKey;
|
||||
for (var index = 0; index < entities.Length; index++)
|
||||
if (index != 6)
|
||||
entities[index].FactionKey = playerFactionKey;
|
||||
entities[1].CargoContents = [new AetheriaCargoBayLoadout()];
|
||||
entities[6].StatGrids =
|
||||
[
|
||||
StatGrid("hull", 1),
|
||||
StatGrid("shield", 0),
|
||||
StatGrid("heat", 0)
|
||||
];
|
||||
var salvage = entities[6].Equipment.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("The starter raider loadout produced no canonical salvage item.");
|
||||
var generation = entities[6].LoadoutGeneration
|
||||
?? throw new InvalidOperationException("The starter raider loadout produced no generation receipt.");
|
||||
var selections = generation.Selections ?? Array.Empty<AetheriaLoadoutGenerationSelection>();
|
||||
var salvageSelection = selections.First(selection =>
|
||||
string.Equals(selection.Role, "equipment", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.ItemKey, salvage.ItemKey, StringComparison.Ordinal));
|
||||
entities[6].CargoContents =
|
||||
[
|
||||
new AetheriaCargoBayLoadout
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new AetheriaLoadoutItemSlot
|
||||
{
|
||||
Item = new AetheriaLoadoutItem
|
||||
{
|
||||
ItemKey = salvage.ItemKey,
|
||||
Quality = salvage.Quality,
|
||||
Durability = salvage.Durability,
|
||||
Quantity = 1,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
entities[6].Equipment = Array.Empty<AetheriaEntityItemSlot>();
|
||||
entities[6].WeaponGroups = Array.Empty<AetheriaWeaponGroupSnapshot>();
|
||||
generation.Selections = selections
|
||||
.Where(selection => string.Equals(selection.Role, "hull", StringComparison.Ordinal))
|
||||
.Append(new AetheriaLoadoutGenerationSelection
|
||||
{
|
||||
Role = "cargo",
|
||||
ItemKey = salvageSelection.ItemKey,
|
||||
ManufacturerKey = salvageSelection.ManufacturerKey,
|
||||
Price = salvageSelection.Price,
|
||||
ManufacturerDistance = salvageSelection.ManufacturerDistance,
|
||||
Allegiance = salvageSelection.Allegiance
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (string.Equals(scenario, AetheriaDaemonTerminusScenarios.CargoCapacityRejectionProof, StringComparison.Ordinal))
|
||||
FillCargoBays(entities[1], salvage.ItemKey, catalog);
|
||||
}
|
||||
|
||||
private static void ApplyDeployment(
|
||||
AetheriaEntitySnapshot entity,
|
||||
AetheriaRuntimeEntityLoadoutCommit loadout)
|
||||
{
|
||||
entity.Name = string.IsNullOrWhiteSpace(loadout.Name) ? entity.Name : loadout.Name;
|
||||
entity.Kind = string.IsNullOrWhiteSpace(loadout.Kind) ? entity.Kind : loadout.Kind;
|
||||
entity.FactionKey = string.IsNullOrWhiteSpace(loadout.FactionKey) ? entity.FactionKey : loadout.FactionKey;
|
||||
entity.HullItemKey = loadout.Hull?.ItemKey ?? entity.HullItemKey;
|
||||
entity.Equipment = (loadout.Equipment ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.CargoBays = (loadout.CargoBays ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.DockingBays = (loadout.DockingBays ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.CargoContents = (loadout.CargoContents ?? []).Select(ToCargoBay).ToArray();
|
||||
entity.DockingBayContents = (loadout.DockingBayContents ?? []).Select(ToCargoBay).ToArray();
|
||||
entity.DockingBayAssignments = (loadout.DockingBayAssignments ?? []).ToArray();
|
||||
entity.WeaponGroups = (loadout.WeaponGroups ?? [])
|
||||
.Select(group => new AetheriaWeaponGroupSnapshot
|
||||
{
|
||||
EquipmentIndices = (group ?? []).ToArray()
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaEntityItemSlot ToEntitySlot(AetheriaRuntimeLoadoutItemSlotCommit slot) => new()
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
|
||||
Rotation = slot.Rotation ?? "None",
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
};
|
||||
|
||||
private static AetheriaCargoBayLoadout ToCargoBay(AetheriaRuntimeCargoBayLoadoutCommit bay) => new()
|
||||
{
|
||||
Items = (bay.Items ?? []).Select(slot => new AetheriaLoadoutItemSlot
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
|
||||
Rotation = slot.Rotation ?? "None",
|
||||
Item = new AetheriaLoadoutItem
|
||||
{
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
}
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
private static string StableToken(string value) =>
|
||||
new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '-').ToArray()).Trim('-');
|
||||
|
||||
private static void FillCargoBays(
|
||||
AetheriaEntitySnapshot entity,
|
||||
string itemKey,
|
||||
AetheriaRuntimeCatalogSnapshot catalog)
|
||||
{
|
||||
var item = catalog.FindItem(itemKey)
|
||||
?? throw new InvalidOperationException($"The cargo-capacity proof item '{itemKey}' is absent from the typed catalog.");
|
||||
if (item.Volume <= 0)
|
||||
throw new InvalidOperationException($"The cargo-capacity proof item '{itemKey}' must have positive volume.");
|
||||
|
||||
var cargoBays = (entity.CargoBays ?? Array.Empty<AetheriaEntityItemSlot>())
|
||||
.Select(slot => catalog.FindItem(slot.ItemKey))
|
||||
.Where(candidate => candidate != null && candidate.InteriorOccupiedCells > 0)
|
||||
.ToArray();
|
||||
if (cargoBays.Length == 0)
|
||||
throw new InvalidOperationException("The cargo-capacity proof ship has no generated cargo bay.");
|
||||
|
||||
entity.CargoContents = cargoBays.Select((bay, index) =>
|
||||
{
|
||||
var quantity = (int)Math.Floor(bay!.InteriorOccupiedCells / item.Volume);
|
||||
if (quantity <= 0)
|
||||
throw new InvalidOperationException($"Cargo bay {index} cannot contain one '{itemKey}' proof item.");
|
||||
return new AetheriaCargoBayLoadout
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new AetheriaLoadoutItemSlot
|
||||
{
|
||||
Item = new AetheriaLoadoutItem
|
||||
{
|
||||
ItemKey = itemKey,
|
||||
Quantity = quantity,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaEntitySnapshot Entity(
|
||||
IReadOnlyDictionary<string, AetheriaDaemonLoadoutGenerator> loadouts,
|
||||
IReadOnlyDictionary<string, string> availabilityFactions,
|
||||
string name,
|
||||
string kind,
|
||||
double x,
|
||||
double z,
|
||||
double vx,
|
||||
double vy,
|
||||
string faction,
|
||||
double visibility,
|
||||
string target,
|
||||
string[] contactKeys,
|
||||
int securityLevel = 0,
|
||||
double securityRadius = 0,
|
||||
string hullItemKey = "")
|
||||
{
|
||||
var availabilityFaction = availabilityFactions[faction];
|
||||
var loadout = loadouts[availabilityFaction].Build(kind, availabilityFaction, hullItemKey);
|
||||
var entity = AetheriaDaemonGeneratedEntityFactory.Create(
|
||||
name,
|
||||
kind,
|
||||
availabilityFaction,
|
||||
loadout,
|
||||
x,
|
||||
z,
|
||||
securityLevel: securityLevel,
|
||||
securityRadius: securityRadius);
|
||||
entity.Direction = Vec2(vx == 0 && vy == 0 ? 0 : vx, vx == 0 && vy == 0 ? 1 : vy);
|
||||
entity.LookDirection = Vec2(vx == 0 && vy == 0 ? 0 : vx, vx == 0 && vy == 0 ? 1 : vy);
|
||||
entity.Velocity = Vec2(vx, vy);
|
||||
entity.Visibility = visibility;
|
||||
entity.TargetEntityKey = target;
|
||||
entity.Contacts = contactKeys.Select(key => new AetheriaEntityContactSnapshot
|
||||
{
|
||||
TargetEntityKey = key,
|
||||
InfoGathered = 1
|
||||
}).ToArray();
|
||||
if (string.Equals(faction, "raider", StringComparison.OrdinalIgnoreCase))
|
||||
entity.StatGrids[0].Values[0] = 85;
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static AetheriaGasGiantVisualState GasGiantVisual(BodyPlan body)
|
||||
{
|
||||
if (body.Kind != "gas_giant" && body.Kind != "sun")
|
||||
return new AetheriaGasGiantVisualState();
|
||||
|
||||
return new AetheriaGasGiantVisualState
|
||||
{
|
||||
FirstOffsetDomainRotationSpeed = body.Kind == "sun" ? 5 : 0,
|
||||
FirstOffsetRotationSpeed = 5,
|
||||
SecondOffsetRotationSpeed = 10,
|
||||
SecondOffsetDomainRotationSpeed = -25,
|
||||
AlbedoRotationSpeed = -3,
|
||||
WaveRadiusMultiplier = 1,
|
||||
WaveDepthMultiplier = 1,
|
||||
Colors =
|
||||
[
|
||||
Color(0.85, 0.32, 0.18, 0),
|
||||
Color(0.18, 0.54, 0.92, 0.5),
|
||||
Color(0.92, 0.78, 0.38, 1)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaSunVisualState SunVisual(BodyPlan body)
|
||||
{
|
||||
return body.Kind == "sun"
|
||||
? new AetheriaSunVisualState
|
||||
{
|
||||
LightColor = Vec3(1, 0.86, 0.58),
|
||||
FogTintColor = Vec3(0.32, 0.52, 1),
|
||||
LightRadiusMultiplier = 1.8
|
||||
}
|
||||
: new AetheriaSunVisualState();
|
||||
}
|
||||
|
||||
private static AetheriaAsteroidSnapshot[] Asteroids(double orbitDistance)
|
||||
{
|
||||
return Enumerable.Range(0, 18)
|
||||
.Select(index =>
|
||||
{
|
||||
var phase = index * Math.Tau / 18.0;
|
||||
return new AetheriaAsteroidSnapshot
|
||||
{
|
||||
Distance = orbitDistance + Math.Sin(phase * 3.0) * 42.0,
|
||||
Phase = phase,
|
||||
Size = 0.65 + (index % 5) * 0.24,
|
||||
RotationSpeed = (index % 2 == 0 ? 1 : -1) * (0.04 + index * 0.006)
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaEntityStatGrid StatGrid(string name, double value)
|
||||
{
|
||||
return new AetheriaEntityStatGrid
|
||||
{
|
||||
Name = name,
|
||||
Width = 1,
|
||||
Height = 1,
|
||||
Values = [value]
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaColor Color(double x, double y, double z, double w)
|
||||
{
|
||||
return new AetheriaColor { X = x, Y = y, Z = z, W = w };
|
||||
}
|
||||
|
||||
private static AetheriaVector2 Vec2(double x, double y)
|
||||
{
|
||||
return new AetheriaVector2 { X = x, Y = y };
|
||||
}
|
||||
|
||||
private static AetheriaVector3 Vec3(double x, double y, double z)
|
||||
{
|
||||
return new AetheriaVector3 { X = x, Y = y, Z = z };
|
||||
}
|
||||
|
||||
private sealed record BodyPlan(
|
||||
string Key,
|
||||
string ParentKey,
|
||||
string Kind,
|
||||
string Name,
|
||||
double Distance,
|
||||
double Phase,
|
||||
double X,
|
||||
double Z,
|
||||
double Mass,
|
||||
double BodyRadius,
|
||||
double GravityRadius,
|
||||
double GravityDepth,
|
||||
double DepthExponent,
|
||||
double WaveRadius,
|
||||
double WaveDepth,
|
||||
double WaveSpeed);
|
||||
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Ymir.Core;
|
||||
|
||||
namespace Aetheria.State.Daemon;
|
||||
|
||||
public sealed record AetheriaYmirZonePersistenceCapture(
|
||||
string RunId,
|
||||
int ZoneIndex,
|
||||
long FrameId,
|
||||
int SimulationStepIndex,
|
||||
YmirSessionPersistenceCapture World,
|
||||
YmirSessionPersistenceCapture? Payload);
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Mesh;
|
||||
using Ymir.Core;
|
||||
|
||||
namespace Aetheria.State.Daemon;
|
||||
|
||||
/// <summary>
|
||||
/// Owns daemon-private Ymir restart material. The public Aetheria frame remains the
|
||||
/// sole durable commit marker; private chunks and resumes are never advertised.
|
||||
/// </summary>
|
||||
public sealed class AetheriaYmirPersistenceCoordinator : IDisposable
|
||||
{
|
||||
private const string WorldChannel = "world";
|
||||
private const string PayloadChannel = "payloads";
|
||||
private readonly CultCache _cache;
|
||||
private readonly AetheriaYmirWorldPhysics _physics;
|
||||
private readonly Dictionary<CursorKey, Cursor> _cursors = new();
|
||||
private bool _activated;
|
||||
|
||||
private AetheriaYmirPersistenceCoordinator(
|
||||
CultCache cache,
|
||||
AetheriaYmirWorldPhysics physics,
|
||||
bool activated)
|
||||
{
|
||||
_cache = cache;
|
||||
_physics = physics;
|
||||
_activated = activated;
|
||||
}
|
||||
|
||||
public static string PrivateStatePath(string publicStatePath) => publicStatePath + ".ymir.cc";
|
||||
|
||||
public static async Task<AetheriaYmirPersistenceCoordinator> OpenAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaYmirWorldPhysics physics,
|
||||
AetheriaRuntimeDaemonFrameDocument? frame)
|
||||
{
|
||||
var trace = string.Equals(
|
||||
Environment.GetEnvironmentVariable("AETHERIA_TRACE_STARTUP_PHASES"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
var phase = Stopwatch.StartNew();
|
||||
void Trace(string name)
|
||||
{
|
||||
if (trace)
|
||||
Console.WriteLine($"Aetheria Ymir restore phase {name} took {phase.Elapsed.TotalMilliseconds:0.###}ms.");
|
||||
phase.Restart();
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
ArgumentNullException.ThrowIfNull(physics);
|
||||
var registry = CultMesh.CreateCultCacheDocumentRegistry(
|
||||
typeof(AetheriaYmirPersistenceMarkerDocument),
|
||||
typeof(AetheriaYmirJournalChunkDocument),
|
||||
typeof(AetheriaYmirResumeDocument));
|
||||
var ownedSchemaIds = registry.AllDescriptors
|
||||
.Select(descriptor => descriptor.SchemaId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var currentResumeKeys = frame?.Run == null || string.IsNullOrWhiteSpace(frame.Run.RunId)
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: (frame.Run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
|
||||
.Where(zone => zone != null)
|
||||
.Select(zone => ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var cache = await CultCacheMessagePack.OpenAsync(
|
||||
PrivateStatePath(node.StatePath),
|
||||
new CultCacheOpenOptions
|
||||
{
|
||||
Registry = registry,
|
||||
PullOnOpen = true,
|
||||
UseDirectoryStore = true,
|
||||
DirectoryStoreHydrationFilter = record =>
|
||||
ownedSchemaIds.Contains(record.SchemaId) &&
|
||||
(string.Equals(record.Key, AetheriaYmirPersistenceMarkerDocument.RecordKey, StringComparison.Ordinal) ||
|
||||
currentResumeKeys.Contains(record.Key)),
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
}).ConfigureAwait(false);
|
||||
Trace("open-private-cache");
|
||||
try
|
||||
{
|
||||
var activated = cache.Get<AetheriaYmirPersistenceMarkerDocument>(
|
||||
new CultRecordKey(AetheriaYmirPersistenceMarkerDocument.RecordKey)) != null;
|
||||
var coordinator = new AetheriaYmirPersistenceCoordinator(cache, physics, activated);
|
||||
if (frame?.Run == null || string.IsNullOrWhiteSpace(frame.Run.RunId))
|
||||
return coordinator;
|
||||
|
||||
var zones = (frame.Run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
|
||||
.Where(zone => zone != null)
|
||||
.OrderBy(zone => zone.ZoneIndex)
|
||||
.ToArray();
|
||||
var resumes = zones
|
||||
.Select(zone => cache.Get<AetheriaYmirResumeDocument>(
|
||||
new CultRecordKey(ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId))))
|
||||
.ToArray();
|
||||
Trace("resolve-resumes");
|
||||
if (resumes.All(value => value == null) && !activated)
|
||||
return coordinator;
|
||||
if (resumes.Any(value => value == null))
|
||||
throw new InvalidOperationException(
|
||||
$"Aetheria frame {frame.FrameId} has incomplete daemon-private Ymir resume state.");
|
||||
|
||||
var journalRanges = zones.Zip(resumes, (zone, resume) =>
|
||||
JournalRanges(frame.Run.RunId, zone.ZoneIndex, resume!))
|
||||
.SelectMany(value => value)
|
||||
.ToArray();
|
||||
await cache.PullBackingStoreRecordsAsync(metadata =>
|
||||
journalRanges.Any(range => range.Contains(metadata.Key))).ConfigureAwait(false);
|
||||
Trace("hydrate-journals");
|
||||
|
||||
foreach (var pair in zones.Zip(resumes, (zone, resume) => (zone, resume: resume!)))
|
||||
coordinator.RestoreZone(frame.Run.RunId, frame.FrameId, pair.zone.ZoneIndex, pair.resume);
|
||||
Trace("restore-zones");
|
||||
return coordinator;
|
||||
}
|
||||
catch
|
||||
{
|
||||
cache.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AetheriaYmirZonePersistenceCapture> Capture(
|
||||
AetheriaRuntimeDaemonFrameDocument frame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
var run = frame.Run ?? throw new InvalidOperationException("Ymir persistence requires an authoritative run.");
|
||||
if (string.IsNullOrWhiteSpace(run.RunId))
|
||||
throw new InvalidOperationException("Ymir persistence requires a stable run id.");
|
||||
var zones = (run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
|
||||
.Where(zone => zone != null)
|
||||
.OrderBy(zone => zone.ZoneIndex)
|
||||
.ToArray();
|
||||
foreach (var zone in zones)
|
||||
_physics.SynchronizePersistenceFrame(
|
||||
run.RunId,
|
||||
frame.FrameId,
|
||||
zone,
|
||||
zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>());
|
||||
return zones
|
||||
.Select(zone =>
|
||||
{
|
||||
var world = CursorFor(run.RunId, zone.ZoneIndex, WorldChannel);
|
||||
var payload = CursorFor(run.RunId, zone.ZoneIndex, PayloadChannel);
|
||||
return _physics.CapturePersistence(
|
||||
run.RunId,
|
||||
zone.ZoneIndex,
|
||||
frame.FrameId,
|
||||
world.Generation,
|
||||
world.EntryCount,
|
||||
payload.Generation,
|
||||
payload.EntryCount);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task PersistPrivateAsync(IReadOnlyList<AetheriaYmirZonePersistenceCapture> captures)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(captures);
|
||||
foreach (var capture in captures)
|
||||
{
|
||||
await PutChunkAsync(capture, WorldChannel, capture.World.JournalChunk).ConfigureAwait(false);
|
||||
if (capture.Payload != null)
|
||||
await PutChunkAsync(capture, PayloadChannel, capture.Payload.JournalChunk).ConfigureAwait(false);
|
||||
}
|
||||
await _cache.FlushAsync(soft: false).ConfigureAwait(false);
|
||||
|
||||
foreach (var capture in captures)
|
||||
{
|
||||
var world = capture.World.ResumeDescriptor;
|
||||
var payload = capture.Payload?.ResumeDescriptor;
|
||||
var resume = new AetheriaYmirResumeDocument
|
||||
{
|
||||
RunId = capture.RunId,
|
||||
ZoneIndex = capture.ZoneIndex,
|
||||
FrameId = capture.FrameId,
|
||||
SimulationStepIndex = capture.SimulationStepIndex,
|
||||
WorldSessionGeneration = world.SessionGeneration,
|
||||
WorldJournalEntryCount = world.JournalEntryCount,
|
||||
WorldDescriptorPayload = YmirSessionCheckpointCodec.Encode(world),
|
||||
PayloadSessionGeneration = payload?.SessionGeneration ?? "",
|
||||
PayloadJournalEntryCount = payload?.JournalEntryCount ?? 0,
|
||||
PayloadDescriptorPayload = payload == null ? [] : YmirSessionCheckpointCodec.Encode(payload)
|
||||
};
|
||||
await PutImmutableAsync(
|
||||
new CultRecordKey(ResumeRecordKey(capture.RunId, capture.ZoneIndex, capture.FrameId)),
|
||||
resume,
|
||||
Equivalent).ConfigureAwait(false);
|
||||
}
|
||||
await _cache.FlushAsync(soft: false).ConfigureAwait(false);
|
||||
|
||||
foreach (var capture in captures)
|
||||
{
|
||||
Remember(capture.RunId, capture.ZoneIndex, WorldChannel, capture.World.ResumeDescriptor);
|
||||
if (capture.Payload != null)
|
||||
Remember(capture.RunId, capture.ZoneIndex, PayloadChannel, capture.Payload.ResumeDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates fail-closed restart only after the matching public frame has been hard-flushed.
|
||||
/// </summary>
|
||||
public async Task ActivateAsync()
|
||||
{
|
||||
if (_activated)
|
||||
return;
|
||||
await _cache.UpsertAsync(
|
||||
new AetheriaYmirPersistenceMarkerDocument
|
||||
{
|
||||
ActivatedAtUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
new CultRecordHandle<AetheriaYmirPersistenceMarkerDocument>(
|
||||
new CultRecordKey(AetheriaYmirPersistenceMarkerDocument.RecordKey))).ConfigureAwait(false);
|
||||
await _cache.FlushAsync(soft: false).ConfigureAwait(false);
|
||||
_activated = true;
|
||||
}
|
||||
|
||||
public static string ResumeRecordKey(string runId, int zoneIndex, long frameId) =>
|
||||
$"private:aetheria.ymir.resume.v1:{RunKey(runId)}:{zoneIndex}:{frameId:D20}";
|
||||
|
||||
public void Dispose() => _cache.Dispose();
|
||||
|
||||
private async Task PutChunkAsync(
|
||||
AetheriaYmirZonePersistenceCapture capture,
|
||||
string channel,
|
||||
YmirSessionJournalChunk? chunk)
|
||||
{
|
||||
if (chunk == null)
|
||||
return;
|
||||
var document = new AetheriaYmirJournalChunkDocument
|
||||
{
|
||||
RunId = capture.RunId,
|
||||
ZoneIndex = capture.ZoneIndex,
|
||||
Channel = channel,
|
||||
SessionGeneration = chunk.SessionGeneration,
|
||||
FirstEntryIndex = chunk.FirstEntryIndex,
|
||||
EntryCount = chunk.Entries.Count,
|
||||
Payload = YmirSessionCheckpointCodec.Encode(chunk)
|
||||
};
|
||||
await PutImmutableAsync(
|
||||
new CultRecordKey(JournalRecordKey(
|
||||
capture.RunId,
|
||||
capture.ZoneIndex,
|
||||
channel,
|
||||
chunk.SessionGeneration,
|
||||
chunk.FirstEntryIndex)),
|
||||
document,
|
||||
Equivalent).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task PutImmutableAsync<T>(
|
||||
CultRecordKey key,
|
||||
T document,
|
||||
Func<T, T, bool> equivalent)
|
||||
where T : class
|
||||
{
|
||||
var existing = _cache.Get<T>(key);
|
||||
if (existing != null)
|
||||
{
|
||||
if (!equivalent(existing, document))
|
||||
throw new InvalidOperationException($"Immutable Ymir persistence record {key.Value} conflicts with durable state.");
|
||||
return;
|
||||
}
|
||||
await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void RestoreZone(string runId, long frameId, int zoneIndex, AetheriaYmirResumeDocument resume)
|
||||
{
|
||||
if (!string.Equals(resume.RunId, runId, StringComparison.Ordinal) ||
|
||||
resume.ZoneIndex != zoneIndex || resume.FrameId != frameId ||
|
||||
resume.WorldDescriptorPayload.Length == 0)
|
||||
throw new InvalidOperationException("Aetheria private Ymir resume identity does not match the durable frame.");
|
||||
var world = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.WorldDescriptorPayload);
|
||||
var payload = resume.PayloadDescriptorPayload.Length == 0
|
||||
? null
|
||||
: YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.PayloadDescriptorPayload);
|
||||
if (!string.Equals(world.SessionGeneration, resume.WorldSessionGeneration, StringComparison.Ordinal) ||
|
||||
world.JournalEntryCount != resume.WorldJournalEntryCount ||
|
||||
!string.Equals(payload?.SessionGeneration ?? "", resume.PayloadSessionGeneration, StringComparison.Ordinal) ||
|
||||
(payload?.JournalEntryCount ?? 0) != resume.PayloadJournalEntryCount)
|
||||
throw new InvalidOperationException("Aetheria private Ymir resume metadata disagrees with its checksummed payload.");
|
||||
|
||||
var worldChunks = Chunks(runId, zoneIndex, WorldChannel, world.SessionGeneration, world.JournalEntryCount);
|
||||
var payloadChunks = payload == null
|
||||
? Array.Empty<YmirSessionJournalChunk>()
|
||||
: Chunks(runId, zoneIndex, PayloadChannel, payload.SessionGeneration, payload.JournalEntryCount);
|
||||
_physics.RestorePersistence(
|
||||
runId, zoneIndex, frameId, resume.SimulationStepIndex,
|
||||
world, worldChunks, payload, payloadChunks);
|
||||
Remember(runId, zoneIndex, WorldChannel, world);
|
||||
if (payload != null)
|
||||
Remember(runId, zoneIndex, PayloadChannel, payload);
|
||||
}
|
||||
|
||||
private YmirSessionJournalChunk[] Chunks(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
string channel,
|
||||
string generation,
|
||||
long entryCount) =>
|
||||
_cache.GetAll<AetheriaYmirJournalChunkDocument>()
|
||||
.Where(value => value != null &&
|
||||
string.Equals(value.RunId, runId, StringComparison.Ordinal) &&
|
||||
value.ZoneIndex == zoneIndex &&
|
||||
string.Equals(value.Channel, channel, StringComparison.Ordinal) &&
|
||||
string.Equals(value.SessionGeneration, generation, StringComparison.Ordinal) &&
|
||||
value.FirstEntryIndex < entryCount)
|
||||
.OrderBy(value => value.FirstEntryIndex)
|
||||
.Select(value =>
|
||||
{
|
||||
var chunk = YmirSessionCheckpointCodec.DecodeJournalChunk(value.Payload);
|
||||
if (chunk.FirstEntryIndex != value.FirstEntryIndex || chunk.Entries.Count != value.EntryCount)
|
||||
throw new InvalidOperationException("Aetheria Ymir journal metadata disagrees with its checksummed payload.");
|
||||
return chunk;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private Cursor CursorFor(string runId, int zoneIndex, string channel) =>
|
||||
_cursors.TryGetValue(new CursorKey(runId, zoneIndex, channel), out var cursor)
|
||||
? cursor
|
||||
: new Cursor("", 0);
|
||||
|
||||
private void Remember(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
string channel,
|
||||
YmirSessionResumeDescriptor descriptor) =>
|
||||
_cursors[new CursorKey(runId, zoneIndex, channel)] =
|
||||
new Cursor(descriptor.SessionGeneration, descriptor.JournalEntryCount);
|
||||
|
||||
private static bool Equivalent(AetheriaYmirJournalChunkDocument left, AetheriaYmirJournalChunkDocument right) =>
|
||||
left.RunId == right.RunId && left.ZoneIndex == right.ZoneIndex && left.Channel == right.Channel &&
|
||||
left.SessionGeneration == right.SessionGeneration && left.FirstEntryIndex == right.FirstEntryIndex &&
|
||||
left.EntryCount == right.EntryCount && left.Payload.AsSpan().SequenceEqual(right.Payload);
|
||||
|
||||
private static bool Equivalent(AetheriaYmirResumeDocument left, AetheriaYmirResumeDocument right) =>
|
||||
left.RunId == right.RunId && left.ZoneIndex == right.ZoneIndex && left.FrameId == right.FrameId &&
|
||||
left.SimulationStepIndex == right.SimulationStepIndex &&
|
||||
left.WorldSessionGeneration == right.WorldSessionGeneration &&
|
||||
left.WorldJournalEntryCount == right.WorldJournalEntryCount &&
|
||||
left.WorldDescriptorPayload.AsSpan().SequenceEqual(right.WorldDescriptorPayload) &&
|
||||
left.PayloadSessionGeneration == right.PayloadSessionGeneration &&
|
||||
left.PayloadJournalEntryCount == right.PayloadJournalEntryCount &&
|
||||
left.PayloadDescriptorPayload.AsSpan().SequenceEqual(right.PayloadDescriptorPayload);
|
||||
|
||||
private static string JournalRecordKey(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
string channel,
|
||||
string generation,
|
||||
long firstEntryIndex) =>
|
||||
$"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:{firstEntryIndex:D20}";
|
||||
|
||||
private static JournalRange[] JournalRanges(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
AetheriaYmirResumeDocument resume)
|
||||
{
|
||||
var world = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.WorldDescriptorPayload);
|
||||
var ranges = new List<JournalRange>
|
||||
{
|
||||
new(JournalRecordPrefix(runId, zoneIndex, WorldChannel, world.SessionGeneration), world.JournalEntryCount)
|
||||
};
|
||||
if (resume.PayloadDescriptorPayload.Length > 0)
|
||||
{
|
||||
var payload = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.PayloadDescriptorPayload);
|
||||
ranges.Add(new JournalRange(
|
||||
JournalRecordPrefix(runId, zoneIndex, PayloadChannel, payload.SessionGeneration),
|
||||
payload.JournalEntryCount));
|
||||
}
|
||||
return ranges.ToArray();
|
||||
}
|
||||
|
||||
private static string JournalRecordPrefix(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
string channel,
|
||||
string generation) =>
|
||||
$"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:";
|
||||
|
||||
private static string RunKey(string runId) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(runId))).ToLowerInvariant();
|
||||
|
||||
private readonly record struct CursorKey(string RunId, int ZoneIndex, string Channel);
|
||||
private readonly record struct Cursor(string Generation, long EntryCount);
|
||||
private readonly record struct JournalRange(string Prefix, long EntryCount)
|
||||
{
|
||||
public bool Contains(string key) =>
|
||||
key.StartsWith(Prefix, StringComparison.Ordinal) &&
|
||||
long.TryParse(key.AsSpan(Prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var firstEntryIndex) &&
|
||||
firstEntryIndex < EntryCount;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Daemon;
|
||||
|
||||
[CultDocument("gamecult.aetheria.ymir_persistence_marker", "gamecult.aetheria.ymir_persistence_marker.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaYmirPersistenceMarkerDocument
|
||||
{
|
||||
public const string RecordKey = "private:aetheria.ymir.persistence.v1";
|
||||
|
||||
[Key(0)] public string FormatId { get; set; } = "gamecult.aetheria.ymir.persistence.v1";
|
||||
[Key(1)] public string ActivatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.aetheria.ymir_journal_chunk", "gamecult.aetheria.ymir_journal_chunk.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaYmirJournalChunkDocument
|
||||
{
|
||||
[Key(0)] public string RunId { get; set; } = "";
|
||||
[Key(1)] public int ZoneIndex { get; set; }
|
||||
[Key(2)] public string Channel { get; set; } = "";
|
||||
[Key(3)] public string SessionGeneration { get; set; } = "";
|
||||
[Key(4)] public long FirstEntryIndex { get; set; }
|
||||
[Key(5)] public int EntryCount { get; set; }
|
||||
[Key(6)] public byte[] Payload { get; set; } = [];
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.aetheria.ymir_resume", "gamecult.aetheria.ymir_resume.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaYmirResumeDocument
|
||||
{
|
||||
[Key(0)] public string RunId { get; set; } = "";
|
||||
[Key(1)] public int ZoneIndex { get; set; }
|
||||
[Key(2)] public long FrameId { get; set; }
|
||||
[Key(3)] public int SimulationStepIndex { get; set; }
|
||||
[Key(4)] public string WorldSessionGeneration { get; set; } = "";
|
||||
[Key(5)] public long WorldJournalEntryCount { get; set; }
|
||||
[Key(6)] public byte[] WorldDescriptorPayload { get; set; } = [];
|
||||
[Key(7)] public string PayloadSessionGeneration { get; set; } = "";
|
||||
[Key(8)] public long PayloadJournalEntryCount { get; set; }
|
||||
[Key(9)] public byte[] PayloadDescriptorPayload { get; set; } = [];
|
||||
}
|
||||
|
|
@ -1,630 +0,0 @@
|
|||
using GameCult.Aetheria.State.Verse;
|
||||
using Ymir.Core;
|
||||
|
||||
namespace Aetheria.State.Daemon;
|
||||
|
||||
public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDisposable
|
||||
{
|
||||
private const long PersistenceRolloverJournalEntries = 64;
|
||||
private const string EntityPrefix = "aetheria.daemon.entity.";
|
||||
private const string PickupPrefix = "aetheria.daemon.pickup.";
|
||||
private const string PhysicalPayloadPrefix = "aetheria.physical-payload.";
|
||||
private const ulong WorldEntityCollisionCategory = 1UL;
|
||||
private const ulong ShipCollisionCategory = 2UL;
|
||||
private const ulong PickupCollisionCategory = 4UL;
|
||||
private readonly Dictionary<WorldKey, SessionState> _sessions = new();
|
||||
|
||||
public string ImplementationId => "ymir.box3d.retained-session.v1";
|
||||
|
||||
public void SynchronizePersistenceFrame(
|
||||
string runId,
|
||||
long frameId,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
throw new ArgumentException("A retained Ymir world requires a run id.", nameof(runId));
|
||||
|
||||
var key = new WorldKey(runId, zone.ZoneIndex);
|
||||
if (_sessions.TryGetValue(key, out var existing) && existing.LastFrameId == frameId)
|
||||
return;
|
||||
if (existing != null && frameId < existing.LastFrameId)
|
||||
throw new InvalidOperationException(
|
||||
$"Ymir session '{existing.WorldSession.Info.SessionId}' cannot synchronize backward from " +
|
||||
$"frame {existing.LastFrameId} to {frameId}.");
|
||||
|
||||
var desiredBodies = BuildBodies(ActiveEntities(entities), ActivePickups(zone));
|
||||
var state = existing ?? CreateSession(key, zone, desiredBodies);
|
||||
if (existing == null)
|
||||
_sessions.Add(key, state);
|
||||
else
|
||||
{
|
||||
var commandOrdinal = 0;
|
||||
Reconcile(
|
||||
state.WorldSession,
|
||||
desiredBodies,
|
||||
frameId,
|
||||
state.LastSimulationStepIndex,
|
||||
ref commandOrdinal);
|
||||
}
|
||||
|
||||
state.LastFrameId = frameId;
|
||||
state.LastResult = null;
|
||||
if (state.PayloadSession != null)
|
||||
{
|
||||
state.LastPayloadFrameId = frameId;
|
||||
state.LastPayloadSimulationStepIndex = state.LastSimulationStepIndex;
|
||||
state.LastPayloadResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public AetheriaYmirZonePersistenceCapture CapturePersistence(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
long frameId,
|
||||
string persistedWorldSessionGeneration,
|
||||
long persistedWorldJournalEntryCount,
|
||||
string persistedPayloadSessionGeneration,
|
||||
long persistedPayloadJournalEntryCount)
|
||||
{
|
||||
if (!_sessions.TryGetValue(new WorldKey(runId, zoneIndex), out var state) ||
|
||||
state.LastFrameId != frameId)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot persist Ymir world for run '{runId}' zone {zoneIndex} at frame {frameId}.");
|
||||
if (state.PayloadSession != null &&
|
||||
(state.LastPayloadFrameId != frameId || state.LastPayloadSimulationStepIndex != state.LastSimulationStepIndex))
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot persist Ymir payload world for run '{runId}' zone {zoneIndex} at frame {frameId}.");
|
||||
|
||||
TryRollPersistenceGeneration(state, payload: false);
|
||||
if (state.PayloadSession != null)
|
||||
TryRollPersistenceGeneration(state, payload: true);
|
||||
|
||||
return new AetheriaYmirZonePersistenceCapture(
|
||||
runId,
|
||||
zoneIndex,
|
||||
frameId,
|
||||
state.LastSimulationStepIndex,
|
||||
state.WorldSession.CapturePersistence(
|
||||
string.Equals(persistedWorldSessionGeneration, state.WorldSession.Info.SessionGeneration, StringComparison.Ordinal)
|
||||
? persistedWorldJournalEntryCount
|
||||
: 0),
|
||||
state.PayloadSession?.CapturePersistence(
|
||||
string.Equals(persistedPayloadSessionGeneration, state.PayloadSession.Info.SessionGeneration, StringComparison.Ordinal)
|
||||
? persistedPayloadJournalEntryCount
|
||||
: 0));
|
||||
}
|
||||
|
||||
public void RestorePersistence(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
YmirSessionResumeDescriptor worldDescriptor,
|
||||
IReadOnlyList<YmirSessionJournalChunk> worldChunks,
|
||||
YmirSessionResumeDescriptor? payloadDescriptor,
|
||||
IReadOnlyList<YmirSessionJournalChunk> payloadChunks)
|
||||
{
|
||||
var key = new WorldKey(runId, zoneIndex);
|
||||
if (_sessions.ContainsKey(key))
|
||||
throw new InvalidOperationException($"Ymir world for run '{runId}' zone {zoneIndex} is already live.");
|
||||
var sessionId = $"aetheria.run.{runId}.zone.{zoneIndex}";
|
||||
if (!string.Equals(worldDescriptor.SessionId, sessionId + ".world", StringComparison.Ordinal) ||
|
||||
(payloadDescriptor != null &&
|
||||
!string.Equals(payloadDescriptor.SessionId, sessionId + ".payloads", StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException("Aetheria Ymir resume belongs to another run or zone.");
|
||||
if (payloadDescriptor == null && payloadChunks.Count > 0)
|
||||
throw new InvalidOperationException("Aetheria Ymir resume has payload journal chunks without a descriptor.");
|
||||
|
||||
var world = YmirSession.Restore(worldDescriptor, worldChunks);
|
||||
YmirSession? payload = null;
|
||||
try
|
||||
{
|
||||
payload = payloadDescriptor == null ? null : YmirSession.Restore(payloadDescriptor, payloadChunks);
|
||||
_sessions.Add(key, new SessionState(world)
|
||||
{
|
||||
PayloadSession = payload,
|
||||
LastFrameId = frameId,
|
||||
LastSimulationStepIndex = simulationStepIndex,
|
||||
LastPayloadFrameId = payload == null ? -1 : frameId,
|
||||
LastPayloadSimulationStepIndex = payload == null ? -1 : simulationStepIndex
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
payload?.Dispose();
|
||||
world.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void RetainWorlds(string runId, IReadOnlyList<int> zoneIndices)
|
||||
{
|
||||
var retainedZones = (zoneIndices ?? Array.Empty<int>()).ToHashSet();
|
||||
foreach (var key in _sessions.Keys
|
||||
.Where(key => !string.Equals(key.RunId, runId, StringComparison.Ordinal) ||
|
||||
!retainedZones.Contains(key.ZoneIndex))
|
||||
.ToArray())
|
||||
{
|
||||
_sessions[key].WorldSession.Dispose();
|
||||
_sessions[key].PayloadSession?.Dispose();
|
||||
_sessions.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public AetheriaRuntimeWorldStep Step(
|
||||
string runId,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
throw new ArgumentException("A retained Ymir world requires a run id.", nameof(runId));
|
||||
if (deltaSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
|
||||
|
||||
var key = new WorldKey(runId, zone.ZoneIndex);
|
||||
if (_sessions.TryGetValue(key, out var existing) &&
|
||||
existing.LastFrameId == frameId && existing.LastSimulationStepIndex == simulationStepIndex &&
|
||||
existing.LastResult != null)
|
||||
return existing.LastResult;
|
||||
if (existing != null &&
|
||||
(frameId < existing.LastFrameId ||
|
||||
(frameId == existing.LastFrameId && simulationStepIndex < existing.LastSimulationStepIndex)))
|
||||
throw new InvalidOperationException(
|
||||
$"Ymir session '{existing.WorldSession.Info.SessionId}' cannot step backward from " +
|
||||
$"frame/step {existing.LastFrameId}/{existing.LastSimulationStepIndex} to {frameId}/{simulationStepIndex}.");
|
||||
|
||||
var active = ActiveEntities(entities);
|
||||
var pickups = ActivePickups(zone);
|
||||
var desiredBodies = BuildBodies(active, pickups);
|
||||
var state = existing ?? CreateSession(key, zone, desiredBodies);
|
||||
if (existing == null)
|
||||
_sessions.Add(key, state);
|
||||
|
||||
var commandOrdinal = 0;
|
||||
Reconcile(state.WorldSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal);
|
||||
ApplyTractorForces(state.WorldSession, active, pickups, frameId, simulationStepIndex, ref commandOrdinal);
|
||||
|
||||
var step = state.WorldSession.Step(new YmirStepSessionCommand(
|
||||
Header(state.WorldSession, frameId, simulationStepIndex, "step", "world", ref commandOrdinal),
|
||||
(float)deltaSeconds,
|
||||
GravityFields(zone)));
|
||||
var result = Lower(step, active);
|
||||
state.LastFrameId = frameId;
|
||||
state.LastSimulationStepIndex = simulationStepIndex;
|
||||
state.LastResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
public AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads(
|
||||
string runId,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (!_sessions.TryGetValue(new WorldKey(runId, zone.ZoneIndex), out var state) ||
|
||||
state.LastFrameId != frameId || state.LastSimulationStepIndex != simulationStepIndex)
|
||||
throw new InvalidOperationException(
|
||||
$"Payload phase requires the retained world phase for run '{runId}' zone {zone.ZoneIndex} " +
|
||||
$"at frame/step {frameId}/{simulationStepIndex}.");
|
||||
if (state.LastPayloadFrameId == frameId && state.LastPayloadSimulationStepIndex == simulationStepIndex &&
|
||||
state.LastPayloadResult != null)
|
||||
return state.LastPayloadResult;
|
||||
if (deltaSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
|
||||
|
||||
var payloadSession = state.PayloadSession ??= YmirSession.Create(new YmirSessionCreateRequest(
|
||||
$"aetheria.run.{runId}.zone.{zone.ZoneIndex}.payloads",
|
||||
[],
|
||||
(float)zone.SimulationTimeSeconds));
|
||||
var payloads = (zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
|
||||
.Where(payload => payload != null && payload.Active)
|
||||
.OrderBy(payload => payload.PayloadId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var desiredBodies = payloads.Select(PayloadBody).ToArray();
|
||||
var commandOrdinal = 0;
|
||||
Reconcile(payloadSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal);
|
||||
var starts = payloadSession.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
|
||||
var stepped = payloadSession.Step(new YmirStepSessionCommand(
|
||||
Header(payloadSession, frameId, simulationStepIndex, "step", "payloads", ref commandOrdinal),
|
||||
(float)deltaSeconds,
|
||||
[]));
|
||||
var moved = stepped.World.Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
|
||||
var activeEntities = ActiveEntities(entities);
|
||||
var entitiesByBodyId = activeEntities.ToDictionary(BodyId, StringComparer.Ordinal);
|
||||
var survivors = new List<AetheriaRuntimePhysicalPayloadCommit>();
|
||||
var hits = new List<AetheriaRuntimePhysicalPayloadHit>();
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
var payloadBodyId = PhysicalPayloadPrefix + payload.PayloadId;
|
||||
var start = starts[payloadBodyId];
|
||||
var body = moved[payloadBodyId];
|
||||
payload.PositionX = body.Position.X;
|
||||
payload.PositionZ = body.Position.Y;
|
||||
payload.VelocityX = body.Velocity.X;
|
||||
payload.VelocityY = body.Velocity.Y;
|
||||
|
||||
var candidates = activeEntities
|
||||
.Where(entity => entity.EntityIndex != payload.SourceEntityIndex)
|
||||
.Select(BodyId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var dx = body.Position.X - start.Position.X;
|
||||
var dz = body.Position.Y - start.Position.Y;
|
||||
var distance = MathF.Sqrt(dx * dx + dz * dz);
|
||||
var queryId = $"aetheria:frame:{frameId}:step:{simulationStepIndex}:payload:{payload.PayloadId}";
|
||||
CircleCastHit? castHit = null;
|
||||
CircleOverlapHit? overlapHit = null;
|
||||
if (distance > 1.0e-6f)
|
||||
{
|
||||
var cast = state.WorldSession.CastCircle(new YmirSessionCircleCastQuery(
|
||||
queryId + ":cast",
|
||||
state.WorldSession.Info.Revision,
|
||||
start.Position,
|
||||
new Vec2(dx, dz),
|
||||
distance,
|
||||
(float)payload.Radius,
|
||||
candidates));
|
||||
RequireQuery(cast.Error, cast.QueryId);
|
||||
castHit = cast.Hits.FirstOrDefault();
|
||||
queryId = cast.QueryId;
|
||||
}
|
||||
else
|
||||
{
|
||||
var overlap = state.WorldSession.OverlapCircle(new YmirSessionCircleOverlapQuery(
|
||||
queryId + ":overlap",
|
||||
state.WorldSession.Info.Revision,
|
||||
body.Position,
|
||||
(float)payload.Radius,
|
||||
candidates));
|
||||
RequireQuery(overlap.Error, overlap.QueryId);
|
||||
overlapHit = overlap.Hits.FirstOrDefault();
|
||||
queryId = overlap.QueryId;
|
||||
}
|
||||
|
||||
var targetBodyId = castHit?.BodyId ?? overlapHit?.BodyId;
|
||||
if (targetBodyId == null || !entitiesByBodyId.TryGetValue(targetBodyId, out var target))
|
||||
{
|
||||
survivors.Add(payload);
|
||||
continue;
|
||||
}
|
||||
|
||||
var point = castHit?.Point ?? overlapHit!.Point;
|
||||
var normal = castHit?.Normal ?? overlapHit!.Normal;
|
||||
hits.Add(new AetheriaRuntimePhysicalPayloadHit
|
||||
{
|
||||
Payload = payload,
|
||||
QueryId = queryId,
|
||||
PhysicalPayloadBodyId = payloadBodyId,
|
||||
TargetEntityIndex = target.EntityIndex,
|
||||
TargetBodyId = targetBodyId,
|
||||
PointX = point.X,
|
||||
PointZ = point.Y,
|
||||
NormalX = normal.X,
|
||||
NormalZ = normal.Y
|
||||
});
|
||||
|
||||
if (string.Equals(payload.PayloadKind, "mine", StringComparison.Ordinal))
|
||||
{
|
||||
payload.Stationary = true;
|
||||
payload.VelocityX = 0;
|
||||
payload.VelocityY = 0;
|
||||
if (!body.IsStatic)
|
||||
RequireAccepted(payloadSession.SetVelocity(new YmirSetBodyVelocityCommand(
|
||||
Header(payloadSession, frameId, simulationStepIndex, "stop", payload.PayloadId, ref commandOrdinal),
|
||||
payloadBodyId,
|
||||
Vec2.Zero,
|
||||
0)));
|
||||
survivors.Add(payload);
|
||||
}
|
||||
else
|
||||
{
|
||||
RequireAccepted(payloadSession.Remove(new YmirRemoveBodyCommand(
|
||||
Header(payloadSession, frameId, simulationStepIndex, "remove-hit", payload.PayloadId, ref commandOrdinal),
|
||||
payloadBodyId)));
|
||||
}
|
||||
}
|
||||
|
||||
var result = new AetheriaRuntimePhysicalPayloadStep(survivors, hits);
|
||||
state.LastPayloadFrameId = frameId;
|
||||
state.LastPayloadSimulationStepIndex = simulationStepIndex;
|
||||
state.LastPayloadResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var state in _sessions.Values)
|
||||
{
|
||||
state.WorldSession.Dispose();
|
||||
state.PayloadSession?.Dispose();
|
||||
}
|
||||
_sessions.Clear();
|
||||
}
|
||||
|
||||
private static SessionState CreateSession(
|
||||
WorldKey key,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
IReadOnlyList<PhysicsBody> bodies)
|
||||
{
|
||||
var sessionId = $"aetheria.run.{key.RunId}.zone.{key.ZoneIndex}";
|
||||
return new SessionState(YmirSession.Create(new YmirSessionCreateRequest(
|
||||
sessionId + ".world",
|
||||
bodies,
|
||||
(float)zone.SimulationTimeSeconds)));
|
||||
}
|
||||
|
||||
private static void Reconcile(
|
||||
YmirSession session,
|
||||
IReadOnlyList<PhysicsBody> desiredBodies,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
ref int commandOrdinal)
|
||||
{
|
||||
var desired = desiredBodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
|
||||
var current = session.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
|
||||
|
||||
foreach (var body in current.Values.Where(body => !desired.ContainsKey(body.Id)).OrderBy(body => body.Id, StringComparer.Ordinal))
|
||||
RequireAccepted(session.Remove(new YmirRemoveBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "remove", body.Id, ref commandOrdinal), body.Id)));
|
||||
|
||||
foreach (var body in desired.Values.OrderBy(body => body.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!current.TryGetValue(body.Id, out var present))
|
||||
{
|
||||
RequireAccepted(session.Spawn(new YmirSpawnBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "spawn", body.Id, ref commandOrdinal), body)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (present.IsBullet != body.IsBullet ||
|
||||
present.ParticipatesInFields != body.ParticipatesInFields ||
|
||||
present.CollisionCategoryBits != body.CollisionCategoryBits ||
|
||||
present.CollisionMaskBits != body.CollisionMaskBits ||
|
||||
present.CollisionGroupIndex != body.CollisionGroupIndex)
|
||||
{
|
||||
RequireAccepted(session.Remove(new YmirRemoveBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "replace-profile", body.Id, ref commandOrdinal), body.Id)));
|
||||
RequireAccepted(session.Spawn(new YmirSpawnBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "respawn-profile", body.Id, ref commandOrdinal), body)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (present.Radius != body.Radius || present.Mass != body.Mass ||
|
||||
present.IsStatic != body.IsStatic || present.IsKinematic != body.IsKinematic ||
|
||||
present.Restitution != body.Restitution)
|
||||
RequireAccepted(session.Configure(new YmirConfigureBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "configure", body.Id, ref commandOrdinal),
|
||||
body.Id, body.Radius, body.Mass, body.IsStatic, body.Restitution, body.IsKinematic)));
|
||||
|
||||
if (present.Position != body.Position || present.Direction != body.Direction)
|
||||
RequireAccepted(session.Teleport(new YmirTeleportBodyCommand(
|
||||
Header(session, frameId, simulationStepIndex, "pose", body.Id, ref commandOrdinal),
|
||||
body.Id, body.Position, body.Direction ?? new Vec2(0, 1))));
|
||||
|
||||
if (!body.IsStatic && (present.Velocity != body.Velocity || present.AngularVelocity != body.AngularVelocity))
|
||||
RequireAccepted(session.SetVelocity(new YmirSetBodyVelocityCommand(
|
||||
Header(session, frameId, simulationStepIndex, "velocity", body.Id, ref commandOrdinal),
|
||||
body.Id, body.Velocity, body.AngularVelocity)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyTractorForces(
|
||||
YmirSession session,
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> active,
|
||||
IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> pickups,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
ref int commandOrdinal)
|
||||
{
|
||||
foreach (var actor in active.Where(entity =>
|
||||
entity.TractorPower > AetheriaRuntimeTractorMechanics.ActivationThreshold))
|
||||
{
|
||||
var forward = Normalize(actor.LookDirectionX, actor.LookDirectionY, 0, 1);
|
||||
foreach (var pickup in pickups)
|
||||
{
|
||||
var dx = pickup.PositionX - actor.PositionX;
|
||||
var dz = pickup.PositionZ - actor.PositionZ;
|
||||
var along = dx * forward.X + dz * forward.Y;
|
||||
var lateral = Math.Abs(dx * forward.Y - dz * forward.X);
|
||||
var distance = Math.Sqrt(dx * dx + dz * dz);
|
||||
if (along < 0 || along > AetheriaRuntimeTractorMechanics.Distance ||
|
||||
lateral > AetheriaRuntimeTractorMechanics.Radius || distance <= 0.001)
|
||||
continue;
|
||||
|
||||
var force = AetheriaRuntimeTractorMechanics.Traction;
|
||||
var pickupId = PickupPrefix + pickup.PickupIndex;
|
||||
RequireAccepted(session.ApplyForce(new YmirApplyForceCommand(
|
||||
Header(session, frameId, simulationStepIndex, "tractor", $"{actor.EntityIndex}:{pickup.PickupIndex}", ref commandOrdinal),
|
||||
pickupId,
|
||||
new Vec2((float)(-dx / distance * force), (float)(-dz / distance * force)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeWorldStep Lower(
|
||||
YmirSessionStepResult step,
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> active)
|
||||
{
|
||||
var bodies = step.World.Bodies;
|
||||
var entitiesByBodyId = active.ToDictionary(BodyId, StringComparer.Ordinal);
|
||||
return new AetheriaRuntimeWorldStep(
|
||||
bodies.Where(body => body.Id.StartsWith(EntityPrefix, StringComparison.Ordinal))
|
||||
.Select(body => new AetheriaRuntimeWorldBodyStep
|
||||
{
|
||||
EntityIndex = entitiesByBodyId[body.Id].EntityIndex,
|
||||
PositionX = body.Position.X,
|
||||
PositionZ = body.Position.Y,
|
||||
VelocityX = body.Velocity.X,
|
||||
VelocityY = body.Velocity.Y,
|
||||
DirectionX = body.Direction?.X ?? 0,
|
||||
DirectionY = body.Direction?.Y ?? 1
|
||||
}).ToArray(),
|
||||
bodies.Where(body => body.Id.StartsWith(PickupPrefix, StringComparison.Ordinal))
|
||||
.Select(body => new AetheriaRuntimeWorldPickupStep
|
||||
{
|
||||
PickupIndex = ParseIndex(body.Id, PickupPrefix),
|
||||
PositionX = body.Position.X,
|
||||
PositionZ = body.Position.Y,
|
||||
VelocityX = body.Velocity.X,
|
||||
VelocityZ = body.Velocity.Y
|
||||
})
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> ActiveEntities(
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities)
|
||||
{
|
||||
var source = entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>();
|
||||
var attached = source.SelectMany(entity => entity.ChildEntityIndices ?? Array.Empty<int>()).ToHashSet();
|
||||
return source.Where(entity => entity.IsActive && entity.WormholeTransition == null &&
|
||||
!attached.Contains(entity.EntityIndex)).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> ActivePickups(
|
||||
AetheriaRuntimeZoneSnapshotCommit zone) =>
|
||||
(zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
|
||||
.Where(pickup => pickup != null && pickup.AgeSeconds < pickup.LifetimeSeconds)
|
||||
.ToArray();
|
||||
|
||||
private static IReadOnlyList<PhysicsBody> BuildBodies(
|
||||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> active,
|
||||
IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> pickups) =>
|
||||
active.Select(entity =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entity.EntityId))
|
||||
throw new InvalidOperationException(
|
||||
$"Entity index {entity.EntityIndex} has no stable runtime identity for retained Ymir ownership.");
|
||||
var isStatic = string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase);
|
||||
var isShip = string.Equals(entity.Kind, "ship", StringComparison.OrdinalIgnoreCase);
|
||||
var direction = Normalize(entity.DirectionX, entity.DirectionY, 0, 1);
|
||||
return new PhysicsBody(
|
||||
BodyId(entity),
|
||||
new Vec2((float)entity.PositionX, (float)entity.PositionZ),
|
||||
isStatic ? new Vec2(0, 0) : new Vec2((float)entity.VelocityX, (float)entity.VelocityY),
|
||||
isStatic ? 48 : 20,
|
||||
1,
|
||||
IsStatic: isStatic,
|
||||
Restitution: 0.2f,
|
||||
Direction: new Vec2((float)direction.X, (float)direction.Y),
|
||||
CollisionCategoryBits: isShip ? ShipCollisionCategory : WorldEntityCollisionCategory,
|
||||
CollisionMaskBits: isShip ? ulong.MaxValue : ulong.MaxValue & ~PickupCollisionCategory,
|
||||
// The fossil moves entities directly and has no rigidbody collision response
|
||||
// between ships/stations. Keep their Ymir bodies for fields and queries, but
|
||||
// do not let invented circle radii hold a ship outside the 25-unit dock rule.
|
||||
// Pickups retain group zero so Ymir can resolve their physical motion against ships.
|
||||
// The daemon independently owns collection through post-step XZ proximity.
|
||||
CollisionGroupIndex: -1);
|
||||
}).Concat(pickups.Select(pickup => new PhysicsBody(
|
||||
PickupPrefix + pickup.PickupIndex,
|
||||
new Vec2((float)pickup.PositionX, (float)pickup.PositionZ),
|
||||
new Vec2((float)pickup.VelocityX, (float)pickup.VelocityZ),
|
||||
5,
|
||||
1,
|
||||
Restitution: 0.2f,
|
||||
CollisionCategoryBits: PickupCollisionCategory,
|
||||
CollisionMaskBits: ShipCollisionCategory)))
|
||||
.ToArray();
|
||||
|
||||
private static string BodyId(AetheriaRuntimeEntitySnapshotCommit entity) => EntityPrefix + entity.EntityId;
|
||||
|
||||
private static PhysicsBody PayloadBody(AetheriaRuntimePhysicalPayloadCommit payload) => new(
|
||||
PhysicalPayloadPrefix + payload.PayloadId,
|
||||
new Vec2((float)payload.PositionX, (float)payload.PositionZ),
|
||||
payload.Stationary ? Vec2.Zero : new Vec2((float)payload.VelocityX, (float)payload.VelocityY),
|
||||
(float)payload.Radius,
|
||||
1,
|
||||
IsStatic: payload.Stationary,
|
||||
Restitution: 0,
|
||||
Direction: new Vec2(0, 1),
|
||||
IsBullet: !payload.Stationary,
|
||||
ParticipatesInFields: false,
|
||||
CollisionCategoryBits: 2,
|
||||
CollisionMaskBits: 0);
|
||||
|
||||
private static IReadOnlyList<RadialField> GravityFields(AetheriaRuntimeZoneSnapshotCommit zone) =>
|
||||
AetheriaRuntimeDaemonRenderQueries.QueryBodyPoses(zone)
|
||||
.Select(pose => (Pose: pose, Body: (zone.Bodies ?? Array.Empty<AetheriaRuntimeBodySnapshotCommit>())
|
||||
.FirstOrDefault(body => body != null && body.BodyKey == pose.BodyKey)))
|
||||
.Where(pair => pair.Body != null && pair.Body.GravityInfluenceRadius > 0 && pair.Body.GravityWellDepth != 0)
|
||||
.Select(pair => new RadialField(
|
||||
"aetheria.daemon.gravity." + pair.Pose.BodyKey,
|
||||
new Vec2((float)pair.Pose.CenterX, (float)pair.Pose.CenterZ),
|
||||
(float)pair.Body!.GravityWellDepth,
|
||||
(float)pair.Body.GravityInfluenceRadius))
|
||||
.ToArray();
|
||||
|
||||
private static YmirCommandHeader Header(
|
||||
YmirSession session,
|
||||
long frameId,
|
||||
int simulationStepIndex,
|
||||
string kind,
|
||||
string subject,
|
||||
ref int ordinal) =>
|
||||
new($"aetheria:frame:{frameId}:step:{simulationStepIndex}:{ordinal++}:{kind}:{subject}", session.Info.Revision);
|
||||
|
||||
private static void RequireAccepted(YmirCommandReceipt receipt)
|
||||
{
|
||||
if (receipt.Outcome != YmirCommandOutcome.Accepted)
|
||||
throw new InvalidOperationException(
|
||||
$"Ymir rejected Aetheria command '{receipt.CommandId}' with {receipt.Error}.");
|
||||
}
|
||||
|
||||
private static void RequireQuery(YmirQueryError error, string queryId)
|
||||
{
|
||||
if (error != YmirQueryError.None)
|
||||
throw new InvalidOperationException($"Ymir rejected Aetheria query '{queryId}' with {error}.");
|
||||
}
|
||||
|
||||
private static int ParseIndex(string id, string prefix) =>
|
||||
id.StartsWith(prefix, StringComparison.Ordinal) &&
|
||||
int.TryParse(id.AsSpan(prefix.Length), out var value)
|
||||
? value
|
||||
: -1;
|
||||
|
||||
private static void TryRollPersistenceGeneration(SessionState state, bool payload)
|
||||
{
|
||||
var current = payload ? state.PayloadSession : state.WorldSession;
|
||||
if (current == null || current.PersistenceJournalEntryCount < PersistenceRolloverJournalEntries ||
|
||||
!current.TryReplaceWithCompactedPersistenceBaseline(out var replacement))
|
||||
return;
|
||||
|
||||
if (payload)
|
||||
state.PayloadSession = replacement!;
|
||||
else
|
||||
state.WorldSession = replacement!;
|
||||
}
|
||||
|
||||
private static (double X, double Y) Normalize(
|
||||
double x,
|
||||
double y,
|
||||
double fallbackX,
|
||||
double fallbackY)
|
||||
{
|
||||
var length = Math.Sqrt(x * x + y * y);
|
||||
return length <= 0.001 ? (fallbackX, fallbackY) : (x / length, y / length);
|
||||
}
|
||||
|
||||
private sealed class SessionState(YmirSession worldSession)
|
||||
{
|
||||
public YmirSession WorldSession { get; set; } = worldSession;
|
||||
public YmirSession? PayloadSession { get; set; }
|
||||
public long LastFrameId { get; set; } = -1;
|
||||
public int LastSimulationStepIndex { get; set; } = -1;
|
||||
public AetheriaRuntimeWorldStep? LastResult { get; set; }
|
||||
public long LastPayloadFrameId { get; set; } = -1;
|
||||
public int LastPayloadSimulationStepIndex { get; set; } = -1;
|
||||
public AetheriaRuntimePhysicalPayloadStep? LastPayloadResult { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct WorldKey(string RunId, int ZoneIndex);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Aetheria.State.Daemon.Smoke")]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
<ProjectReference Include="..\..\CultLib\src\GameCult.Geometry\GameCult.Geometry.csproj" />
|
||||
<Compile Include="..\Assets\Scripts\ServerShared\YmirPhysicsContracts.cs" Link="YmirPhysicsContracts.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
<ProjectReference Include="..\..\EvePlugins\plugins\eve-plugin-fields\GameCult.Eve.PluginFields.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Description>Replica sync worker for pulling daemon-owned Aetheria Verse state into a local cache-only .cc witness.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
using Aetheria.State;
|
||||
|
||||
return await ProgramMainAsync(args).ConfigureAwait(false);
|
||||
|
||||
static async Task<int> ProgramMainAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var mode = args[0].Trim();
|
||||
var options = ParseOptions(args.Skip(1));
|
||||
var endpoint = RequireOption(options, "endpoint");
|
||||
var runtimeId = ReadOption(options, "runtime-id", "aetheria-verse-replica");
|
||||
var replicaPath = ResolveReplicaPath(options);
|
||||
SeedBaselineReplica(options, replicaPath);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case "sync":
|
||||
{
|
||||
var appliedSequence = await AetheriaVerseReplica
|
||||
.SyncSnapshotAsync(replicaPath, endpoint, runtimeId)
|
||||
.ConfigureAwait(false);
|
||||
Console.WriteLine($"Synced Aetheria replica to {replicaPath} from {endpoint} at shard sequence {appliedSequence}.");
|
||||
return 0;
|
||||
}
|
||||
case "follow":
|
||||
{
|
||||
var pollSeconds = ReadDoubleOption(options, "poll-seconds", 1d);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cancellation.Cancel();
|
||||
};
|
||||
|
||||
Console.WriteLine(
|
||||
$"Following Aetheria replica at {replicaPath} from {endpoint} every {pollSeconds:0.###} seconds. Press Ctrl+C to stop.");
|
||||
await AetheriaVerseReplica
|
||||
.RunReplicaAsync(replicaPath, endpoint, TimeSpan.FromSeconds(pollSeconds), cancellation.Token, runtimeId)
|
||||
.ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown mode '{mode}'.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static string ResolveReplicaPath(IReadOnlyDictionary<string, string> options)
|
||||
{
|
||||
var explicitPath = ReadOption(options, "replica", "");
|
||||
if (!string.IsNullOrWhiteSpace(explicitPath))
|
||||
return Path.GetFullPath(explicitPath);
|
||||
|
||||
var gameDataRoot = RequireOption(options, "game-data-root");
|
||||
var verseId = RequireOption(options, "verse-id");
|
||||
return Path.Combine(
|
||||
Path.GetFullPath(gameDataRoot),
|
||||
"Verses",
|
||||
$"{SanitizeVerseId(verseId)}.cc");
|
||||
}
|
||||
|
||||
static void SeedBaselineReplica(IReadOnlyDictionary<string, string> 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<string, string> ParseOptions(IEnumerable<string> args)
|
||||
{
|
||||
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? pendingKey = null;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
pendingKey = arg[2..];
|
||||
options[pendingKey] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pendingKey))
|
||||
{
|
||||
options[pendingKey] = arg;
|
||||
pendingKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
static string RequireOption(IReadOnlyDictionary<string, string> options, string key)
|
||||
{
|
||||
var value = ReadOption(options, key, "");
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
throw new InvalidOperationException($"Missing required option --{key}.");
|
||||
}
|
||||
|
||||
static string ReadOption(IReadOnlyDictionary<string, string> options, string key, string fallback)
|
||||
{
|
||||
return options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
static double ReadDoubleOption(IReadOnlyDictionary<string, string> options, string key, double fallback)
|
||||
{
|
||||
var value = ReadOption(options, key, "");
|
||||
return double.TryParse(value, out var parsed) && parsed > 0
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("Usage:");
|
||||
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]");
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State.Unity\Aetheria.State.Unity.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Aetheria.State.Unity.csproj" Aliases="PackageUnity" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
extern alias PackageUnity;
|
||||
|
||||
using Aetheria.State;
|
||||
using Aetheria.State.Unity;
|
||||
using PackageUnity::GameCult.Aetheria.State.Verse;
|
||||
|
||||
var root = args.Length > 0 ? Path.GetFullPath(args[0]) : Directory.GetCurrentDirectory();
|
||||
var statePath = args.Length > 1
|
||||
? Path.GetFullPath(args[1])
|
||||
: AetheriaStatePaths.ResolveDefaultStatePath(root);
|
||||
|
||||
await using var client = await AetheriaRuntimeCatalogClient.OpenAsync(statePath);
|
||||
var catalog = client.ReadCatalog();
|
||||
var packageCatalog = AetheriaRuntimeCatalogStore.OpenReadOnly(statePath);
|
||||
var surface = client.ReadCatalogSurface();
|
||||
var commitSmokeDirectory = Path.Combine(Path.GetTempPath(), "aetheria-state-unity-smoke", Guid.NewGuid().ToString("N"));
|
||||
var commitSmokeStatePath = Path.Combine(commitSmokeDirectory, "aetheria-world.cc");
|
||||
Directory.CreateDirectory(commitSmokeDirectory);
|
||||
|
||||
if (catalog.Items.Count != 115)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected 115 runtime catalog items, found {catalog.Items.Count}.");
|
||||
}
|
||||
|
||||
if (packageCatalog.Items.Count != catalog.Items.Count ||
|
||||
packageCatalog.Corporations.Count != catalog.Corporations.Count ||
|
||||
packageCatalog.NameFiles.Count != catalog.NameFiles.Count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Package catalog store mismatch: {packageCatalog.Items.Count}/{packageCatalog.Corporations.Count}/{packageCatalog.NameFiles.Count} " +
|
||||
$"!= {catalog.Items.Count}/{catalog.Corporations.Count}/{catalog.NameFiles.Count}.");
|
||||
}
|
||||
|
||||
if (catalog.TradeItems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Runtime catalog has no trade items.");
|
||||
}
|
||||
|
||||
if (catalog.EquipmentItems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Runtime catalog has no equipment items.");
|
||||
}
|
||||
|
||||
var shaped = catalog.Items.FirstOrDefault(item => item.ShapeCells.Count > 0)
|
||||
?? throw new InvalidOperationException("Runtime catalog has no typed shape masks.");
|
||||
if (string.IsNullOrWhiteSpace(shaped.ItemKey))
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime shape sample has no typed item key: {shaped.Name}.");
|
||||
}
|
||||
|
||||
var packageShaped = packageCatalog.FindItem(shaped.ItemKey);
|
||||
if (packageShaped == null ||
|
||||
packageShaped.Name != shaped.Name ||
|
||||
packageShaped.ShapeCells.Count != shaped.ShapeCells.Count)
|
||||
{
|
||||
throw new InvalidOperationException($"Package catalog store did not read the expected typed item payload for {shaped.ItemKey}.");
|
||||
}
|
||||
|
||||
if (shaped.ShapeCells.Count != shaped.OccupiedCells)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime shape mask count mismatch for {shaped.Name}: cells={shaped.ShapeCells.Count}, occupied={shaped.OccupiedCells}.");
|
||||
}
|
||||
|
||||
if (shaped.ShapeCells.Any(cell => cell.X < 0 || cell.Y < 0 || cell.X >= shaped.ShapeWidth || cell.Y >= shaped.ShapeHeight))
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime shape mask has out-of-bounds cells for {shaped.Name}.");
|
||||
}
|
||||
|
||||
var interior = catalog.Items.FirstOrDefault(item => item.InteriorShapeCells.Count > 0)
|
||||
?? throw new InvalidOperationException("Runtime catalog has no typed interior shape masks.");
|
||||
if (interior.InteriorShapeCells.Count != interior.InteriorOccupiedCells)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime interior shape mask count mismatch for {interior.Name}: cells={interior.InteriorShapeCells.Count}, occupied={interior.InteriorOccupiedCells}.");
|
||||
}
|
||||
|
||||
var hardpointHost = catalog.Items.FirstOrDefault(item => item.Hardpoints.Count > 0)
|
||||
?? throw new InvalidOperationException("Runtime catalog has no typed hardpoints.");
|
||||
var hardpoint = hardpointHost.Hardpoints.First();
|
||||
if (hardpoint.ShapeCells.Count != hardpoint.OccupiedCells)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime hardpoint shape count mismatch for {hardpointHost.Name}: cells={hardpoint.ShapeCells.Count}, occupied={hardpoint.OccupiedCells}.");
|
||||
}
|
||||
|
||||
var behaviorKind = catalog.Items.SelectMany(item => item.BehaviorKinds).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Runtime catalog has no behavior kind fingerprints.");
|
||||
if (!catalog.FindItemsByBehavior(behaviorKind).Any())
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime catalog behavior lookup failed for {behaviorKind}.");
|
||||
}
|
||||
|
||||
var behaviorHost = catalog.Items.FirstOrDefault(item => item.BehaviorPayloads.Count > 0)
|
||||
?? throw new InvalidOperationException("Runtime catalog has no typed behavior payloads.");
|
||||
if (!behaviorHost.BehaviorPayloads.All(payload => behaviorHost.BehaviorKinds.Contains(payload.Kind)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime behavior payload kind index mismatch for {behaviorHost.Name}.");
|
||||
}
|
||||
|
||||
var behaviorPayload = behaviorHost.BehaviorPayloads.First();
|
||||
if (behaviorPayload.Fields.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime behavior payload has no fields for {behaviorHost.Name}.");
|
||||
}
|
||||
|
||||
var behaviorItemRefsMissingItemKeys = packageCatalog.Items
|
||||
.SelectMany(item => item.BehaviorPayloads)
|
||||
.Sum(CountRequiredBehaviorItemRefsMissingItemKeys);
|
||||
if (behaviorItemRefsMissingItemKeys > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime behavior payload item refs missing item-key projections: {behaviorItemRefsMissingItemKeys}.");
|
||||
}
|
||||
|
||||
var equipment = catalog.EquipmentItems.First();
|
||||
if (!catalog.FindItemsByHardpoint(equipment.HardpointType).Any())
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime catalog hardpoint lookup failed for {equipment.HardpointType}.");
|
||||
}
|
||||
|
||||
var manufactured = catalog.Items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.ManufacturerKey))
|
||||
?? throw new InvalidOperationException("Runtime catalog has no manufactured item.");
|
||||
if (string.IsNullOrWhiteSpace(manufactured.ManufacturerKey) ||
|
||||
catalog.FindCorporation(manufactured.ManufacturerKey) == null ||
|
||||
catalog.GetManufacturer(manufactured) == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Runtime catalog typed manufacturer lookup failed for {manufactured.Name}.");
|
||||
}
|
||||
|
||||
var packageCorporation = packageCatalog.Corporations.FirstOrDefault(corporation => !string.IsNullOrWhiteSpace(corporation.GeonameFileKey));
|
||||
if (packageCorporation == null)
|
||||
{
|
||||
throw new InvalidOperationException("Package catalog store did not read any corporation name-file links.");
|
||||
}
|
||||
|
||||
var packageNameFile = packageCatalog.GetNameFile(packageCorporation);
|
||||
if (string.IsNullOrWhiteSpace(packageCorporation.GeonameFileKey) ||
|
||||
packageCatalog.FindNameFile(packageCorporation.GeonameFileKey) == null ||
|
||||
packageNameFile == null ||
|
||||
string.IsNullOrWhiteSpace(packageNameFile.NameFileKey) ||
|
||||
packageNameFile.Names.Count == 0 ||
|
||||
packageNameFile.Names.Count != packageNameFile.NameCount)
|
||||
{
|
||||
throw new InvalidOperationException("Package catalog store did not read typed corporation/name-file links with full names.");
|
||||
}
|
||||
|
||||
if (packageCorporation.Allegiances.Count == 0 || packageCorporation.Allegiances.Count != packageCorporation.AllegianceCount)
|
||||
{
|
||||
throw new InvalidOperationException("Package catalog store did not read corporation allegiance edges.");
|
||||
}
|
||||
|
||||
if (surface?.Schema != "gamecult.eve.surface.v1" ||
|
||||
surface.Surface.Id != AetheriaEveSurfaceDocuments.CatalogSurfaceId)
|
||||
{
|
||||
throw new InvalidOperationException("Runtime catalog client did not read the typed Eve surface.");
|
||||
}
|
||||
|
||||
if (surface == null ||
|
||||
surface.Schema != "gamecult.eve.surface.v1" ||
|
||||
surface.ProviderId != "aetheria" ||
|
||||
surface.Surface.Root.Kind != "surface" ||
|
||||
surface.Surface.Root.Children.Length == 0 ||
|
||||
surface.Commands.All(command => command.Command != AetheriaRuntimeCatalogCommands.Refresh))
|
||||
{
|
||||
throw new InvalidOperationException("Unity runtime catalog client did not read the typed Eve surface contract from the managed document.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var eveCommand = AetheriaRuntimeEveCommands.SubmitCatalogCommand(
|
||||
commitSmokeStatePath,
|
||||
AetheriaRuntimeEveCommandKind.CatalogRefresh,
|
||||
"aetheria-state-unity-smoke");
|
||||
await using var eveCommandNode = await AetheriaStateNode.OpenAsync(
|
||||
commitSmokeStatePath,
|
||||
"aetheria-unity-runtime-eve-command-smoke");
|
||||
await eveCommandNode.SubmitEveCommandAsync(new GameCult.Aetheria.State.Verse.AetheriaRuntimeEveCommandDocument
|
||||
{
|
||||
Schema = eveCommand.Schema,
|
||||
CommandId = eveCommand.CommandId,
|
||||
ProviderId = eveCommand.ProviderId,
|
||||
SurfaceId = eveCommand.SurfaceId,
|
||||
Command = eveCommand.Command,
|
||||
Kind = (GameCult.Aetheria.State.Verse.AetheriaRuntimeEveCommandKind)(int)eveCommand.Kind,
|
||||
IssuedAtUtc = eveCommand.IssuedAtUtc,
|
||||
ClientId = eveCommand.ClientId,
|
||||
PlayerSettings = new GameCult.Aetheria.State.Verse.AetheriaRuntimePlayerSettingsCommandBody
|
||||
{
|
||||
PlayerName = eveCommand.PlayerSettings.PlayerName
|
||||
},
|
||||
InputSettings = new GameCult.Aetheria.State.Verse.AetheriaRuntimeInputSettingsCommandBody
|
||||
{
|
||||
ActionName = eveCommand.InputSettings.ActionName,
|
||||
BindingIndex = eveCommand.InputSettings.BindingIndex,
|
||||
InputSystemPath = eveCommand.InputSettings.InputSystemPath,
|
||||
Enabled = eveCommand.InputSettings.Enabled
|
||||
}
|
||||
});
|
||||
await eveCommandNode.FlushAsync();
|
||||
await using var eveCommandCheckNode = await AetheriaStateNode.OpenAsync(
|
||||
commitSmokeStatePath,
|
||||
"aetheria-unity-runtime-eve-command-check");
|
||||
var eveSubmitted = eveCommandCheckNode.Documents<AetheriaRuntimeEveCommandDocument>()
|
||||
.Select(AetheriaRuntimeEveCommandClient.NormalizeDocument)
|
||||
.OrderBy(command => command.IssuedAtUtc ?? "", StringComparer.Ordinal)
|
||||
.ThenBy(command => command.CommandId ?? "", StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (eveSubmitted.Length != 1 ||
|
||||
eveSubmitted[0].Schema != AetheriaRuntimeEveCommandClient.CommandSchema ||
|
||||
eveSubmitted[0].CommandId != eveCommand.CommandId ||
|
||||
eveSubmitted[0].ProviderId != "aetheria" ||
|
||||
eveSubmitted[0].SurfaceId != AetheriaRuntimeCatalogCommands.SurfaceId ||
|
||||
eveSubmitted[0].Command != AetheriaRuntimeCatalogCommands.Refresh ||
|
||||
(int)eveSubmitted[0].Kind != (int)AetheriaRuntimeEveCommandKind.CatalogRefresh ||
|
||||
eveSubmitted[0].PlayerSettings == null ||
|
||||
eveSubmitted[0].InputSettings == null)
|
||||
{
|
||||
var submitted = eveSubmitted.Length > 0 ? eveSubmitted[0] : null;
|
||||
throw new InvalidOperationException(
|
||||
"Runtime Eve command state record did not preserve typed command envelopes separately from state commits. " +
|
||||
$"Count={eveSubmitted.Length}, " +
|
||||
$"Schema={submitted?.Schema ?? "<null>"}, " +
|
||||
$"CommandId={submitted?.CommandId ?? "<null>"} expected {eveCommand.CommandId}, " +
|
||||
$"ProviderId={submitted?.ProviderId ?? "<null>"}, " +
|
||||
$"SurfaceId={submitted?.SurfaceId ?? "<null>"}, " +
|
||||
$"Command={submitted?.Command ?? "<null>"}, " +
|
||||
$"Kind={submitted?.Kind.ToString() ?? "<null>"}, " +
|
||||
$"PlayerSettings={(submitted?.PlayerSettings == null ? "null" : "present")}, " +
|
||||
$"InputSettings={(submitted?.InputSettings == null ? "null" : "present")}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("AETHERIA_KEEP_SMOKE_STATE") == "1")
|
||||
Console.Error.WriteLine($"Kept smoke state: {commitSmokeDirectory}");
|
||||
else
|
||||
Directory.Delete(commitSmokeDirectory, true);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Aetheria Unity runtime catalog smoke passed: {statePath}");
|
||||
Console.WriteLine($"Items/trade/equipment: {catalog.Items.Count}/{catalog.TradeItems.Count}/{catalog.EquipmentItems.Count}");
|
||||
Console.WriteLine($"Shape mask sample: {shaped.Name} {shaped.ShapeWidth}x{shaped.ShapeHeight}/{shaped.ShapeCells.Count}");
|
||||
Console.WriteLine($"Interior/hardpoint sample: {interior.Name} {interior.InteriorShapeCells.Count}; {hardpointHost.Name} {hardpoint.Type} {hardpoint.ShapeCells.Count}");
|
||||
Console.WriteLine($"Behavior payload sample: {behaviorHost.Name} {behaviorPayload.Kind}/{behaviorPayload.Fields.Count}");
|
||||
Console.WriteLine($"Behavior sample: {behaviorKind}");
|
||||
Console.WriteLine($"Eve surface: {surface.Surface.Id}");
|
||||
Console.WriteLine($"Catalog Eve surface document: {surface.Surface.Id}");
|
||||
Console.WriteLine("Runtime Eve command smoke: surface command document is a typed state record");
|
||||
|
||||
static int CountRequiredBehaviorItemRefsMissingItemKeys(AetheriaRuntimeBehaviorPayload payload)
|
||||
{
|
||||
return payload.Fields.Count(field =>
|
||||
IsBehaviorItemRefField(payload.Kind, field.Key) &&
|
||||
IsNonEmptyLegacyRefMissingItemKey(field.Value));
|
||||
}
|
||||
|
||||
static bool IsBehaviorItemRefField(string behaviorKind, int fieldKey)
|
||||
{
|
||||
return (string.Equals(behaviorKind, "ItemUsage", StringComparison.OrdinalIgnoreCase) && fieldKey == 1) ||
|
||||
(IsWeaponBehavior(behaviorKind) && fieldKey == 12);
|
||||
}
|
||||
|
||||
static bool IsWeaponBehavior(string behaviorKind)
|
||||
{
|
||||
return behaviorKind is "GuidedWeapon" or "InstantWeapon" or "ConstantWeapon" or "ChargedWeapon" or "AutoWeapon" or "LockWeapon";
|
||||
}
|
||||
|
||||
static bool IsNonEmptyLegacyRefMissingItemKey(AetheriaRuntimeBehaviorValue value)
|
||||
{
|
||||
return string.Equals(value.Kind, "legacy-id", StringComparison.OrdinalIgnoreCase) &&
|
||||
!IsEmptyLegacyId(value.LegacyIdValue) &&
|
||||
string.IsNullOrWhiteSpace(value.ItemKeyValue);
|
||||
}
|
||||
|
||||
static bool IsEmptyLegacyId(string legacyId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(legacyId) ||
|
||||
(Guid.TryParse(legacyId, out var parsed) && parsed == Guid.Empty);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Description>Unity-facing runtime read facade over typed Aetheria CultCache state.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Mesh;
|
||||
using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument;
|
||||
|
||||
namespace Aetheria.State.Unity;
|
||||
|
||||
public sealed class AetheriaRuntimeCatalogClient : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly AetheriaStateNode _node;
|
||||
|
||||
private AetheriaRuntimeCatalogClient(AetheriaStateNode node)
|
||||
{
|
||||
_node = node;
|
||||
}
|
||||
|
||||
public static async Task<AetheriaRuntimeCatalogClient> OpenAsync(string statePath)
|
||||
{
|
||||
var node = await AetheriaStateNode
|
||||
.OpenAsync(statePath, "aetheria-unity-runtime-catalog", enableDurableShardLogs: false)
|
||||
.ConfigureAwait(false);
|
||||
return new AetheriaRuntimeCatalogClient(node);
|
||||
}
|
||||
|
||||
public AetheriaRuntimeCatalogSnapshot ReadCatalog()
|
||||
{
|
||||
return _node.RuntimeCatalog().Latest();
|
||||
}
|
||||
|
||||
public EveSurfaceDocument? ReadCatalogSurface()
|
||||
{
|
||||
return _node.CatalogSurface().Latest();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return _node.DisposeAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_node.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
<ProjectReference Include="..\Aetheria.State.Unity\Aetheria.State.Unity.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\Aetheria.State\Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
internal enum AetheriaVerifyHardpointType
|
||||
{
|
||||
Hull,
|
||||
Tool,
|
||||
Thermal,
|
||||
Thruster,
|
||||
WarpDrive,
|
||||
Reactor,
|
||||
Radiator,
|
||||
Shield,
|
||||
Sensors,
|
||||
Energy,
|
||||
Ballistic,
|
||||
Launcher,
|
||||
ControlModule,
|
||||
AetherDrive
|
||||
}
|
||||
|
||||
internal enum AetheriaVerifySimpleCommodityCategory
|
||||
{
|
||||
Minerals,
|
||||
Metals,
|
||||
Alloys,
|
||||
Compounds,
|
||||
Organics,
|
||||
Ammo,
|
||||
Consumer
|
||||
}
|
||||
|
||||
internal enum AetheriaVerifyCompoundCommodityCategory
|
||||
{
|
||||
Wearables,
|
||||
Consumables,
|
||||
Luxuries,
|
||||
Tools,
|
||||
Manufacturing,
|
||||
Assemblies
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
<Project>
|
||||
<PropertyGroup>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\CultLib</CultLibRoot>
|
||||
<EveUnityRoot Condition="'$(EveUnityRoot)' == ''">$(MSBuildThisFileDirectory)..\..\EveUnity</EveUnityRoot>
|
||||
<YmirRoot Condition="'$(YmirRoot)' == ''">$(MSBuildThisFileDirectory)..\..\Ymir</YmirRoot>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<Project>
|
||||
<Target Name="VerifyGameCultMeshReference" AfterTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<ItemGroup>
|
||||
<_GameCultMeshReference Include="@(ReferencePath)"
|
||||
Condition="'%(ReferencePath.Filename)%(ReferencePath.Extension)' == 'GameCult.Mesh.dll'" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="@(_GameCultMeshReference->Count()) != 1"
|
||||
Text="Expected exactly one resolved GameCult.Mesh reference, found @(_GameCultMeshReference->Count()): @(_GameCultMeshReference, ', ')" />
|
||||
<Error Condition="$([System.String]::Copy('%(_GameCultMeshReference.FullPath)').StartsWith($([System.IO.Path]::GetFullPath('$(CultLibRoot)\')), System.StringComparison.OrdinalIgnoreCase)) != 'true'"
|
||||
Text="GameCult.Mesh resolved outside CultLibRoot '$(CultLibRoot)': %(_GameCultMeshReference.FullPath)" />
|
||||
|
||||
<GetFileHash Files="@(_GameCultMeshReference)" Algorithm="SHA256">
|
||||
<Output TaskParameter="Items" ItemName="_GameCultMeshReferenceHash" />
|
||||
</GetFileHash>
|
||||
<Message Importance="High"
|
||||
Text="GameCult.Mesh reference: %(_GameCultMeshReferenceHash.FullPath) SHA256=%(_GameCultMeshReferenceHash.FileHash)" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="Aetheria.State.Dependencies.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Description>Typed CultCache and CultMesh state spine for Aetheria.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\*.cs" Link="Runtime\%(Filename)%(Extension)" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
<ProjectReference Include="..\..\CultMath\src\CultMath\CultMath.csproj" />
|
||||
<ProjectReference Include="$(EveUnityRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj"
|
||||
AdditionalProperties="CultLibRoot=$(CultLibRoot)" />
|
||||
<ProjectReference Include="..\..\EvePlugins\plugins\eve-plugin-fields\GameCult.Eve.PluginFields.csproj"
|
||||
AdditionalProperties="CultLibRoot=$(CultLibRoot)" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Aetheria.State.Dependencies.targets" />
|
||||
</Project>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaCatalogKeys
|
||||
{
|
||||
public static CultRecordKey ItemDefinitionFromLegacyId(string legacyId)
|
||||
{
|
||||
return CreateLegacyKey("item", legacyId);
|
||||
}
|
||||
|
||||
public static CultRecordKey CorporationFromLegacyId(string legacyId)
|
||||
{
|
||||
return CreateLegacyKey("faction", legacyId);
|
||||
}
|
||||
|
||||
public static CultRecordKey NameFileFromLegacyId(string legacyId)
|
||||
{
|
||||
return CreateLegacyKey("name-file", legacyId);
|
||||
}
|
||||
|
||||
private static CultRecordKey CreateLegacyKey(string catalogKind, string legacyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(legacyId))
|
||||
{
|
||||
throw new ArgumentException("Legacy catalog id must be non-empty.", nameof(legacyId));
|
||||
}
|
||||
|
||||
return new CultRecordKey($"legacy:{catalogKind}:{legacyId.Trim()}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Eve.PluginFields;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaDocumentRegistry
|
||||
{
|
||||
public static CultDocumentRegistry CreateCultCacheRegistry()
|
||||
{
|
||||
return CultMesh.CreateCultCacheDocumentRegistry(DocumentTypes);
|
||||
}
|
||||
|
||||
public static CultNetDocumentRegistry CreateCultNetRegistry(CultDocumentRegistry? cacheRegistry = null)
|
||||
{
|
||||
var registry = cacheRegistry ?? CreateCultCacheRegistry();
|
||||
return CultMesh.CreateCultNetDocumentRegistry(DocumentTypes, registry);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Type> DocumentTypes { get; } =
|
||||
[
|
||||
typeof(AetheriaWorldState),
|
||||
typeof(AetheriaItemDefinition),
|
||||
typeof(AetheriaCorporation),
|
||||
typeof(AetheriaNameFile),
|
||||
typeof(AetheriaPlayerProfile),
|
||||
typeof(AetheriaHangarState),
|
||||
typeof(AetheriaRuntimeSession),
|
||||
typeof(AetheriaMigrationLedger),
|
||||
typeof(AetheriaLegacyCatalogQuarantine),
|
||||
typeof(AetheriaTradeValuePolicy),
|
||||
typeof(AetheriaRuntimeCatalogSnapshot),
|
||||
typeof(AetheriaRuntimeNameCorpusSnapshot),
|
||||
typeof(AetheriaPlayerSettings),
|
||||
typeof(AetheriaLoadoutTemplate),
|
||||
typeof(AetheriaRunState),
|
||||
typeof(AetheriaZoneState),
|
||||
typeof(AetheriaEntitySnapshot),
|
||||
typeof(AetheriaVerseHostSettings),
|
||||
typeof(AetheriaMainMenuState),
|
||||
typeof(AetheriaGameSessionState),
|
||||
typeof(AetheriaEveCommandAcceptanceStatus),
|
||||
typeof(AetheriaRuntimeDaemonProviderAdvertisementDocument),
|
||||
typeof(AetheriaRuntimeDaemonHealthDocument),
|
||||
typeof(AetheriaRuntimeDaemonCommandBoundaryDocument),
|
||||
typeof(AetheriaRuntimeAssetManifestDocument),
|
||||
typeof(AetheriaRuntimeDaemonFrameDocument),
|
||||
typeof(AetheriaRuntimeGameViewportDocument),
|
||||
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(EveFieldsSplatsDocument),
|
||||
typeof(EveInputCapabilityDocument),
|
||||
typeof(AetheriaRuntimeDaemonSoaViewDocument),
|
||||
typeof(AetheriaRuntimeVerseAuthorityPolicyDocument),
|
||||
typeof(AetheriaRuntimeAuthorityLeaseDocument),
|
||||
typeof(AetheriaRuntimeDaemonCommandDocument),
|
||||
typeof(AetheriaRuntimeCommittedCommandFactDocument),
|
||||
typeof(AetheriaRuntimeEveCommandDocument),
|
||||
typeof(AetheriaRuntimeSurfaceDocument),
|
||||
typeof(EveProviderAdvertisementDocument),
|
||||
typeof(EveSurfaceDocument),
|
||||
typeof(EveSurfaceCommandRequest),
|
||||
typeof(EveCommandReceiptDocument),
|
||||
typeof(EveAssetCatalogDocument)
|
||||
];
|
||||
}
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Mesh;
|
||||
using EveProviderAdvertisementDocument = GameCult.Eve.Surface.EveProviderAdvertisementDocument;
|
||||
using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public sealed class AetheriaEveCommandAcceptanceReport
|
||||
{
|
||||
public int AcceptedCatalogRefreshes { get; set; }
|
||||
public int AcceptedOperationsRefreshes { get; set; }
|
||||
public int AcceptedPlayerSettingsCommands { get; set; }
|
||||
public int AcceptedInputSettingsCommands { get; set; }
|
||||
public int AcceptedLoadoutTemplateCommands { get; set; }
|
||||
public int AcceptedVerseHostCommands { get; set; }
|
||||
public int AcceptedTradeValuePolicyCommands { get; set; }
|
||||
public int AcceptedMainMenuCommands { get; set; }
|
||||
public int RejectedCommands { get; set; }
|
||||
public string[] AcceptedCommandIds { get; set; } = [];
|
||||
public string[] RejectedCommandIds { get; set; } = [];
|
||||
public string[] AccountedCommandIds { get; set; } = [];
|
||||
public string LastRejectedCommand { get; set; } = "";
|
||||
public string LastRejectedReason { get; set; } = "";
|
||||
}
|
||||
public static class AetheriaEveCommandBridge
|
||||
{
|
||||
public const string CommandSchema = "gamecult.eve.command.v1";
|
||||
|
||||
public static async Task<AetheriaEveCommandAcceptanceReport> AcceptObservedAsync(
|
||||
AetheriaStateNode node,
|
||||
IEnumerable<string>? accountedCommandIds = null)
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
|
||||
var report = new AetheriaEveCommandAcceptanceReport();
|
||||
var accepted = new List<string>();
|
||||
var rejected = new List<string>();
|
||||
var accounted = new HashSet<string>(accountedCommandIds ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
foreach (var command in node.Documents<AetheriaRuntimeEveCommandDocument>()
|
||||
.Select(AetheriaRuntimeEveCommandClient.NormalizeDocument)
|
||||
.OrderBy(command => command.IssuedAtUtc ?? "", StringComparer.Ordinal)
|
||||
.ThenBy(command => command.CommandId ?? "", StringComparer.Ordinal)
|
||||
.Where(command => !string.IsNullOrWhiteSpace(command.CommandId))
|
||||
.Where(command => !accounted.Contains(command.CommandId)))
|
||||
{
|
||||
var rejection = Validate(command);
|
||||
if (!string.IsNullOrWhiteSpace(rejection))
|
||||
{
|
||||
RecordRejection(report, command, rejection, rejected);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case AetheriaRuntimeEveCommandKind.CatalogRefresh:
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
var catalog = await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false);
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.CatalogSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildCatalogSurface(catalog, command.IssuedAtUtc))
|
||||
.ConfigureAwait(false);
|
||||
report.AcceptedCatalogRefreshes++;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.OperationsRefresh:
|
||||
var eveStatus = await node.MutableDocument<AetheriaEveCommandAcceptanceStatus>(AetheriaStateNode.EveCommandAcceptanceStatusKey).ReadAsync().ConfigureAwait(false) ??
|
||||
EmptyEveCommandAcceptanceStatus(node.StatePath, command.IssuedAtUtc);
|
||||
var verseHostSettings = await node.MutableDocument<AetheriaVerseHostSettings>(AetheriaStateNode.VerseHostSettingsKey).ReadAsync().ConfigureAwait(false);
|
||||
var runtimeSession = await node.MutableDocument<AetheriaRuntimeSession>(AetheriaStateNode.RuntimeSessionKey(eveStatus.RuntimeId)).ReadAsync().ConfigureAwait(false);
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.OperationsSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildOperationsSurface(
|
||||
eveStatus,
|
||||
verseHostSettings,
|
||||
runtimeSession))
|
||||
.ConfigureAwait(false);
|
||||
report.AcceptedOperationsRefreshes++;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.PlayerSettingsRefresh:
|
||||
case AetheriaRuntimeEveCommandKind.SetPlayerName:
|
||||
case AetheriaRuntimeEveCommandKind.CycleTemperatureUnit:
|
||||
case AetheriaRuntimeEveCommandKind.DecrementSignificantDigits:
|
||||
case AetheriaRuntimeEveCommandKind.IncrementSignificantDigits:
|
||||
case AetheriaRuntimeEveCommandKind.CycleNebulaQuality:
|
||||
case AetheriaRuntimeEveCommandKind.ToggleShowAsteroidsInMinimap:
|
||||
await ExecutePlayerSettingsCommandAsync(node, command).ConfigureAwait(false);
|
||||
report.AcceptedPlayerSettingsCommands++;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.InputSettingsRefresh:
|
||||
case AetheriaRuntimeEveCommandKind.SetBindingOverride:
|
||||
case AetheriaRuntimeEveCommandKind.SetActionBarEnabled:
|
||||
await ExecuteInputSettingsCommandAsync(node, command).ConfigureAwait(false);
|
||||
report.AcceptedInputSettingsCommands++;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.SaveLoadoutTemplate:
|
||||
await ExecuteLoadoutTemplateCommandAsync(node, command).ConfigureAwait(false);
|
||||
report.AcceptedLoadoutTemplateCommands++;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.VerseHostRefresh:
|
||||
case AetheriaRuntimeEveCommandKind.CycleVerseHostVisibility:
|
||||
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;
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuContinueRun:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuNewGame:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuQuit:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowPlayerSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowVerseSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowInputSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuBackToMain:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuBackToSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuOpenRuntimeInputScreen:
|
||||
await ExecuteMainMenuCommandAsync(node, command).ConfigureAwait(false);
|
||||
report.AcceptedMainMenuCommands++;
|
||||
break;
|
||||
}
|
||||
|
||||
accepted.Add(command.CommandId ?? "");
|
||||
}
|
||||
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
report.AcceptedCommandIds = accepted.ToArray();
|
||||
report.RejectedCommandIds = rejected.ToArray();
|
||||
report.AccountedCommandIds = accounted
|
||||
.Concat(report.AcceptedCommandIds)
|
||||
.Concat(report.RejectedCommandIds)
|
||||
.Where(commandId => !string.IsNullOrWhiteSpace(commandId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return report;
|
||||
}
|
||||
|
||||
private static string Validate(AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
if (!string.Equals(command.Schema, CommandSchema, StringComparison.Ordinal))
|
||||
return $"Unexpected Eve command schema '{command.Schema}'.";
|
||||
if (!string.Equals(command.ProviderId, AetheriaEveSurfaceDocuments.ProviderId, StringComparison.Ordinal))
|
||||
return $"Unexpected Eve provider '{command.ProviderId}'.";
|
||||
if (command.Kind == AetheriaRuntimeEveCommandKind.Unknown)
|
||||
return $"Unknown typed Eve command kind for surface '{command.SurfaceId}' command '{command.Command}'.";
|
||||
if (AetheriaRuntimeEveCommandClient.CommandKindForSurface(command.SurfaceId, command.Command) != command.Kind)
|
||||
return $"Eve command '{command.Command}' does not match typed command kind '{command.Kind}'.";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static async Task ExecutePlayerSettingsCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
settings.Gameplay ??= new AetheriaPlayerGameplaySettings();
|
||||
settings.Graphics ??= new AetheriaPlayerGraphicsSettings();
|
||||
var persistSettings = false;
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case AetheriaRuntimeEveCommandKind.SetPlayerName:
|
||||
settings.PlayerName = command.PlayerSettings.PlayerName ?? "";
|
||||
persistSettings = true;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.CycleTemperatureUnit:
|
||||
settings.Gameplay.TemperatureUnit = Cycle(
|
||||
settings.Gameplay.TemperatureUnit,
|
||||
"Kelvin",
|
||||
"Celsius",
|
||||
"Fahrenheit");
|
||||
persistSettings = true;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.DecrementSignificantDigits:
|
||||
settings.Gameplay.SignificantDigits = Math.Max(0, settings.Gameplay.SignificantDigits - 1);
|
||||
persistSettings = true;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.IncrementSignificantDigits:
|
||||
if (settings.Gameplay.SignificantDigits < int.MaxValue)
|
||||
settings.Gameplay.SignificantDigits++;
|
||||
persistSettings = true;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.CycleNebulaQuality:
|
||||
settings.Graphics.NebulaQuality = Cycle(
|
||||
settings.Graphics.NebulaQuality,
|
||||
"Low",
|
||||
"Normal",
|
||||
"High",
|
||||
"Ultra");
|
||||
persistSettings = true;
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.ToggleShowAsteroidsInMinimap:
|
||||
settings.Graphics.ShowAsteroidsInMinimap = !settings.Graphics.ShowAsteroidsInMinimap;
|
||||
persistSettings = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (persistSettings)
|
||||
{
|
||||
settings.LastUpdatedAtUtc = command.IssuedAtUtc;
|
||||
await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReplaceAsync(settings)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.PlayerSettingsSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildPlayerSettingsSurface(settings, command.IssuedAtUtc))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteInputSettingsCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
||||
settings.Input ??= new AetheriaPlayerInputSettings();
|
||||
var persistSettings = false;
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case AetheriaRuntimeEveCommandKind.SetBindingOverride:
|
||||
var actionName = command.InputSettings.ActionName ?? "";
|
||||
var inputSystemPath = command.InputSettings.InputSystemPath ?? "";
|
||||
var bindingIndex = command.InputSettings.BindingIndex;
|
||||
if (!string.IsNullOrWhiteSpace(actionName) && bindingIndex >= 0)
|
||||
{
|
||||
settings.Input.BindingOverrides = settings.Input.BindingOverrides
|
||||
.Where(binding => !string.Equals(binding.ActionName, actionName, StringComparison.Ordinal) ||
|
||||
binding.BindingIndex != bindingIndex)
|
||||
.Concat(new[]
|
||||
{
|
||||
new AetheriaInputBindingOverride
|
||||
{
|
||||
ActionName = actionName,
|
||||
BindingIndex = bindingIndex,
|
||||
BindingPath = inputSystemPath
|
||||
}
|
||||
})
|
||||
.OrderBy(binding => binding.ActionName, StringComparer.Ordinal)
|
||||
.ThenBy(binding => binding.BindingIndex)
|
||||
.ToArray();
|
||||
persistSettings = true;
|
||||
}
|
||||
break;
|
||||
case AetheriaRuntimeEveCommandKind.SetActionBarEnabled:
|
||||
var inputPath = command.InputSettings.InputSystemPath ?? "";
|
||||
var enabled = command.InputSettings.Enabled;
|
||||
if (!string.IsNullOrWhiteSpace(inputPath))
|
||||
{
|
||||
var inputs = settings.Input.ActionBarInputs
|
||||
.Where(path => !string.Equals(path, inputPath, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
if (enabled)
|
||||
inputs.Add(inputPath);
|
||||
settings.Input.ActionBarInputs = inputs
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
persistSettings = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (persistSettings)
|
||||
{
|
||||
settings.LastUpdatedAtUtc = command.IssuedAtUtc;
|
||||
await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReplaceAsync(settings)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExecuteVerseHostCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var settings = await node.MutableDocument<AetheriaVerseHostSettings>(AetheriaStateNode.VerseHostSettingsKey).ReadAsync().ConfigureAwait(false) ?? new AetheriaVerseHostSettings();
|
||||
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
|
||||
var persistSettings = false;
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case AetheriaRuntimeEveCommandKind.CycleVerseHostVisibility:
|
||||
normalized.Visibility = Cycle(normalized.Visibility, "private", "public");
|
||||
persistSettings = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (persistSettings)
|
||||
{
|
||||
normalized.LastUpdatedAtUtc = command.IssuedAtUtc;
|
||||
await node.MutableDocument<AetheriaVerseHostSettings>(AetheriaStateNode.VerseHostSettingsKey)
|
||||
.ReplaceAsync(normalized)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var eveStatus = await node.MutableDocument<AetheriaEveCommandAcceptanceStatus>(AetheriaStateNode.EveCommandAcceptanceStatusKey).ReadAsync().ConfigureAwait(false) ??
|
||||
EmptyEveCommandAcceptanceStatus(node.StatePath, command.IssuedAtUtc);
|
||||
var runtimeSession = await node.MutableDocument<AetheriaRuntimeSession>(AetheriaStateNode.RuntimeSessionKey(eveStatus.RuntimeId)).ReadAsync().ConfigureAwait(false);
|
||||
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.OperationsSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildOperationsSurface(
|
||||
eveStatus,
|
||||
normalized,
|
||||
runtimeSession))
|
||||
.ConfigureAwait(false);
|
||||
await node.MutableDocument<EveProviderAdvertisementDocument>(AetheriaStateNode.ProviderAdvertisementSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildProviderAdvertisement(normalized, node.StatePath, command.IssuedAtUtc))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteLoadoutTemplateCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var commit = command.LoadoutTemplate ?? throw new InvalidOperationException(
|
||||
$"Loadout template command '{command.CommandId}' is missing its typed payload.");
|
||||
var loadout = AetheriaRuntimeStateMapper.ToLoadoutTemplate(commit, command.IssuedAtUtc);
|
||||
await node.MutableDocument<AetheriaLoadoutTemplate>(AetheriaRuntimeStateMapper.LoadoutKey(loadout.Name))
|
||||
.ReplaceAsync(loadout)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteTradeValuePolicyCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var tradeValuePolicy = node.MutableDocument<AetheriaTradeValuePolicy>(AetheriaStateNode.TradeValuePolicyKey);
|
||||
var policy = await tradeValuePolicy.ReadAsync().ConfigureAwait(false) ??
|
||||
AetheriaRuntimeStateMapper.ToTradeValuePolicy(
|
||||
AetheriaRuntimeTradeValueSettings.Default,
|
||||
command.IssuedAtUtc);
|
||||
policy.QualityPriceModifier ??= new AetheriaExponentialLerp();
|
||||
policy.Tiers ??= Array.Empty<AetheriaItemRarityTier>();
|
||||
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 tradeValuePolicy.ReplaceAsync(policy).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExecuteMainMenuCommandAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
var state = await node.MutableDocument<AetheriaMainMenuState>(AetheriaStateNode.MainMenuStateKey).ReadAsync().ConfigureAwait(false) ??
|
||||
new AetheriaMainMenuState();
|
||||
var activeSurfaceId = ActiveMainMenuSurfaceFor(command.Kind, state.ActiveSurfaceId);
|
||||
state.ActiveSurfaceId = activeSurfaceId;
|
||||
state.LastCommandId = command.CommandId ?? "";
|
||||
state.LastCommand = command.Command ?? "";
|
||||
state.UpdatedAtUtc = string.IsNullOrWhiteSpace(command.IssuedAtUtc)
|
||||
? DateTimeOffset.UtcNow.ToString("O")
|
||||
: command.IssuedAtUtc;
|
||||
await node.MutableDocument<AetheriaMainMenuState>(AetheriaStateNode.MainMenuStateKey)
|
||||
.ReplaceAsync(state)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ActiveMainMenuSurfaceFor(
|
||||
AetheriaRuntimeEveCommandKind kind,
|
||||
string currentSurfaceId)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuBackToSettings:
|
||||
return AetheriaRuntimeMainMenuCommands.SettingsSurfaceId;
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowPlayerSettings:
|
||||
return AetheriaRuntimeMainMenuCommands.PlayerSettingsSurfaceId;
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowVerseSettings:
|
||||
return AetheriaRuntimeMainMenuCommands.VerseSettingsSurfaceId;
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuShowInputSettings:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuOpenRuntimeInputScreen:
|
||||
return AetheriaRuntimeMainMenuCommands.InputSettingsSurfaceId;
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuBackToMain:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuContinueRun:
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuNewGame:
|
||||
return "";
|
||||
case AetheriaRuntimeEveCommandKind.MainMenuQuit:
|
||||
return AetheriaRuntimeMainMenuCommands.RootSurfaceId;
|
||||
default:
|
||||
return NormalizeMainMenuSurfaceId(currentSurfaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeMainMenuSurfaceId(string surfaceId)
|
||||
{
|
||||
switch (surfaceId ?? "")
|
||||
{
|
||||
case AetheriaRuntimeMainMenuCommands.SettingsSurfaceId:
|
||||
case AetheriaRuntimeMainMenuCommands.InputSettingsSurfaceId:
|
||||
case AetheriaRuntimeMainMenuCommands.PlayerSettingsSurfaceId:
|
||||
case AetheriaRuntimeMainMenuCommands.VerseSettingsSurfaceId:
|
||||
return surfaceId;
|
||||
default:
|
||||
return AetheriaRuntimeMainMenuCommands.RootSurfaceId;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Cycle(string current, params string[] values)
|
||||
{
|
||||
if (values.Length == 0)
|
||||
return current;
|
||||
|
||||
var index = Array.FindIndex(values, value => string.Equals(value, current, StringComparison.Ordinal));
|
||||
if (index < 0)
|
||||
return values[0];
|
||||
|
||||
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,
|
||||
string reason,
|
||||
List<string> rejected)
|
||||
{
|
||||
report.RejectedCommands++;
|
||||
report.LastRejectedCommand = string.IsNullOrWhiteSpace(command.Command)
|
||||
? command.CommandId
|
||||
: command.Command;
|
||||
report.LastRejectedReason = reason;
|
||||
rejected.Add(command.CommandId ?? "");
|
||||
}
|
||||
|
||||
private static AetheriaEveCommandAcceptanceStatus EmptyEveCommandAcceptanceStatus(string statePath, string now)
|
||||
{
|
||||
return new AetheriaEveCommandAcceptanceStatus
|
||||
{
|
||||
RuntimeId = "aetheria-state",
|
||||
StatePath = statePath,
|
||||
LastPollAtUtc = now,
|
||||
Status = "idle"
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Mesh;
|
||||
using EveCommandTemplate = GameCult.Eve.Surface.EveCommandTemplate;
|
||||
using EveCommandReceiptDocument = GameCult.Eve.Surface.EveCommandReceiptDocument;
|
||||
using EveAdvertisedCommand = GameCult.Eve.Surface.EveAdvertisedCommand;
|
||||
using EveAdvertisedSurface = GameCult.Eve.Surface.EveAdvertisedSurface;
|
||||
using EveProviderAdvertisementDocument = GameCult.Eve.Surface.EveProviderAdvertisementDocument;
|
||||
using EveProviderFreshness = GameCult.Eve.Surface.EveProviderFreshness;
|
||||
using EveProviderWitness = GameCult.Eve.Surface.EveProviderWitness;
|
||||
using EveStyleToken = GameCult.Eve.Surface.EveStyleToken;
|
||||
using EveSurfaceComponent = GameCult.Eve.Surface.EveSurfaceComponent;
|
||||
using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument;
|
||||
using EveSurfaceTree = GameCult.Eve.Surface.EveSurfaceTree;
|
||||
using EveWorldInteractionAdvertisement = GameCult.Eve.Surface.EveWorldInteractionAdvertisement;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaEveSurfaceDocuments
|
||||
{
|
||||
public const string ProviderId = "aetheria";
|
||||
public const string ProviderAdvertisementKey = "eve:provider:aetheria";
|
||||
public const string CatalogSurfaceKey = "eve:surface:aetheria.catalog.operator";
|
||||
public const string OperationsSurfaceKey = "eve:surface:aetheria.operations";
|
||||
public const string PlayerSettingsSurfaceKey = "eve:surface:aetheria.player_settings";
|
||||
public const string DaemonGameSurfaceKey = "eve:surface:aetheria.daemon.game";
|
||||
public const string DaemonGameTuiSurfaceKey = "eve:surface:aetheria.daemon.game.tui";
|
||||
public const string DaemonEditorSurfaceKey = "eve:surface:aetheria.daemon.editor";
|
||||
public const string DaemonEditorTuiSurfaceKey = "eve:surface:aetheria.daemon.editor.tui";
|
||||
|
||||
public const string CatalogSurfaceId = AetheriaRuntimeCatalogCommands.SurfaceId;
|
||||
public const string OperationsSurfaceId = AetheriaRuntimeOperationsCommands.SurfaceId;
|
||||
public const string PlayerSettingsSurfaceId = AetheriaRuntimePlayerSettingsCommands.SurfaceId;
|
||||
|
||||
private const string DaemonCommandBoundaryId = "aetheria.daemon.commands";
|
||||
private const string DaemonRecordTransport = "cultmesh-record";
|
||||
|
||||
public static EveSurfaceDocument BuildCatalogSurface(
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string updatedAtUtc,
|
||||
long version = 1)
|
||||
{
|
||||
var tradeItems = catalog.TradeItems.Take(12).Select(item =>
|
||||
Row(
|
||||
$"item.{SafeId(item.ItemKey)}",
|
||||
("name", item.Name),
|
||||
("manufacturer", catalog.GetManufacturer(item)?.Name ?? "GameCult"),
|
||||
("price", item.Price.ToString("N0")),
|
||||
("size", item.ShapeWidth > 0 && item.ShapeHeight > 0 ? $"{item.ShapeWidth}x{item.ShapeHeight}" : "")))
|
||||
.ToArray();
|
||||
|
||||
var corporations = catalog.Corporations.Take(12).Select(corporation =>
|
||||
Row(
|
||||
$"corporation.{SafeId(corporation.CorporationKey)}",
|
||||
("name", corporation.Name),
|
||||
("short", corporation.ShortName),
|
||||
("names", catalog.GetNameFile(corporation)?.Name ?? ""),
|
||||
("influence", corporation.InfluenceDistance.ToString())))
|
||||
.ToArray();
|
||||
var refreshCommand = CultMesh.OperationBindingRecord(
|
||||
CultMesh.OperationBinding(
|
||||
AetheriaRuntimeCatalogCommands.Refresh,
|
||||
label: "Refresh",
|
||||
routeHint: new CultMeshRouteHint(
|
||||
CultMeshLocalityKind.Automatic,
|
||||
"cultmesh")));
|
||||
|
||||
return new EveSurfaceDocument(
|
||||
ProviderId,
|
||||
"game.runtime",
|
||||
"Aetheria Catalog",
|
||||
version,
|
||||
updatedAtUtc,
|
||||
new EveSurfaceTree(
|
||||
CatalogSurfaceId,
|
||||
Node(
|
||||
"aetheria.catalog.root",
|
||||
"surface",
|
||||
[],
|
||||
Node(
|
||||
"aetheria.catalog.summary",
|
||||
"grid",
|
||||
[("columns", "6")],
|
||||
Metric("summary.items", "Items", catalog.Items.Count.ToString()),
|
||||
Metric("summary.trade", "Trade Items", catalog.TradeItems.Count().ToString()),
|
||||
Metric("summary.equipment", "Equipment", catalog.EquipmentItems.Count().ToString()),
|
||||
Metric("summary.behaviors", "Behavior Kinds", catalog.Items.SelectMany(item => item.BehaviorKinds).Distinct().Count().ToString()),
|
||||
Metric("summary.corporations", "Corporations", catalog.Corporations.Count.ToString()),
|
||||
Metric("summary.nameFiles", "Name Files", catalog.NameFiles.Count.ToString())),
|
||||
Node(
|
||||
"aetheria.catalog.trade",
|
||||
"card",
|
||||
[("title", "Trade Catalog")],
|
||||
Node("aetheria.catalog.trade.rows", "inspector.kv", [], tradeItems)),
|
||||
Node(
|
||||
"aetheria.catalog.corporations",
|
||||
"card",
|
||||
[("title", "Corporations")],
|
||||
Node("aetheria.catalog.corporation.rows", "inspector.kv", [], corporations))),
|
||||
Array.Empty<EveStyleToken>()),
|
||||
new[]
|
||||
{
|
||||
new EveCommandTemplate(refreshCommand.ToBinding())
|
||||
});
|
||||
}
|
||||
|
||||
public static EveSurfaceDocument BuildOperationsSurface(
|
||||
AetheriaEveCommandAcceptanceStatus? eveCommandStatus = null,
|
||||
AetheriaVerseHostSettings? verseHostSettings = null,
|
||||
AetheriaRuntimeSession? runtimeSession = null,
|
||||
long version = 1)
|
||||
{
|
||||
var normalizedVerseHost = AetheriaVerseHostSettingsNormalizer.Normalize(verseHostSettings);
|
||||
var updatedAtUtc = LatestTimestamp(
|
||||
eveCommandStatus?.LastPollAtUtc,
|
||||
normalizedVerseHost.LastUpdatedAtUtc,
|
||||
runtimeSession?.LastSeenAtUtc);
|
||||
var refreshCommand = CultMesh.OperationBindingRecord(
|
||||
CultMesh.OperationBinding(
|
||||
AetheriaRuntimeOperationsCommands.Refresh,
|
||||
label: "Refresh",
|
||||
routeHint: new CultMeshRouteHint(
|
||||
CultMeshLocalityKind.Automatic,
|
||||
"cultmesh")));
|
||||
return new EveSurfaceDocument(
|
||||
ProviderId,
|
||||
"game.runtime",
|
||||
"Aetheria Operations",
|
||||
version,
|
||||
updatedAtUtc,
|
||||
new EveSurfaceTree(
|
||||
OperationsSurfaceId,
|
||||
Node(
|
||||
"aetheria.operations.root",
|
||||
"surface",
|
||||
[],
|
||||
Node(
|
||||
"aetheria.operations.eveCommandAcceptance",
|
||||
"card",
|
||||
[("title", "Eve Request Acceptance")],
|
||||
Metric("eveCommandAcceptance.status", "Status", eveCommandStatus?.Status ?? "missing"),
|
||||
Metric("eveCommandAcceptance.observed", "Observed Before Accept", (eveCommandStatus?.ObservedBeforeAccept ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.accepted", "Commands Accepted", (eveCommandStatus?.CommandsAccepted ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.rejected", "Commands Rejected", (eveCommandStatus?.CommandsRejected ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.catalogRefreshes", "Catalog Refreshes", (eveCommandStatus?.AppliedCatalogRefreshes ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.operationsRefreshes", "Operations Refreshes", (eveCommandStatus?.AppliedOperationsRefreshes ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.playerSettings", "Player Settings Commands", (eveCommandStatus?.AppliedPlayerSettingsCommands ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.inputSettings", "Input Settings Commands", (eveCommandStatus?.AppliedInputSettingsCommands ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.loadoutTemplates", "Loadout Template Commands", (eveCommandStatus?.AppliedLoadoutTemplateCommands ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.verseHost", "Verse Host Commands", (eveCommandStatus?.AppliedVerseHostCommands ?? 0).ToString()),
|
||||
Metric("eveCommandAcceptance.failures", "Consecutive Failures", (eveCommandStatus?.ConsecutiveFailures ?? 0).ToString()),
|
||||
Row(
|
||||
"eveCommandAcceptance.last",
|
||||
("runtime", eveCommandStatus?.RuntimeId ?? ""),
|
||||
("lastPoll", eveCommandStatus?.LastPollAtUtc ?? ""),
|
||||
("lastAccepted", eveCommandStatus?.LastAcceptedAtUtc ?? ""),
|
||||
("lastRejected", eveCommandStatus?.LastRejectedCommand ?? ""),
|
||||
("rejectedReason", eveCommandStatus?.LastRejectedReason ?? ""),
|
||||
("error", eveCommandStatus?.LastError ?? ""))),
|
||||
Node(
|
||||
"aetheria.operations.verseHost",
|
||||
"card",
|
||||
[("title", "Verse Host")],
|
||||
Metric("verseHost.visibility", "Visibility", normalizedVerseHost.Visibility),
|
||||
Metric("verseHost.service", "Service", normalizedVerseHost.ServiceId),
|
||||
Metric("verseHost.verse", "Verse", normalizedVerseHost.VerseId),
|
||||
Row(
|
||||
"verseHost.identity",
|
||||
("rootVerse", normalizedVerseHost.RootVerse),
|
||||
("canonicalService", normalizedVerseHost.CanonicalService),
|
||||
("locatedService", normalizedVerseHost.LocatedService),
|
||||
("cultMeshAddress", normalizedVerseHost.CultMeshAddress))),
|
||||
Node(
|
||||
"aetheria.operations.runtimeSession",
|
||||
"card",
|
||||
[("title", "Runtime Session")],
|
||||
Metric("runtimeSession.status", "Status", runtimeSession?.Status ?? "missing"),
|
||||
Metric("runtimeSession.role", "Role", runtimeSession?.Role ?? ""),
|
||||
Row(
|
||||
"runtimeSession.last",
|
||||
("runtime", runtimeSession?.RuntimeId ?? ""),
|
||||
("started", runtimeSession?.StartedAtUtc ?? ""),
|
||||
("lastSeen", runtimeSession?.LastSeenAtUtc ?? "")))),
|
||||
Array.Empty<EveStyleToken>()),
|
||||
new[]
|
||||
{
|
||||
new EveCommandTemplate(refreshCommand.ToBinding())
|
||||
});
|
||||
}
|
||||
|
||||
public static EveSurfaceDocument BuildPlayerSettingsSurface(
|
||||
AetheriaPlayerSettings? settings,
|
||||
string updatedAtUtc,
|
||||
long version = 1)
|
||||
{
|
||||
settings ??= new AetheriaPlayerSettings();
|
||||
var gameplay = settings.Gameplay ?? new AetheriaPlayerGameplaySettings();
|
||||
var graphics = settings.Graphics ?? new AetheriaPlayerGraphicsSettings();
|
||||
var publishedAtUtc = !string.IsNullOrWhiteSpace(settings.LastUpdatedAtUtc)
|
||||
? settings.LastUpdatedAtUtc
|
||||
: updatedAtUtc;
|
||||
|
||||
var surface = AetheriaRuntimePlayerSettingsSurfaceBuilder.Build(
|
||||
settings.PlayerName,
|
||||
settings.TutorialPassed,
|
||||
settings.ActiveRunKey,
|
||||
gameplay.TemperatureUnit,
|
||||
gameplay.SignificantDigits,
|
||||
graphics.NebulaQuality,
|
||||
graphics.ShowAsteroidsInMinimap,
|
||||
publishedAtUtc,
|
||||
version);
|
||||
|
||||
return AetheriaRuntimeSurfaceDocuments.ToPortableSurface(surface);
|
||||
}
|
||||
|
||||
public static EveProviderAdvertisementDocument BuildProviderAdvertisement(
|
||||
AetheriaVerseHostSettings settings,
|
||||
string statePath,
|
||||
string updatedAtUtc)
|
||||
{
|
||||
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
|
||||
var schemas = new[]
|
||||
{
|
||||
"aetheria.world_state.v1",
|
||||
"aetheria.item_definition.v1",
|
||||
"aetheria.corporation.v2",
|
||||
"aetheria.name_file.v2",
|
||||
"aetheria.trade_value_policy.v1",
|
||||
"aetheria.player_settings.v1",
|
||||
"aetheria.game_session.v1",
|
||||
"aetheria.loadout_template.v1",
|
||||
"aetheria.run_state.v1",
|
||||
"aetheria.zone_state.v1",
|
||||
"aetheria.entity_snapshot.v1",
|
||||
"aetheria.verse_host_settings.v1",
|
||||
"aetheria.runtime_session.v1",
|
||||
"aetheria.eve_command_acceptance_status.v1",
|
||||
EveSurfaceDocument.SchemaId,
|
||||
AetheriaRuntimeEveCommandDocument.SchemaId,
|
||||
AetheriaRuntimeDaemonSchemas.ProviderAdvertisement,
|
||||
AetheriaRuntimeDaemonSchemas.Frame,
|
||||
AetheriaRuntimeDaemonSchemas.SoaView,
|
||||
AetheriaRuntimeDaemonSchemas.Health,
|
||||
AetheriaRuntimeDaemonSchemas.CommandBoundary,
|
||||
AetheriaRuntimeDaemonSchemas.GameSurface,
|
||||
AetheriaRuntimeDaemonSchemas.EditorSurface,
|
||||
AetheriaRuntimeDaemonSchemas.Command
|
||||
};
|
||||
var witnesses = new[]
|
||||
{
|
||||
Witness("cultcache", statePath, "Aetheria typed CultCache state file"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonProviderAdvertisement.ToString(),
|
||||
"Aetheria daemon-owned provider advertisement record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(),
|
||||
"Aetheria daemon latest simulation frame record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonSoaViewLatest.ToString(),
|
||||
"Aetheria daemon latest SoA view record for thin clients"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonHealth.ToString(),
|
||||
"Aetheria daemon health record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonCommandBoundary.ToString(),
|
||||
"Aetheria daemon typed command boundary record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonGameSurface.ToString(),
|
||||
"Aetheria daemon game Eve GUI surface record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonGameTuiSurface.ToString(),
|
||||
"Aetheria daemon game Eve TUI surface record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonEditorSurface.ToString(),
|
||||
"Aetheria daemon editor Eve GUI surface record"),
|
||||
Witness(DaemonRecordTransport, AetheriaRuntimeVerseRecordKeys.DaemonEditorTuiSurface.ToString(),
|
||||
"Aetheria daemon editor Eve TUI surface record")
|
||||
};
|
||||
var commands = new[]
|
||||
{
|
||||
Command(DaemonCommandBoundaryId, "", "Aetheria daemon typed command boundary"),
|
||||
Command(AetheriaRuntimeCatalogCommands.Refresh, AetheriaRuntimeCatalogCommands.SurfaceId, "Refresh catalog state"),
|
||||
Command(AetheriaRuntimeOperationsCommands.Refresh, AetheriaRuntimeOperationsCommands.SurfaceId, "Refresh operations state"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.Refresh, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Refresh player settings state"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.CycleTemperatureUnit, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Cycle the typed player temperature unit"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.DecrementSignificantDigits, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Decrease typed player significant digits"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.IncrementSignificantDigits, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Increase typed player significant digits"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.CycleNebulaQuality, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Cycle typed player nebula quality"),
|
||||
Command(AetheriaRuntimePlayerSettingsCommands.ToggleShowAsteroidsInMinimap, AetheriaRuntimePlayerSettingsCommands.SurfaceId, "Toggle typed player minimap asteroid visibility")
|
||||
};
|
||||
|
||||
return new EveProviderAdvertisementDocument(
|
||||
ProviderId,
|
||||
normalized.ServiceId,
|
||||
normalized.VerseId,
|
||||
normalized.Title,
|
||||
"game.runtime",
|
||||
normalized.CultMeshAddress,
|
||||
updatedAtUtc,
|
||||
new EveProviderFreshness("fresh", updatedAtUtc, 15000),
|
||||
schemas,
|
||||
witnesses,
|
||||
AetheriaRuntimeEveSurfaceCatalog.All.Select(ToProviderSurface).ToArray(),
|
||||
commands,
|
||||
new[] { AetheriaRuntimeDaemonSoaFramePublisher.ProducerId });
|
||||
}
|
||||
|
||||
private static EveProviderWitness Witness(string kind, string reference, string summary) =>
|
||||
new(kind, reference, summary);
|
||||
|
||||
private static EveAdvertisedCommand Command(string command, string surfaceId, string summary) =>
|
||||
new(command, surfaceId, "cultmesh", summary);
|
||||
|
||||
private static EveAdvertisedSurface ToProviderSurface(AetheriaRuntimeEveSurfaceAdvertisement surface)
|
||||
{
|
||||
var interaction = surface.WorldInteraction == null
|
||||
? null
|
||||
: new EveWorldInteractionAdvertisement(
|
||||
surface.WorldInteraction.ProjectionKind,
|
||||
surface.WorldInteraction.StateSchemas,
|
||||
surface.WorldInteraction.CommandBoundary,
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonCommandBoundary.ToString(),
|
||||
EveCommandReceiptDocument.SchemaId,
|
||||
AetheriaRuntimeVerseRecordKeys.EveReceiptRecordPrefix,
|
||||
AetheriaRuntimeVerseRecordKeys.EveAssetCatalog.ToString(),
|
||||
surface.WorldInteraction.LoweringTargets,
|
||||
surface.WorldInteraction.Ownership);
|
||||
return new EveAdvertisedSurface(
|
||||
surface.SurfaceId,
|
||||
EveSurfaceDocument.SchemaId,
|
||||
surface.RecordRef,
|
||||
surface.Transport,
|
||||
surface.Status,
|
||||
surface.SurfaceKind,
|
||||
interaction);
|
||||
}
|
||||
|
||||
|
||||
private static string LatestTimestamp(params string?[] timestamps)
|
||||
{
|
||||
var latest = "";
|
||||
foreach (var timestamp in timestamps)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timestamp) &&
|
||||
(string.IsNullOrWhiteSpace(latest) || string.CompareOrdinal(timestamp, latest) > 0))
|
||||
{
|
||||
latest = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
private static EveSurfaceComponent Metric(string id, string label, string value)
|
||||
{
|
||||
return Node(id, "metric", [("label", label), ("value", value)]);
|
||||
}
|
||||
|
||||
private static EveSurfaceComponent Row(string id, params (string Key, string Value)[] props)
|
||||
{
|
||||
return Node(id, "row", props);
|
||||
}
|
||||
|
||||
private static EveSurfaceComponent Node(
|
||||
string id,
|
||||
string kind,
|
||||
(string Key, string Value)[] props,
|
||||
params EveSurfaceComponent[] children)
|
||||
{
|
||||
return new EveSurfaceComponent(
|
||||
id,
|
||||
kind,
|
||||
props.ToDictionary(prop => prop.Key, prop => prop.Value),
|
||||
children);
|
||||
}
|
||||
|
||||
private static string SafeId(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? "empty"
|
||||
: new string(value.Select(character => char.IsLetterOrDigit(character) ? character : '.').ToArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaHangar
|
||||
{
|
||||
private static readonly SemaphoreSlim AdmissionGate = new(1, 1);
|
||||
|
||||
public static async Task<AetheriaDeploymentReceipt> AdmitAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDeploymentRequest request,
|
||||
string now)
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
|
||||
await AdmissionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
|
||||
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var loadout = string.IsNullOrWhiteSpace(request.LoadoutTemplateKey)
|
||||
? null
|
||||
: await node.MutableDocument<AetheriaLoadoutTemplate>(new(request.LoadoutTemplateKey))
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
var updated = Clone(hangar);
|
||||
var receipt = Admit(updated, request, loadout, now);
|
||||
if (!receipt.Accepted)
|
||||
return receipt;
|
||||
|
||||
await pointer.ReplaceAsync(updated).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return receipt;
|
||||
}
|
||||
finally
|
||||
{
|
||||
AdmissionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static AetheriaDeploymentReceipt Admit(
|
||||
AetheriaHangarState hangar,
|
||||
AetheriaDeploymentRequest request,
|
||||
AetheriaLoadoutTemplate? loadout,
|
||||
string now)
|
||||
{
|
||||
if (hangar == null) throw new ArgumentNullException(nameof(hangar));
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
|
||||
var prior = (hangar.Deployments ?? []).FirstOrDefault(value =>
|
||||
string.Equals(value.RequestId, request.RequestId, StringComparison.Ordinal));
|
||||
if (prior != null)
|
||||
return prior;
|
||||
|
||||
var rejection = Validate(hangar, request, loadout);
|
||||
if (rejection != null)
|
||||
return Receipt(hangar, request, loadout, now, false, rejection, hangar.Revision);
|
||||
|
||||
var ship = hangar.Ships.Single(value =>
|
||||
string.Equals(value.ShipId, request.ShipId, StringComparison.Ordinal));
|
||||
var revision = checked(hangar.Revision + 1);
|
||||
var receipt = Receipt(hangar, request, loadout, now, true, "", revision);
|
||||
|
||||
ship.Status = AetheriaHangarShipStatuses.Deployed;
|
||||
ship.ActiveDeploymentId = receipt.DeploymentId;
|
||||
ship.LoadoutTemplateKey = request.LoadoutTemplateKey;
|
||||
hangar.Revision = revision;
|
||||
hangar.UpdatedAtUtc = now;
|
||||
hangar.Deployments = (hangar.Deployments ?? []).Append(receipt).ToArray();
|
||||
return receipt;
|
||||
}
|
||||
|
||||
public static async Task<AetheriaHangarMutationResult> EquipAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
string itemKey,
|
||||
long expectedRevision,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now)
|
||||
{
|
||||
await AdmissionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
|
||||
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
|
||||
if (rejection != null) return new(false, rejection, hangar.Revision);
|
||||
var stack = (hangar.Inventory ?? []).SingleOrDefault(value =>
|
||||
string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal) && value.Quantity > 0);
|
||||
if (stack == null) return new(false, "item is not available in Hangar inventory", hangar.Revision);
|
||||
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
|
||||
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
|
||||
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
|
||||
|
||||
var destination = ToRuntimeEntity(template.RootEntity);
|
||||
var source = new AetheriaRuntimeEntitySnapshotCommit
|
||||
{
|
||||
Equipment =
|
||||
[
|
||||
new AetheriaRuntimeLoadoutItemSlotCommit
|
||||
{
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = itemKey,
|
||||
Quantity = 1,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
if (!AetheriaRuntimeRefitTransactions.TryEquip(
|
||||
source,
|
||||
AetheriaRuntimeRefitSourceKinds.Equipment,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
destination,
|
||||
itemKey,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
catalog,
|
||||
out rejection))
|
||||
return new(false, rejection, hangar.Revision);
|
||||
|
||||
var updatedHangar = Clone(hangar);
|
||||
var updatedStack = updatedHangar.Inventory.Single(value => string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal));
|
||||
updatedStack.Quantity--;
|
||||
updatedHangar.Inventory = updatedHangar.Inventory.Where(value => value.Quantity > 0).ToArray();
|
||||
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
|
||||
updatedHangar.UpdatedAtUtc = now;
|
||||
var updatedTemplate = AetheriaRuntimeStateMapper.ToLoadoutTemplate(
|
||||
new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = template.Name,
|
||||
OwnerPlayerKey = template.OwnerPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(destination)
|
||||
},
|
||||
now);
|
||||
updatedTemplate.CreatedAtUtc = template.CreatedAtUtc;
|
||||
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
|
||||
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new(true, "", updatedHangar.Revision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AdmissionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<AetheriaHangarMutationResult> RemoveAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
int equipmentIndex,
|
||||
long expectedRevision,
|
||||
string now)
|
||||
{
|
||||
await AdmissionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
|
||||
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
|
||||
if (rejection != null) return new(false, rejection, hangar.Revision);
|
||||
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
|
||||
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
|
||||
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
|
||||
var equipment = (template.RootEntity.Equipment ?? []).ToList();
|
||||
if (equipmentIndex < 0 || equipmentIndex >= equipment.Count)
|
||||
return new(false, "equipment index is outside the configured loadout", hangar.Revision);
|
||||
var removed = equipment[equipmentIndex];
|
||||
if (string.IsNullOrWhiteSpace(removed.Item?.ItemKey))
|
||||
return new(false, "configured equipment item is missing", hangar.Revision);
|
||||
equipment.RemoveAt(equipmentIndex);
|
||||
|
||||
var updatedTemplate = CloneTemplate(template);
|
||||
updatedTemplate.RootEntity.Equipment = equipment.ToArray();
|
||||
updatedTemplate.RootEntity.WeaponGroups = (updatedTemplate.RootEntity.WeaponGroups ?? [])
|
||||
.Select(group => (group ?? [])
|
||||
.Where(index => index != equipmentIndex)
|
||||
.Select(index => index > equipmentIndex ? index - 1 : index)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
updatedTemplate.UpdatedAtUtc = now;
|
||||
var updatedHangar = Clone(hangar);
|
||||
var stack = updatedHangar.Inventory.FirstOrDefault(value =>
|
||||
string.Equals(value.ItemKey, removed.Item.ItemKey, StringComparison.Ordinal));
|
||||
if (stack == null)
|
||||
updatedHangar.Inventory = updatedHangar.Inventory.Append(new AetheriaHangarItemStack
|
||||
{
|
||||
ItemKey = removed.Item.ItemKey,
|
||||
Quantity = 1
|
||||
}).ToArray();
|
||||
else
|
||||
stack.Quantity++;
|
||||
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
|
||||
updatedHangar.UpdatedAtUtc = now;
|
||||
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
|
||||
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new(true, "", updatedHangar.Revision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AdmissionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateRefit(
|
||||
AetheriaHangarState hangar,
|
||||
string shipId,
|
||||
long expectedRevision,
|
||||
out AetheriaHangarShip? ship)
|
||||
{
|
||||
ship = null;
|
||||
if (expectedRevision != hangar.Revision) return "hangar revision mismatch";
|
||||
ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
|
||||
if (ship == null) return "ship is not owned by Hangar";
|
||||
if (!string.Equals(ship.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal))
|
||||
return "deployed ship cannot be refit from the Hangar";
|
||||
if (string.IsNullOrWhiteSpace(ship.LoadoutTemplateKey)) return "ship has no configured loadout";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Validate(
|
||||
AetheriaHangarState hangar,
|
||||
AetheriaDeploymentRequest request,
|
||||
AetheriaLoadoutTemplate? loadout)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RequestId)) return "deployment request id is required";
|
||||
if (!AetheriaGameModes.IsKnown(request.Mode)) return "unknown game mode";
|
||||
if (!string.Equals(hangar.PlayerKey, request.PlayerKey, StringComparison.Ordinal)) return "player does not own hangar";
|
||||
if (request.ExpectedHangarRevision != hangar.Revision) return "hangar revision mismatch";
|
||||
if (!string.Equals(request.ModePolicyId, AetheriaModePolicies.ForMode(request.Mode), StringComparison.Ordinal))
|
||||
return "mode policy does not match game mode";
|
||||
|
||||
var ships = (hangar.Ships ?? []).Where(value =>
|
||||
string.Equals(value.ShipId, request.ShipId, StringComparison.Ordinal)).ToArray();
|
||||
if (ships.Length != 1) return "deployment ship is not uniquely owned by hangar";
|
||||
if (!string.Equals(ships[0].Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal))
|
||||
return "deployment ship is not available";
|
||||
if (!(hangar.LoadoutTemplateKeys ?? []).Contains(request.LoadoutTemplateKey, StringComparer.Ordinal))
|
||||
return "loadout template is not owned by hangar";
|
||||
if (loadout == null) return "loadout template is missing";
|
||||
if (!string.Equals(loadout.OwnerPlayerKey, request.PlayerKey, StringComparison.Ordinal))
|
||||
return "player does not own loadout template";
|
||||
if (!string.Equals(loadout.RootEntity.Hull.ItemKey, ships[0].HullItemKey, StringComparison.Ordinal))
|
||||
return "loadout hull does not match deployed ship";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AetheriaDeploymentReceipt Receipt(
|
||||
AetheriaHangarState hangar,
|
||||
AetheriaDeploymentRequest request,
|
||||
AetheriaLoadoutTemplate? loadout,
|
||||
string now,
|
||||
bool accepted,
|
||||
string diagnostic,
|
||||
long revision) => new()
|
||||
{
|
||||
DeploymentId = accepted ? $"deployment:{hangar.HangarId}:{revision}" : "",
|
||||
RequestId = request.RequestId,
|
||||
Accepted = accepted,
|
||||
Diagnostic = diagnostic,
|
||||
PlayerKey = request.PlayerKey,
|
||||
Mode = request.Mode,
|
||||
ShipId = request.ShipId,
|
||||
LoadoutTemplateKey = request.LoadoutTemplateKey,
|
||||
HangarRevision = revision,
|
||||
ModePolicyId = request.ModePolicyId,
|
||||
Loadout = accepted ? Clone(loadout!.RootEntity) : new AetheriaRuntimeEntityLoadoutCommit(),
|
||||
CommittedAtUtc = now
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit Clone(AetheriaEntityLoadout source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
FactionKey = source.FactionKey,
|
||||
Hull = Clone(source.Hull),
|
||||
Equipment = (source.Equipment ?? []).Select(Clone).ToArray(),
|
||||
CargoBays = (source.CargoBays ?? []).Select(Clone).ToArray(),
|
||||
DockingBays = (source.DockingBays ?? []).Select(Clone).ToArray(),
|
||||
CargoContents = (source.CargoContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayContents = (source.DockingBayContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayAssignments = (source.DockingBayAssignments ?? []).ToArray(),
|
||||
WeaponGroups = (source.WeaponGroups ?? []).Select(group => (group ?? []).ToArray()).ToArray(),
|
||||
Children = (source.Children ?? []).Select(Clone).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaHangarState Clone(AetheriaHangarState source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
HangarId = source.HangarId,
|
||||
PlayerKey = source.PlayerKey,
|
||||
Revision = source.Revision,
|
||||
Ships = (source.Ships ?? []).Select(ship => new AetheriaHangarShip
|
||||
{
|
||||
ShipId = ship.ShipId,
|
||||
HullItemKey = ship.HullItemKey,
|
||||
LoadoutTemplateKey = ship.LoadoutTemplateKey,
|
||||
Status = ship.Status,
|
||||
ActiveDeploymentId = ship.ActiveDeploymentId
|
||||
}).ToArray(),
|
||||
Inventory = (source.Inventory ?? []).Select(item => new AetheriaHangarItemStack
|
||||
{
|
||||
ItemKey = item.ItemKey,
|
||||
Quantity = item.Quantity
|
||||
}).ToArray(),
|
||||
Currencies = (source.Currencies ?? []).Select(currency => new AetheriaHangarCurrency
|
||||
{
|
||||
CurrencyKey = currency.CurrencyKey,
|
||||
Quantity = currency.Quantity
|
||||
}).ToArray(),
|
||||
UnlockKeys = (source.UnlockKeys ?? []).ToArray(),
|
||||
LoadoutTemplateKeys = (source.LoadoutTemplateKeys ?? []).ToArray(),
|
||||
Deployments = (source.Deployments ?? []).ToArray(),
|
||||
UpdatedAtUtc = source.UpdatedAtUtc
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit Clone(AetheriaLoadoutItemSlot source) => new()
|
||||
{
|
||||
X = source.Position.X,
|
||||
Y = source.Position.Y,
|
||||
Rotation = source.Rotation,
|
||||
Item = Clone(source.Item)
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeCargoBayLoadoutCommit Clone(AetheriaCargoBayLoadout source) => new()
|
||||
{
|
||||
Items = (source.Items ?? []).Select(Clone).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemCommit Clone(AetheriaLoadoutItem source) => new()
|
||||
{
|
||||
ItemKey = source.ItemKey,
|
||||
Quality = source.Quality,
|
||||
Durability = source.Durability,
|
||||
Quantity = source.Quantity,
|
||||
Enabled = source.Enabled,
|
||||
OverrideShutdown = source.OverrideShutdown,
|
||||
Temperature = source.Temperature
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(AetheriaEntityLoadout source)
|
||||
{
|
||||
var loadout = ToRuntimeLoadout(source);
|
||||
return new AetheriaRuntimeEntitySnapshotCommit
|
||||
{
|
||||
Name = loadout.Name,
|
||||
Kind = loadout.Kind,
|
||||
FactionKey = loadout.FactionKey,
|
||||
HullItemKey = loadout.Hull.ItemKey,
|
||||
Equipment = loadout.Equipment,
|
||||
CargoBays = loadout.CargoBays,
|
||||
DockingBays = loadout.DockingBays,
|
||||
CargoContents = loadout.CargoContents,
|
||||
DockingBayContents = loadout.DockingBayContents,
|
||||
DockingBayAssignments = loadout.DockingBayAssignments,
|
||||
WeaponGroups = loadout.WeaponGroups
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaRuntimeEntitySnapshotCommit source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
FactionKey = source.FactionKey,
|
||||
Hull = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = source.HullItemKey,
|
||||
Quantity = 1,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Enabled = true
|
||||
},
|
||||
Equipment = source.Equipment ?? [],
|
||||
CargoBays = source.CargoBays ?? [],
|
||||
DockingBays = source.DockingBays ?? [],
|
||||
CargoContents = source.CargoContents ?? [],
|
||||
DockingBayContents = source.DockingBayContents ?? [],
|
||||
DockingBayAssignments = source.DockingBayAssignments ?? [],
|
||||
WeaponGroups = source.WeaponGroups ?? []
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaEntityLoadout source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
FactionKey = source.FactionKey,
|
||||
Hull = Clone(source.Hull),
|
||||
Equipment = (source.Equipment ?? []).Select(Clone).ToArray(),
|
||||
CargoBays = (source.CargoBays ?? []).Select(Clone).ToArray(),
|
||||
DockingBays = (source.DockingBays ?? []).Select(Clone).ToArray(),
|
||||
CargoContents = (source.CargoContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayContents = (source.DockingBayContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayAssignments = (source.DockingBayAssignments ?? []).ToArray(),
|
||||
WeaponGroups = (source.WeaponGroups ?? []).Select(group => (IReadOnlyList<int>)(group ?? []).ToArray()).ToArray(),
|
||||
Children = (source.Children ?? []).Select(ToRuntimeLoadout).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaLoadoutTemplate CloneTemplate(AetheriaLoadoutTemplate source)
|
||||
{
|
||||
var clone = AetheriaRuntimeStateMapper.ToLoadoutTemplate(new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = source.Name,
|
||||
OwnerPlayerKey = source.OwnerPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(source.RootEntity)
|
||||
}, source.UpdatedAtUtc);
|
||||
clone.CreatedAtUtc = source.CreatedAtUtc;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AetheriaHangarMutationResult
|
||||
{
|
||||
public AetheriaHangarMutationResult(bool accepted, string diagnostic, long hangarRevision)
|
||||
{
|
||||
Accepted = accepted;
|
||||
Diagnostic = diagnostic;
|
||||
HangarRevision = hangarRevision;
|
||||
}
|
||||
|
||||
public bool Accepted { get; }
|
||||
public string Diagnostic { get; }
|
||||
public long HangarRevision { get; }
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
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<AetheriaItemRarityTier>())
|
||||
.Select(tier => new AetheriaRuntimeItemRarityTier(
|
||||
tier.Name,
|
||||
tier.Quality,
|
||||
tier.Red,
|
||||
tier.Green,
|
||||
tier.Blue))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public static AetheriaLoadoutTemplate ToLoadoutTemplate(
|
||||
AetheriaRuntimeLoadoutTemplateCommit loadout,
|
||||
string updatedAtUtc)
|
||||
{
|
||||
return new AetheriaLoadoutTemplate
|
||||
{
|
||||
Name = loadout.Name ?? "",
|
||||
OwnerPlayerKey = loadout.OwnerPlayerKey ?? "",
|
||||
RootEntity = ToEntityLoadout(loadout.RootEntity),
|
||||
CreatedAtUtc = updatedAtUtc,
|
||||
UpdatedAtUtc = updatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static AetheriaRuntimeLoadoutTemplateCommit ToRuntimeLoadoutTemplate(
|
||||
AetheriaLoadoutTemplate loadout)
|
||||
{
|
||||
if (loadout == null) throw new ArgumentNullException(nameof(loadout));
|
||||
return new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = loadout.Name,
|
||||
OwnerPlayerKey = loadout.OwnerPlayerKey,
|
||||
RootEntity = ToRuntimeEntityLoadout(loadout.RootEntity)
|
||||
};
|
||||
}
|
||||
|
||||
public static CultRecordKey LoadoutKey(string name)
|
||||
{
|
||||
return new CultRecordKey($"global:aetheria.loadout_template.{StableToken(name)}.v1");
|
||||
}
|
||||
|
||||
private static AetheriaEntityLoadout ToEntityLoadout(AetheriaRuntimeEntityLoadoutCommit? entity)
|
||||
{
|
||||
entity ??= new AetheriaRuntimeEntityLoadoutCommit();
|
||||
return new AetheriaEntityLoadout
|
||||
{
|
||||
Name = entity.Name ?? "",
|
||||
Kind = entity.Kind ?? "",
|
||||
FactionKey = entity.FactionKey ?? "",
|
||||
Hull = ToLoadoutItem(entity.Hull),
|
||||
Equipment = ToItemSlots(entity.Equipment),
|
||||
CargoBays = ToItemSlots(entity.CargoBays),
|
||||
DockingBays = ToItemSlots(entity.DockingBays),
|
||||
CargoContents = ToCargoBays(entity.CargoContents),
|
||||
DockingBayContents = ToCargoBays(entity.DockingBayContents),
|
||||
DockingBayAssignments = ToIntArray(entity.DockingBayAssignments),
|
||||
WeaponGroups = ToIntArrayArray(entity.WeaponGroups),
|
||||
Children = (entity.Children ?? Array.Empty<AetheriaRuntimeEntityLoadoutCommit>())
|
||||
.Select(ToEntityLoadout)
|
||||
.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeEntityLoadout(AetheriaEntityLoadout entity) => new()
|
||||
{
|
||||
Name = entity.Name,
|
||||
Kind = entity.Kind,
|
||||
FactionKey = entity.FactionKey,
|
||||
Hull = ToRuntimeLoadoutItem(entity.Hull),
|
||||
Equipment = (entity.Equipment ?? []).Select(ToRuntimeItemSlot).ToArray(),
|
||||
CargoBays = (entity.CargoBays ?? []).Select(ToRuntimeItemSlot).ToArray(),
|
||||
DockingBays = (entity.DockingBays ?? []).Select(ToRuntimeItemSlot).ToArray(),
|
||||
CargoContents = (entity.CargoContents ?? []).Select(value => new AetheriaRuntimeCargoBayLoadoutCommit
|
||||
{
|
||||
Items = (value.Items ?? []).Select(ToRuntimeItemSlot).ToArray()
|
||||
}).ToArray(),
|
||||
DockingBayContents = (entity.DockingBayContents ?? []).Select(value => new AetheriaRuntimeCargoBayLoadoutCommit
|
||||
{
|
||||
Items = (value.Items ?? []).Select(ToRuntimeItemSlot).ToArray()
|
||||
}).ToArray(),
|
||||
DockingBayAssignments = (entity.DockingBayAssignments ?? []).ToArray(),
|
||||
WeaponGroups = (entity.WeaponGroups ?? []).Select(value => (IReadOnlyList<int>)(value ?? []).ToArray()).ToArray(),
|
||||
Children = (entity.Children ?? []).Select(ToRuntimeEntityLoadout).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeItemSlot(AetheriaLoadoutItemSlot slot) => new()
|
||||
{
|
||||
X = slot.Position?.X ?? 0,
|
||||
Y = slot.Position?.Y ?? 0,
|
||||
Rotation = slot.Rotation,
|
||||
Item = ToRuntimeLoadoutItem(slot.Item)
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemCommit ToRuntimeLoadoutItem(AetheriaLoadoutItem item) => new()
|
||||
{
|
||||
ItemKey = item.ItemKey,
|
||||
Quality = item.Quality,
|
||||
Durability = item.Durability,
|
||||
Quantity = item.Quantity,
|
||||
Enabled = item.Enabled,
|
||||
OverrideShutdown = item.OverrideShutdown,
|
||||
Temperature = item.Temperature
|
||||
};
|
||||
|
||||
private static AetheriaLoadoutItem ToLoadoutItem(AetheriaRuntimeLoadoutItemCommit? item)
|
||||
{
|
||||
item ??= new AetheriaRuntimeLoadoutItemCommit();
|
||||
return new AetheriaLoadoutItem
|
||||
{
|
||||
ItemKey = item.ItemKey ?? "",
|
||||
Quality = item.Quality,
|
||||
Durability = item.Durability,
|
||||
Quantity = item.Quantity,
|
||||
Enabled = item.Enabled,
|
||||
OverrideShutdown = item.OverrideShutdown,
|
||||
Temperature = item.Temperature
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaLoadoutItemSlot[] ToItemSlots(
|
||||
IReadOnlyList<AetheriaRuntimeLoadoutItemSlotCommit>? slots)
|
||||
{
|
||||
return (slots ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
|
||||
.Select(slot => new AetheriaLoadoutItemSlot
|
||||
{
|
||||
Position = new AetheriaGridCoord
|
||||
{
|
||||
X = slot.X,
|
||||
Y = slot.Y
|
||||
},
|
||||
Rotation = slot.Rotation ?? "None",
|
||||
Item = ToLoadoutItem(slot.Item)
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaCargoBayLoadout[] ToCargoBays(
|
||||
IReadOnlyList<AetheriaRuntimeCargoBayLoadoutCommit>? cargoBays)
|
||||
{
|
||||
return (cargoBays ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
|
||||
.Select(bay => new AetheriaCargoBayLoadout
|
||||
{
|
||||
Items = ToItemSlots(bay.Items)
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static int[] ToIntArray(IReadOnlyList<int>? values)
|
||||
{
|
||||
return (values ?? Array.Empty<int>()).ToArray();
|
||||
}
|
||||
|
||||
private static int[][] ToIntArrayArray(IReadOnlyList<IReadOnlyList<int>>? values)
|
||||
{
|
||||
return (values ?? Array.Empty<IReadOnlyList<int>>())
|
||||
.Select(ToIntArray)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string StableToken(string value)
|
||||
{
|
||||
var chars = (string.IsNullOrWhiteSpace(value) ? "unnamed" : value.Trim().ToLowerInvariant())
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray();
|
||||
var token = new string(chars).Trim('-');
|
||||
while (token.Contains("--", StringComparison.Ordinal))
|
||||
token = token.Replace("--", "-", StringComparison.Ordinal);
|
||||
return string.IsNullOrWhiteSpace(token) ? "unnamed" : token;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
using R3;
|
||||
using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument;
|
||||
using EveSurfaceCommandRequest = GameCult.Eve.Surface.EveSurfaceCommandRequest;
|
||||
using EveCommandReceiptDocument = GameCult.Eve.Surface.EveCommandReceiptDocument;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public enum AetheriaStateHydrationProfile
|
||||
{
|
||||
All,
|
||||
DaemonBoot
|
||||
}
|
||||
|
||||
public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private static readonly string[] DaemonNativeCatalogBootRecordKeys =
|
||||
[
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId("82efc0a5-1ba5-4ff3-a281-b2e6e247521d").ToString(), // Zenith hull
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId("3e930a2c-ac72-4385-98aa-1c5b0b90db46").ToString(), // Medium docking bay
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId("ca098005-8cc8-47f4-be99-7bc842805359").ToString(), // Dockyard hull
|
||||
AetheriaCatalogKeys.ItemDefinitionFromLegacyId("8ec30f8d-8536-48b4-bd64-65f29f229895").ToString() // Dockyard berth
|
||||
];
|
||||
private readonly CultMeshNode _node;
|
||||
private CultMeshDocumentHandle<AetheriaRuntimeCatalogSnapshot>? _runtimeCatalog;
|
||||
private CultMeshDocumentHandle<AetheriaRuntimeNameCorpusSnapshot>? _runtimeNameCorpus;
|
||||
private CultMeshDocumentHandle<EveSurfaceDocument>? _catalogSurface;
|
||||
|
||||
private AetheriaStateNode(string statePath, string runtimeId, CultMeshNode node)
|
||||
{
|
||||
StatePath = statePath;
|
||||
RuntimeId = string.IsNullOrWhiteSpace(runtimeId)
|
||||
? "aetheria-local"
|
||||
: runtimeId;
|
||||
_node = node;
|
||||
}
|
||||
|
||||
public string StatePath { get; }
|
||||
|
||||
public string RuntimeId { get; }
|
||||
|
||||
public CultMeshNode MeshNode => _node;
|
||||
|
||||
public CultCache Cache => _node.Cache;
|
||||
|
||||
public CultNetDatabase Database => _node.Database;
|
||||
|
||||
public static async Task<AetheriaStateNode> OpenAsync(
|
||||
string statePath,
|
||||
string runtimeId = "aetheria-local",
|
||||
bool startServer = false,
|
||||
bool enableDurableShardLogs = true,
|
||||
bool useDirectoryStore = true,
|
||||
AetheriaStateHydrationProfile hydrationProfile = AetheriaStateHydrationProfile.All)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statePath))
|
||||
{
|
||||
throw new ArgumentException("State path must be non-empty.", nameof(statePath));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(statePath)) ?? ".");
|
||||
|
||||
var traceStartup = string.Equals(
|
||||
Environment.GetEnvironmentVariable("AETHERIA_TRACE_STARTUP_PHASES"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
var startupPhase = Stopwatch.StartNew();
|
||||
void Trace(string phase)
|
||||
{
|
||||
if (traceStartup)
|
||||
Console.WriteLine($"Aetheria state-node phase {phase} took {startupPhase.Elapsed.TotalMilliseconds:0.###}ms.");
|
||||
startupPhase.Restart();
|
||||
}
|
||||
|
||||
var cacheRegistry = AetheriaDocumentRegistry.CreateCultCacheRegistry();
|
||||
Trace("cache-registry");
|
||||
var hydrationFilter = CreateHydrationFilter(hydrationProfile, cacheRegistry);
|
||||
Trace("hydration-filter");
|
||||
var databaseRegistry = AetheriaDocumentRegistry.CreateCultNetRegistry(cacheRegistry);
|
||||
Trace("database-registry");
|
||||
var node = await CultMesh.CreateNodeAsync(
|
||||
statePath,
|
||||
new CultMeshNodeOptions
|
||||
{
|
||||
StartServer = startServer,
|
||||
EnableDurableShardLogs = enableDurableShardLogs,
|
||||
CacheOptions = new CultCacheOpenOptions
|
||||
{
|
||||
Registry = cacheRegistry,
|
||||
PullOnOpen = true,
|
||||
StoreFlushOnDispose = true,
|
||||
UseDirectoryStore = useDirectoryStore,
|
||||
DirectoryStoreHydrationFilter = hydrationFilter
|
||||
},
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = runtimeId,
|
||||
DocumentRegistry = databaseRegistry
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
Trace("cultmesh-node");
|
||||
|
||||
return new AetheriaStateNode(statePath, runtimeId, node);
|
||||
}
|
||||
|
||||
private static Func<CultPersistedRecordMetadata, bool>? CreateHydrationFilter(
|
||||
AetheriaStateHydrationProfile profile,
|
||||
CultDocumentRegistry registry)
|
||||
{
|
||||
if (profile == AetheriaStateHydrationProfile.All)
|
||||
return null;
|
||||
if (profile != AetheriaStateHydrationProfile.DaemonBoot)
|
||||
throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown Aetheria state hydration profile.");
|
||||
|
||||
var schemaTypes = new[]
|
||||
{
|
||||
typeof(AetheriaLoadoutTemplate),
|
||||
typeof(AetheriaRuntimeAuthorityLeaseDocument),
|
||||
typeof(AetheriaRuntimeDaemonCommandDocument),
|
||||
typeof(AetheriaRuntimeCommittedCommandFactDocument),
|
||||
typeof(AetheriaRuntimeEveCommandDocument),
|
||||
typeof(EveSurfaceCommandRequest),
|
||||
typeof(EveCommandReceiptDocument)
|
||||
};
|
||||
var schemaIds = schemaTypes
|
||||
.Select(registry.GetRequired)
|
||||
.SelectMany(descriptor => descriptor.ToCatalogEntry().CompatibleSchemaIds.Append(descriptor.SchemaId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var recordKeys = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
WorldKey.ToString(),
|
||||
RuntimeCatalogKey.ToString(),
|
||||
TradeValuePolicyKey.ToString(),
|
||||
PlayerSettingsKey.ToString(),
|
||||
HangarKey.ToString(),
|
||||
VerseHostSettingsKey.ToString(),
|
||||
GameSessionStateKey.ToString(),
|
||||
MainMenuStateKey.ToString(),
|
||||
EveCommandAcceptanceStatusKey.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonProviderAdvertisement.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.EveProviderAdvertisement.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonAssetManifest.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.EveAssetCatalog.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.PilotInputCapability.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonGameSurface.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.MapMenuSurface.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.VerseAuthorityPolicy.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.StarbridgeSessionLatest.ToString()
|
||||
};
|
||||
recordKeys.UnionWith(DaemonNativeCatalogBootRecordKeys);
|
||||
return record => recordKeys.Contains(record.Key) || schemaIds.Contains(record.SchemaId);
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
return CultMesh.Document<TDocument>(
|
||||
key.ToString(),
|
||||
CultMesh.Verse("aetheria.local", RuntimeId),
|
||||
async _ => (await Database.GetAsync<TDocument>(key).ConfigureAwait(false))!,
|
||||
_ => Database.WatchRecord<TDocument>(key)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
sources: new[]
|
||||
{
|
||||
CultMesh.ProjectionSource(key.ToString())
|
||||
},
|
||||
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed state node document"));
|
||||
}
|
||||
|
||||
public CultMeshMutableStatePointer<TDocument> MutableDocument<TDocument>(CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
return MutableDocumentPointer<TDocument>(key);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TDocument> Documents<TDocument>()
|
||||
where TDocument : class
|
||||
{
|
||||
return Cache.GetAll<TDocument>().ToArray();
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<AetheriaRuntimeCatalogSnapshot> RuntimeCatalog()
|
||||
{
|
||||
return _runtimeCatalog ??= CultMesh.Document(
|
||||
"aetheria.catalog.runtime",
|
||||
CultMesh.Verse("aetheria.local", RuntimeId),
|
||||
async _ => await Database.GetAsync<AetheriaRuntimeCatalogSnapshot>(RuntimeCatalogKey)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidDataException(
|
||||
"The compiled Aetheria runtime catalog is missing. Refresh it after changing source catalog records."),
|
||||
_ => Database.WatchRecord<AetheriaRuntimeCatalogSnapshot>(RuntimeCatalogKey)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
sources: new[]
|
||||
{
|
||||
CultMesh.ProjectionSource("catalog:aetheria.runtime"),
|
||||
CultMesh.ProjectionSource(
|
||||
"aetheria.catalog.runtime",
|
||||
AetheriaRuntimeCatalogSnapshot.SchemaId,
|
||||
"managed Aetheria runtime catalog document")
|
||||
},
|
||||
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed catalog state"));
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<AetheriaRuntimeNameCorpusSnapshot> RuntimeNameCorpus()
|
||||
{
|
||||
return _runtimeNameCorpus ??= CultMesh.Document(
|
||||
"aetheria.catalog.runtime-names",
|
||||
CultMesh.Verse("aetheria.local", RuntimeId),
|
||||
async _ => await Database.GetAsync<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("The compiled Aetheria runtime name corpus is missing."),
|
||||
_ => Database.WatchRecord<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
sources: new[]
|
||||
{
|
||||
CultMesh.ProjectionSource(
|
||||
RuntimeNameCorpusKey.ToString(),
|
||||
AetheriaRuntimeNameCorpusSnapshot.SchemaId,
|
||||
"managed Aetheria runtime name corpus")
|
||||
},
|
||||
routeHint: new CultMeshRouteHint(
|
||||
CultMeshLocalityKind.SharedMemory,
|
||||
"Aetheria typed name generation corpus"));
|
||||
}
|
||||
|
||||
public async Task<AetheriaRuntimeCatalogSnapshot> RuntimeCatalogForGenerationAsync()
|
||||
{
|
||||
var catalog = RuntimeCatalog().Latest()
|
||||
?? throw new InvalidDataException("The compiled Aetheria runtime catalog is missing.");
|
||||
if (Cache.Get<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey) == null)
|
||||
{
|
||||
await Cache.PullBackingStoreRecordsAsync(metadata =>
|
||||
string.Equals(metadata.Key, RuntimeNameCorpusKey.ToString(), StringComparison.Ordinal)).ConfigureAwait(false);
|
||||
}
|
||||
var corpus = await RuntimeNameCorpus().LatestAsync().ConfigureAwait(false);
|
||||
return new AetheriaRuntimeCatalogSnapshot(
|
||||
catalog.Items.ToArray(),
|
||||
catalog.Corporations.ToArray(),
|
||||
corpus.NameFiles.ToArray(),
|
||||
catalog.TradeValueSettings)
|
||||
{
|
||||
CatalogId = catalog.CatalogId,
|
||||
NameCorpusRecordKey = RuntimeNameCorpusKey.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<EveSurfaceDocument> CatalogSurface()
|
||||
{
|
||||
var catalog = RuntimeCatalog();
|
||||
return _catalogSurface ??= CultMesh.Document(
|
||||
"aetheria.catalog.surface",
|
||||
CultMesh.Verse("aetheria.local", RuntimeId),
|
||||
_ => Task.FromResult(AetheriaEveSurfaceDocuments.BuildCatalogSurface(
|
||||
catalog.Latest(),
|
||||
DateTimeOffset.UtcNow.ToString("O"))),
|
||||
_ => catalog.Watch()
|
||||
.Select(snapshot => AetheriaEveSurfaceDocuments.BuildCatalogSurface(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.ToString("O"))),
|
||||
sources: new[]
|
||||
{
|
||||
CultMesh.ProjectionSource(
|
||||
"aetheria.catalog.runtime",
|
||||
AetheriaRuntimeCatalogSnapshot.SchemaId,
|
||||
"managed Aetheria runtime catalog document"),
|
||||
CultMesh.ProjectionSource(
|
||||
CatalogSurfaceKey.ToString(),
|
||||
"gamecult.eve.surface.v1",
|
||||
"managed Aetheria catalog Eve surface document")
|
||||
},
|
||||
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed catalog Eve surface"));
|
||||
}
|
||||
|
||||
public async Task<AetheriaRuntimeCatalogSnapshot> RefreshRuntimeCatalogAsync()
|
||||
{
|
||||
var snapshot = AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath);
|
||||
await Database.PutAsync(
|
||||
RuntimeNameCorpusKey,
|
||||
new AetheriaRuntimeNameCorpusSnapshot
|
||||
{
|
||||
NameFiles = snapshot.NameFiles.ToArray()
|
||||
}).ConfigureAwait(false);
|
||||
snapshot.NameFiles = snapshot.NameFiles
|
||||
.Select(nameFile => new AetheriaRuntimeNameFile(
|
||||
nameFile.NameFileKey,
|
||||
nameFile.Name,
|
||||
nameFile.NameCount,
|
||||
nameFile.SampleNames.ToArray(),
|
||||
Array.Empty<string>()))
|
||||
.ToArray();
|
||||
snapshot.NameCorpusRecordKey = RuntimeNameCorpusKey.ToString();
|
||||
await Database.PutAsync(RuntimeCatalogKey, snapshot).ConfigureAwait(false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public Task<CultRecordHandle<AetheriaRuntimeDaemonCommandDocument>> SubmitDaemonCommandAsync(
|
||||
AetheriaRuntimeDaemonCommandDocument command)
|
||||
{
|
||||
if (command == null) throw new ArgumentNullException(nameof(command));
|
||||
command.Schema = AetheriaRuntimeDaemonSchemas.Command;
|
||||
if (string.IsNullOrWhiteSpace(command.CommandId))
|
||||
command.CommandId = Guid.NewGuid().ToString("N");
|
||||
if (string.IsNullOrWhiteSpace(command.IssuedAtUtc))
|
||||
command.IssuedAtUtc = DateTime.UtcNow.ToString("O");
|
||||
|
||||
return Database.PutAsync(DaemonCommandKey(command.CommandId), command);
|
||||
}
|
||||
|
||||
public Task<CultRecordHandle<AetheriaRuntimeCommittedCommandFactDocument>> 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);
|
||||
}
|
||||
|
||||
private static CultRecordKey DaemonCommandKey(string commandId)
|
||||
{
|
||||
return new CultRecordKey($"daemon:commands:{StableToken(commandId)}:gamecult.aetheria.daemon_command.v1");
|
||||
}
|
||||
|
||||
public Task<CultRecordHandle<AetheriaRuntimeEveCommandDocument>> SubmitEveCommandAsync(
|
||||
AetheriaRuntimeEveCommandDocument command)
|
||||
{
|
||||
if (command == null) throw new ArgumentNullException(nameof(command));
|
||||
AetheriaRuntimeEveCommandClient.NormalizeDocument(command);
|
||||
command.Schema = AetheriaRuntimeEveCommandDocument.SchemaId;
|
||||
if (string.IsNullOrWhiteSpace(command.CommandId))
|
||||
command.CommandId = Guid.NewGuid().ToString("N");
|
||||
if (string.IsNullOrWhiteSpace(command.IssuedAtUtc))
|
||||
command.IssuedAtUtc = DateTime.UtcNow.ToString("O");
|
||||
|
||||
return Database.PutAsync(EveCommandKey(command.CommandId), command);
|
||||
}
|
||||
|
||||
private static CultRecordKey EveCommandKey(string commandId)
|
||||
{
|
||||
return new CultRecordKey($"eve:commands:{StableToken(commandId)}:gamecult.eve.command.v1");
|
||||
}
|
||||
|
||||
public Task FlushAsync(bool soft = false)
|
||||
{
|
||||
return _node.FlushAsync(soft);
|
||||
}
|
||||
|
||||
private static string StableToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "empty";
|
||||
|
||||
var chars = value
|
||||
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
|
||||
.ToArray();
|
||||
var token = new string(chars).Trim('-').ToLowerInvariant();
|
||||
while (token.Contains("--", StringComparison.Ordinal))
|
||||
token = token.Replace("--", "-", StringComparison.Ordinal);
|
||||
return string.IsNullOrWhiteSpace(token) ? "empty" : token;
|
||||
}
|
||||
|
||||
public static CultRecordKey RuntimeSessionKey(string runtimeId)
|
||||
{
|
||||
return new CultRecordKey($"runtime:{runtimeId}:aetheria.runtime_session.v1");
|
||||
}
|
||||
|
||||
public static CultRecordKey WorldKey { get; } =
|
||||
new("global:aetheria.world_state.v1");
|
||||
|
||||
public static CultRecordKey MigrationLedgerKey { get; } =
|
||||
new("global:aetheria.migration_ledger.v1");
|
||||
|
||||
public static CultRecordKey LegacyCatalogQuarantineKey { get; } =
|
||||
new("global:aetheria.legacy_catalog_quarantine.v1");
|
||||
|
||||
public static CultRecordKey CatalogSurfaceKey { get; } =
|
||||
new(AetheriaEveSurfaceDocuments.CatalogSurfaceKey);
|
||||
|
||||
public static CultRecordKey RuntimeCatalogKey { get; } =
|
||||
new("global:aetheria.runtime_catalog.v1");
|
||||
|
||||
public static CultRecordKey RuntimeNameCorpusKey { get; } =
|
||||
new("global:aetheria.runtime_name_corpus.v1");
|
||||
|
||||
public static CultRecordKey OperationsSurfaceKey { get; } =
|
||||
new(AetheriaEveSurfaceDocuments.OperationsSurfaceKey);
|
||||
|
||||
public static CultRecordKey PlayerSettingsSurfaceKey { get; } =
|
||||
new(AetheriaEveSurfaceDocuments.PlayerSettingsSurfaceKey);
|
||||
|
||||
public static CultRecordKey ProviderAdvertisementSurfaceKey { get; } =
|
||||
new(AetheriaEveSurfaceDocuments.ProviderAdvertisementKey);
|
||||
|
||||
public static CultRecordKey PlayerSettingsKey { get; } =
|
||||
new("global:aetheria.player_settings.v1");
|
||||
|
||||
public static CultRecordKey HangarKey { get; } =
|
||||
new("global:gamecult.aetheria.hangar.v1");
|
||||
|
||||
public static CultRecordKey VerseHostSettingsKey { get; } =
|
||||
new("global:aetheria.verse_host_settings.v1");
|
||||
|
||||
public static CultRecordKey EveCommandAcceptanceStatusKey { get; } =
|
||||
new("global:aetheria.eve_command_acceptance_status.v1");
|
||||
|
||||
public static CultRecordKey MainMenuStateKey { get; } =
|
||||
new("global:aetheria.main_menu_state.v1");
|
||||
|
||||
public static CultRecordKey GameSessionStateKey { get; } =
|
||||
new("global:aetheria.game_session.v1");
|
||||
|
||||
public static CultRecordKey TradeValuePolicyKey { get; } =
|
||||
new(AetheriaTradeValuePolicy.RecordKey);
|
||||
|
||||
private CultMeshMutableStatePointer<T> MutableDocumentPointer<T>(CultRecordKey key) where T : class
|
||||
{
|
||||
return CultMesh.MutableStatePointer(
|
||||
key.ToString(),
|
||||
_ => Database.GetAsync<T>(key),
|
||||
_ => Database.WatchRecord<T>(key)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
async (_, value) => { await Database.PutAsync(key, value).ConfigureAwait(false); },
|
||||
sources:
|
||||
[
|
||||
CultMesh.ProjectionSource(key.ToString())
|
||||
]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_node.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await FlushAsync().ConfigureAwait(false);
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
using System.IO;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaStatePaths
|
||||
{
|
||||
public const string DefaultStateFileName = "aetheria-world.cc";
|
||||
|
||||
public static string ResolveDefaultStatePath(string? projectRoot = null)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(projectRoot)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: projectRoot!;
|
||||
return Path.Combine(root, "GameData", DefaultStateFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Mesh;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaVerseCatalogDocuments
|
||||
{
|
||||
private const string TransportVersion = "cultmesh.v0";
|
||||
private const string RulesEpoch = "aetheria-runtime-state.v1";
|
||||
|
||||
public static CultMeshVerseDescriptor Build(AetheriaVerseHostSettings? settings)
|
||||
{
|
||||
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
|
||||
var discoveryEndpoint = BuildDiscoveryEndpoint(normalized);
|
||||
var rulesHash = CultMeshVerseDescriptor.ComputeRulesHash(
|
||||
"aetheria",
|
||||
RulesEpoch,
|
||||
normalized.RootVerse,
|
||||
normalized.CanonicalService);
|
||||
|
||||
return new CultMeshVerseDescriptor(
|
||||
normalized.VerseId,
|
||||
normalized.Title,
|
||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility(TransportVersion, rulesHash),
|
||||
discoveryEndpoints: new[] { discoveryEndpoint },
|
||||
authorityRuntimeIds: new[] { normalized.ServiceId },
|
||||
parentVerseId: normalized.RootVerse,
|
||||
description: BuildDescription(normalized));
|
||||
}
|
||||
|
||||
public static string BuildDiscoveryEndpoint(AetheriaVerseHostSettings? settings)
|
||||
{
|
||||
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
|
||||
var host = normalized.LocatedService;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
host = normalized.CanonicalService;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
host = "localhost";
|
||||
|
||||
host = host.Trim();
|
||||
var slashIndex = host.IndexOf('/', StringComparison.Ordinal);
|
||||
if (slashIndex >= 0)
|
||||
host = host.Substring(0, slashIndex);
|
||||
|
||||
return $"cultnet://{host}:3075";
|
||||
}
|
||||
|
||||
private static string BuildDescription(AetheriaVerseHostSettings settings)
|
||||
{
|
||||
var visibility = string.IsNullOrWhiteSpace(settings.Visibility) ? "private" : settings.Visibility;
|
||||
return $"{settings.Title} Verse host ({visibility})";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Mesh;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public sealed class AetheriaVerseDiscoveryHost : IDisposable
|
||||
{
|
||||
private readonly AetheriaStateNode _node;
|
||||
private CultMeshVerseCatalog? _catalog;
|
||||
private CultMeshVerseDiscoveryServer? _server;
|
||||
private string _fingerprint = "";
|
||||
|
||||
public AetheriaVerseDiscoveryHost(AetheriaStateNode node)
|
||||
{
|
||||
_node = node ?? throw new ArgumentNullException(nameof(node));
|
||||
}
|
||||
|
||||
public void Update(AetheriaVerseHostSettings? settings)
|
||||
{
|
||||
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
|
||||
if (!string.Equals(normalized.Visibility, "public", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
DisposeServer();
|
||||
return;
|
||||
}
|
||||
|
||||
var nextFingerprint = CreateFingerprint(normalized);
|
||||
if (string.Equals(_fingerprint, nextFingerprint, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
DisposeServer();
|
||||
|
||||
_catalog = CultMesh.CreateVerseCatalog();
|
||||
_catalog.Upsert(AetheriaVerseCatalogDocuments.Build(normalized));
|
||||
_server = CultMesh.ServeVerseCatalog(_node.MeshNode, _catalog);
|
||||
_fingerprint = nextFingerprint;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeServer();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DisposeServer()
|
||||
{
|
||||
_server?.Dispose();
|
||||
_server = null;
|
||||
_catalog?.Dispose();
|
||||
_catalog = null;
|
||||
_fingerprint = "";
|
||||
}
|
||||
|
||||
private static string CreateFingerprint(AetheriaVerseHostSettings settings)
|
||||
{
|
||||
return string.Join(
|
||||
"\u001f",
|
||||
settings.ServiceId ?? "",
|
||||
settings.VerseId ?? "",
|
||||
settings.RootVerse ?? "",
|
||||
settings.CanonicalService ?? "",
|
||||
settings.LocatedService ?? "",
|
||||
settings.CultMeshAddress ?? "",
|
||||
settings.Title ?? "",
|
||||
settings.Visibility ?? "");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaVerseHostSettingsNormalizer
|
||||
{
|
||||
public static AetheriaVerseHostSettings Normalize(AetheriaVerseHostSettings? settings)
|
||||
{
|
||||
return new AetheriaVerseHostSettings
|
||||
{
|
||||
Schema = NormalizeOrDefault(settings?.Schema, "aetheria.verse_host_settings.v1"),
|
||||
ServiceId = NormalizeOrDefault(settings?.ServiceId, "aetheria.runtime"),
|
||||
VerseId = NormalizeOrDefault(settings?.VerseId, "aetheria.local"),
|
||||
RootVerse = NormalizeOrDefault(settings?.RootVerse, "asgard"),
|
||||
CanonicalService = NormalizeOrDefault(settings?.CanonicalService, "asgard.aetheria"),
|
||||
LocatedService = NormalizeOrDefault(settings?.LocatedService, "asgard.local.aetheria"),
|
||||
CultMeshAddress = NormalizeOrDefault(settings?.CultMeshAddress, "asgard.local.aetheria/eve"),
|
||||
Title = NormalizeOrDefault(settings?.Title, "Aetheria"),
|
||||
Visibility = NormalizeOrDefault(settings?.Visibility, "private"),
|
||||
LastUpdatedAtUtc = settings?.LastUpdatedAtUtc?.Trim() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
public static bool Equivalent(AetheriaVerseHostSettings? left, AetheriaVerseHostSettings? right)
|
||||
{
|
||||
var normalizedLeft = Normalize(left);
|
||||
var normalizedRight = Normalize(right);
|
||||
return string.Equals(normalizedLeft.Schema, normalizedRight.Schema, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.ServiceId, normalizedRight.ServiceId, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.VerseId, normalizedRight.VerseId, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.RootVerse, normalizedRight.RootVerse, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.CanonicalService, normalizedRight.CanonicalService, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.LocatedService, normalizedRight.LocatedService, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.CultMeshAddress, normalizedRight.CultMeshAddress, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.Title, normalizedRight.Title, StringComparison.Ordinal) &&
|
||||
string.Equals(normalizedLeft.Visibility, normalizedRight.Visibility, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeOrDefault(string? value, string fallback)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? fallback : trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public static class AetheriaVerseReplica
|
||||
{
|
||||
private const string ReplicaShardId = "primary";
|
||||
private const string ReplicaOwnerRuntimeId = "aetheria-remote";
|
||||
|
||||
public static async Task<long> SyncSnapshotAsync(
|
||||
string replicaStatePath,
|
||||
string endpoint,
|
||||
string runtimeId = "aetheria-verse-replica")
|
||||
{
|
||||
using var node = await OpenReplicaNodeAsync(
|
||||
replicaStatePath,
|
||||
endpoint,
|
||||
runtimeId,
|
||||
pullOnOpen: false).ConfigureAwait(false);
|
||||
return await SyncRawSnapshotChunksAsync(node, endpoint, runtimeId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<int> SyncScopedSnapshotAsync(
|
||||
string replicaStatePath,
|
||||
string endpoint,
|
||||
string runtimeId = "aetheria-verse-replica",
|
||||
IReadOnlyList<string>? schemaIds = null,
|
||||
IReadOnlyList<string>? recordKeys = null,
|
||||
TimeSpan? connectTimeout = null,
|
||||
TimeSpan? responseTimeout = null)
|
||||
{
|
||||
using var node = await OpenReplicaNodeAsync(
|
||||
replicaStatePath,
|
||||
endpoint,
|
||||
runtimeId,
|
||||
pullOnOpen: false).ConfigureAwait(false);
|
||||
var result = await CreateSnapshotEndpoint(
|
||||
endpoint,
|
||||
runtimeId,
|
||||
schemaIds,
|
||||
recordKeys,
|
||||
connectTimeout,
|
||||
responseTimeout)
|
||||
.SyncSnapshotAsync(node)
|
||||
.ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return result.AppliedCount;
|
||||
}
|
||||
|
||||
public static async Task<CultNetSnapshotResponseRawMessage> FetchScopedSnapshotAsync(
|
||||
string endpoint,
|
||||
IReadOnlyList<string>? schemaIds = null,
|
||||
IReadOnlyList<string>? recordKeys = null,
|
||||
TimeSpan? connectTimeout = null,
|
||||
TimeSpan? responseTimeout = null)
|
||||
{
|
||||
return await CreateSnapshotEndpoint(
|
||||
endpoint,
|
||||
"aetheria-verse-replica",
|
||||
schemaIds,
|
||||
recordKeys,
|
||||
connectTimeout,
|
||||
responseTimeout)
|
||||
.FetchSnapshotAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<int> ApplyRawSnapshotResponseAsync(
|
||||
CultMeshNode node,
|
||||
CultNetSnapshotResponseRawMessage response,
|
||||
bool flush = true)
|
||||
{
|
||||
if (node == null)
|
||||
throw new ArgumentNullException(nameof(node));
|
||||
if (response == null)
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
|
||||
var applied = await node.Database.Documents
|
||||
.ApplyRawSnapshotResponseAsync(node.Cache, response)
|
||||
.ConfigureAwait(false);
|
||||
if (flush)
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
|
||||
return applied.Count;
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<T>> FetchScopedDocumentsAsync<T>(
|
||||
string endpoint,
|
||||
IReadOnlyList<string>? schemaIds = null,
|
||||
IReadOnlyList<string>? recordKeys = null,
|
||||
TimeSpan? connectTimeout = null,
|
||||
TimeSpan? responseTimeout = null)
|
||||
where T : class
|
||||
{
|
||||
var surface = CreateSnapshotEndpoint(
|
||||
endpoint,
|
||||
"aetheria-verse-replica",
|
||||
schemaIds,
|
||||
recordKeys,
|
||||
connectTimeout,
|
||||
responseTimeout);
|
||||
return await surface.FetchDocumentsAsync<T>(recordKeys, schemaIds).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<T>> SyncScopedDocumentsAsync<T>(
|
||||
string replicaStatePath,
|
||||
string endpoint,
|
||||
string runtimeId = "aetheria-verse-replica",
|
||||
IReadOnlyList<string>? schemaIds = null,
|
||||
IReadOnlyList<string>? recordKeys = null,
|
||||
TimeSpan? connectTimeout = null,
|
||||
TimeSpan? responseTimeout = null)
|
||||
where T : class
|
||||
{
|
||||
using var node = await OpenReplicaNodeAsync(
|
||||
replicaStatePath,
|
||||
endpoint,
|
||||
runtimeId,
|
||||
pullOnOpen: false).ConfigureAwait(false);
|
||||
return await CreateSnapshotEndpoint(
|
||||
endpoint,
|
||||
runtimeId,
|
||||
schemaIds,
|
||||
recordKeys,
|
||||
connectTimeout,
|
||||
responseTimeout)
|
||||
.SyncDocumentsAsync<T>(node, recordKeys, schemaIds, flush: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task RunReplicaAsync(
|
||||
string replicaStatePath,
|
||||
string endpoint,
|
||||
TimeSpan pollInterval,
|
||||
CancellationToken cancellationToken,
|
||||
string runtimeId = "aetheria-verse-replica")
|
||||
{
|
||||
using var node = await OpenReplicaNodeAsync(
|
||||
replicaStatePath,
|
||||
endpoint,
|
||||
runtimeId,
|
||||
pullOnOpen: false).ConfigureAwait(false);
|
||||
var snapshotFetcher = new CultNetSchemaShardSnapshotFetcher();
|
||||
var logFetcher = new CultNetSchemaShardLogFetcher();
|
||||
|
||||
await SyncRawSnapshotChunksAsync(node, endpoint, runtimeId).ConfigureAwait(false);
|
||||
|
||||
using var replicator = new CultNetShardReplicator(
|
||||
node.Database,
|
||||
new CultNetShardReplicatorOptions
|
||||
{
|
||||
PollInterval = pollInterval,
|
||||
Fetcher = logFetcher,
|
||||
SnapshotFetcher = snapshotFetcher
|
||||
});
|
||||
replicator.Start();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static CultNetShardDescriptor BuildReplicaShard(string endpoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
throw new ArgumentException("Replica endpoint must be non-empty.", nameof(endpoint));
|
||||
|
||||
return new CultNetShardDescriptor(
|
||||
ReplicaShardId,
|
||||
ReplicaOwnerRuntimeId,
|
||||
epoch: 1,
|
||||
isPrimary: false,
|
||||
primaryEndpoints: new[] { endpoint.Trim() });
|
||||
}
|
||||
|
||||
private static async Task<long> SyncRawSnapshotChunksAsync(
|
||||
CultMeshNode node,
|
||||
string endpoint,
|
||||
string runtimeId)
|
||||
{
|
||||
var hot = await SyncHotPublicationsAsync(node, endpoint, runtimeId).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return hot;
|
||||
}
|
||||
|
||||
private static async Task<long> SyncHotPublicationsAsync(
|
||||
CultMeshNode node,
|
||||
string endpoint,
|
||||
string runtimeId)
|
||||
{
|
||||
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;
|
||||
var surface = CreateSnapshotEndpoint(
|
||||
endpoint,
|
||||
runtimeId,
|
||||
schemaIds: null,
|
||||
recordKeys: null,
|
||||
connectTimeout: null,
|
||||
responseTimeout: TimeSpan.FromSeconds(15));
|
||||
foreach (var recordKey in recordKeys)
|
||||
{
|
||||
var result = await surface.SyncSnapshotAsync(
|
||||
node,
|
||||
schemaIds: null,
|
||||
recordKeys: new[] { recordKey }).ConfigureAwait(false);
|
||||
sequence = Math.Max(sequence, result.ShardLogSequence);
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private static CultMeshSnapshotRequestOptions CreateSnapshotOptions(
|
||||
string runtimeId,
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
IReadOnlyList<string>? recordKeys,
|
||||
TimeSpan? connectTimeout,
|
||||
TimeSpan? responseTimeout)
|
||||
{
|
||||
return new CultMeshSnapshotRequestOptions
|
||||
{
|
||||
SchemaIds = schemaIds,
|
||||
RecordKeys = recordKeys,
|
||||
ShardId = ReplicaShardId,
|
||||
ShardEpoch = 1,
|
||||
ConnectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5),
|
||||
ResponseTimeout = responseTimeout ?? TimeSpan.FromSeconds(5),
|
||||
MessageIdPrefix = "aetheria-replica",
|
||||
RudpRuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "aetheria-verse-replica" : runtimeId,
|
||||
RudpMaxFragmentBytes = 1200
|
||||
};
|
||||
}
|
||||
|
||||
private static CultMeshSnapshotEndpoint CreateSnapshotEndpoint(
|
||||
string endpoint,
|
||||
string runtimeId,
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
IReadOnlyList<string>? recordKeys,
|
||||
TimeSpan? connectTimeout,
|
||||
TimeSpan? responseTimeout)
|
||||
{
|
||||
var effectiveRuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "aetheria-verse-replica" : runtimeId;
|
||||
return CultMesh.SnapshotEndpoint(
|
||||
endpoint,
|
||||
new CultMeshSnapshotEndpointOptions
|
||||
{
|
||||
Context = CultMesh.Verse("aetheria.remote", effectiveRuntimeId).Context,
|
||||
DocumentRegistry = AetheriaDocumentRegistry.CreateCultNetRegistry(),
|
||||
Request = CreateSnapshotOptions(
|
||||
effectiveRuntimeId,
|
||||
schemaIds,
|
||||
recordKeys,
|
||||
connectTimeout,
|
||||
responseTimeout)
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<CultMeshNode> OpenReplicaNodeAsync(
|
||||
string replicaStatePath,
|
||||
string endpoint,
|
||||
string runtimeId,
|
||||
bool pullOnOpen = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(replicaStatePath))
|
||||
throw new ArgumentException("Replica state path must be non-empty.", nameof(replicaStatePath));
|
||||
|
||||
var shard = BuildReplicaShard(endpoint);
|
||||
var cacheRegistry = AetheriaDocumentRegistry.CreateCultCacheRegistry();
|
||||
return CultMesh.CreateNodeAsync(
|
||||
replicaStatePath,
|
||||
new CultMeshNodeOptions
|
||||
{
|
||||
StartServer = false,
|
||||
EnableDurableShardLogs = true,
|
||||
CacheOptions = new CultCacheOpenOptions
|
||||
{
|
||||
Registry = cacheRegistry,
|
||||
PullOnOpen = pullOnOpen,
|
||||
StoreFlushOnDispose = true,
|
||||
UseDirectoryStore = true
|
||||
},
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "aetheria-verse-replica" : runtimeId,
|
||||
Shards = new[] { shard },
|
||||
DocumentRegistry = AetheriaDocumentRegistry.CreateCultNetRegistry(cacheRegistry)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.corporation", "aetheria.corporation.v2")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaCorporation
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("legacyId")]
|
||||
public string LegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string CorporationKey => string.IsNullOrWhiteSpace(LegacyId)
|
||||
? ""
|
||||
: $"aetheria.corporation:legacy:{LegacyId}";
|
||||
|
||||
[Key(2)]
|
||||
public string ShortName { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
[CultIndex("geonameFileLegacyId")]
|
||||
public string GeonameFileLegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string GeonameFileKey => string.IsNullOrWhiteSpace(GeonameFileLegacyId)
|
||||
? ""
|
||||
: $"aetheria.name_file:legacy:{GeonameFileLegacyId}";
|
||||
|
||||
[Key(5)]
|
||||
[CultIndex("bossHullLegacyId")]
|
||||
public string BossHullLegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string BossHullItemKey => string.IsNullOrWhiteSpace(BossHullLegacyId)
|
||||
? ""
|
||||
: $"aetheria.item_definition:legacy:{BossHullLegacyId}";
|
||||
|
||||
[Key(6)]
|
||||
public int InfluenceDistance { get; set; }
|
||||
|
||||
[Key(7)]
|
||||
public int AllegianceCount { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
public uint OverworldMusic { get; set; }
|
||||
|
||||
[Key(9)]
|
||||
public uint CombatMusic { get; set; }
|
||||
|
||||
[Key(10)]
|
||||
public uint BossMusic { get; set; }
|
||||
|
||||
[Key(11)]
|
||||
public AetheriaCorporationAllegiance[] Allegiances { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaCorporationAllegiance
|
||||
{
|
||||
[Key(0)]
|
||||
public string CorporationLegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string CorporationKey => string.IsNullOrWhiteSpace(CorporationLegacyId)
|
||||
? ""
|
||||
: $"aetheria.corporation:legacy:{CorporationLegacyId}";
|
||||
|
||||
[Key(1)]
|
||||
public double Weight { get; set; }
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.eve_command_acceptance_status", "aetheria.eve_command_acceptance_status.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaEveCommandAcceptanceStatus
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "Aetheria Eve command acceptance";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("runtimeId")]
|
||||
public string RuntimeId { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public string StatePath { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string LastPollAtUtc { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string LastAcceptedAtUtc { get; set; } = "";
|
||||
|
||||
[Key(5)]
|
||||
public int ObservedBeforeAccept { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public int CommandsAccepted { get; set; }
|
||||
|
||||
[Key(7)]
|
||||
public int CommandsRejected { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
public int AppliedCatalogRefreshes { get; set; }
|
||||
|
||||
[Key(9)]
|
||||
public int AppliedOperationsRefreshes { get; set; }
|
||||
|
||||
[Key(10)]
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
|
||||
[Key(11)]
|
||||
public string LastError { get; set; } = "";
|
||||
|
||||
[Key(12)]
|
||||
public string LastRejectedCommand { get; set; } = "";
|
||||
|
||||
[Key(13)]
|
||||
public string LastRejectedReason { get; set; } = "";
|
||||
|
||||
[Key(14)]
|
||||
public string Status { get; set; } = "ok";
|
||||
|
||||
[Key(15)]
|
||||
public int AppliedPlayerSettingsCommands { get; set; }
|
||||
|
||||
[Key(16)]
|
||||
public int AppliedVerseHostCommands { get; set; }
|
||||
|
||||
[Key(17)]
|
||||
public int AppliedInputSettingsCommands { get; set; }
|
||||
|
||||
[Key(18)]
|
||||
public int AppliedLoadoutTemplateCommands { get; set; }
|
||||
|
||||
[Key(19)]
|
||||
public string[] AccountedCommandIds { get; set; } = [];
|
||||
|
||||
[Key(20)]
|
||||
public int AppliedMainMenuCommands { get; set; }
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.game_session", "aetheria.game_session.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaGameSessionState
|
||||
{
|
||||
public const string TerminusMode = AetheriaGameModes.Terminus;
|
||||
public const string StarbridgeMode = AetheriaGameModes.Starbridge;
|
||||
public const string ArenaMode = AetheriaGameModes.Arena;
|
||||
public const string AetheriaMode = "aetheria";
|
||||
|
||||
[Key(0)] public string Mode { get; set; } = TerminusMode;
|
||||
[Key(1)] public string SessionId { get; set; } = "";
|
||||
[Key(2)] public string RunId { get; set; } = "";
|
||||
[Key(3)] public string ControlledEntityKey { get; set; } = "";
|
||||
[Key(4)] public string EntrySurfaceId { get; set; } = "aetheria.pilot";
|
||||
[Key(5)] public string LastStartCommandId { get; set; } = "";
|
||||
[Key(6)] public string UpdatedAtUtc { get; set; } = "";
|
||||
[Key(7)] public double SimulationRate { get; set; } = 1;
|
||||
[Key(8)] public double? EffectiveSimulationRate { get; set; } = 1;
|
||||
}
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.item_definition", "aetheria.item_definition.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaItemDefinition
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("category")]
|
||||
public string Category { get; set; } = "uncategorized";
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("legacyId")]
|
||||
public string LegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string ItemKey => string.IsNullOrWhiteSpace(LegacyId)
|
||||
? ""
|
||||
: $"aetheria.item_definition:legacy:{LegacyId}";
|
||||
|
||||
[Key(3)]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public double Mass { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
public double Volume { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public string[] Tags { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
[CultIndex("manufacturerLegacyId")]
|
||||
public string ManufacturerLegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string ManufacturerKey => string.IsNullOrWhiteSpace(ManufacturerLegacyId)
|
||||
? ""
|
||||
: $"aetheria.corporation:legacy:{ManufacturerLegacyId}";
|
||||
|
||||
[Key(8)]
|
||||
public int Price { get; set; }
|
||||
|
||||
[Key(9)]
|
||||
public int ShapeWidth { get; set; }
|
||||
|
||||
[Key(10)]
|
||||
public int ShapeHeight { get; set; }
|
||||
|
||||
[Key(11)]
|
||||
public int OccupiedCells { get; set; }
|
||||
|
||||
[Key(12)]
|
||||
[CultIndex("hardpointType")]
|
||||
public string HardpointType { get; set; } = "";
|
||||
|
||||
[Key(13)]
|
||||
[CultIndex("hullType")]
|
||||
public string HullType { get; set; } = "";
|
||||
|
||||
[Key(14)]
|
||||
public string[] BehaviorKinds { get; set; } = [];
|
||||
|
||||
[Key(15)]
|
||||
public int BehaviorCount { get; set; }
|
||||
|
||||
[Key(16)]
|
||||
public int MaxStack { get; set; }
|
||||
|
||||
[Key(17)]
|
||||
public bool Stackable { get; set; }
|
||||
|
||||
[Key(18)]
|
||||
public double Duration { get; set; }
|
||||
|
||||
[Key(19)]
|
||||
public double Durability { get; set; }
|
||||
|
||||
[Key(20)]
|
||||
public string WeaponRange { get; set; } = "";
|
||||
|
||||
[Key(21)]
|
||||
public string WeaponCaliber { get; set; } = "";
|
||||
|
||||
[Key(22)]
|
||||
public string WeaponType { get; set; } = "";
|
||||
|
||||
[Key(23)]
|
||||
public string WeaponFireTypes { get; set; } = "";
|
||||
|
||||
[Key(24)]
|
||||
public string WeaponModifiers { get; set; } = "";
|
||||
|
||||
[Key(25)]
|
||||
public AetheriaShapeCell[] ShapeCells { get; set; } = [];
|
||||
|
||||
[Key(26)]
|
||||
public int InteriorShapeWidth { get; set; }
|
||||
|
||||
[Key(27)]
|
||||
public int InteriorShapeHeight { get; set; }
|
||||
|
||||
[Key(28)]
|
||||
public int InteriorOccupiedCells { get; set; }
|
||||
|
||||
[Key(29)]
|
||||
public AetheriaShapeCell[] InteriorShapeCells { get; set; } = [];
|
||||
|
||||
[Key(30)]
|
||||
public AetheriaItemHardpoint[] Hardpoints { get; set; } = [];
|
||||
|
||||
[Key(31)]
|
||||
public AetheriaBehaviorPayload[] BehaviorPayloads { get; set; } = [];
|
||||
|
||||
[Key(32)]
|
||||
public double MinimumTemperature { get; set; }
|
||||
|
||||
[Key(33)]
|
||||
public double MaximumTemperature { get; set; }
|
||||
|
||||
[Key(34)]
|
||||
public AetheriaCurveKey[] ThermalPerformanceCurveKeys { get; set; } = [];
|
||||
|
||||
[Key(35)]
|
||||
public string HullPrefab { get; set; } = "";
|
||||
|
||||
[Key(36)]
|
||||
public string SimpleCommodityCategory { get; set; } = "";
|
||||
|
||||
[Key(37)]
|
||||
public string CompoundCommodityCategory { get; set; } = "";
|
||||
|
||||
[Key(38)]
|
||||
public double SpecificHeat { get; set; } = 1;
|
||||
|
||||
[Key(39)]
|
||||
public double Conductivity { get; set; } = 1;
|
||||
|
||||
[Key(40)]
|
||||
public double HullGridOffset { get; set; }
|
||||
|
||||
[Key(41)]
|
||||
public double HullArmor { get; set; }
|
||||
|
||||
[Key(42)]
|
||||
public double HullDrag { get; set; }
|
||||
|
||||
[Key(43)]
|
||||
public bool HullCanTow { get; set; }
|
||||
|
||||
[Key(44)]
|
||||
public int DockingMaxSizeX { get; set; }
|
||||
|
||||
[Key(45)]
|
||||
public int DockingMaxSizeY { get; set; }
|
||||
|
||||
[Key(46)]
|
||||
public string ActionBarIcon { get; set; } = "";
|
||||
|
||||
[Key(47)]
|
||||
public double ThermalResilience { get; set; } = 1;
|
||||
|
||||
[Key(48)]
|
||||
public AetheriaItemAudioStat[] AudioStats { get; set; } = [];
|
||||
|
||||
[Key(49)]
|
||||
public AetheriaCurveKey[] EffectivenessCurveKeys { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaShapeCell
|
||||
{
|
||||
[Key(0)]
|
||||
public int X { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public int Y { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaItemHardpoint
|
||||
{
|
||||
[Key(0)]
|
||||
public string Type { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public int PositionX { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public int PositionY { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public int ShapeWidth { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
public int ShapeHeight { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
public int OccupiedCells { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public AetheriaShapeCell[] ShapeCells { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
public string Transform { get; set; } = "";
|
||||
|
||||
[Key(8)]
|
||||
public string Rotation { get; set; } = "";
|
||||
|
||||
[Key(9)]
|
||||
public double Armor { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaCurveKey
|
||||
{
|
||||
[Key(0)]
|
||||
public double Time { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public double Value { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public double InTangent { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public double OutTangent { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaItemAudioStat
|
||||
{
|
||||
[Key(0)]
|
||||
public uint Parameter { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public AetheriaItemPerformanceStat Stat { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaItemPerformanceStat
|
||||
{
|
||||
[Key(0)]
|
||||
public double Min { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public double Max { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public double HeatExponentMultiplier { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public double DurabilityExponentMultiplier { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
public double QualityExponent { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaBehaviorPayload
|
||||
{
|
||||
[Key(0)]
|
||||
public int UnionKey { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public int Group { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public AetheriaBehaviorField[] Fields { get; set; } = [];
|
||||
|
||||
[Key(4)]
|
||||
public string BehaviorId { get; set; } = "";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaBehaviorField
|
||||
{
|
||||
[Key(0)]
|
||||
public int Key { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public AetheriaBehaviorValue Value { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaBehaviorMapEntry
|
||||
{
|
||||
[Key(0)]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public AetheriaBehaviorValue Value { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaBehaviorValue
|
||||
{
|
||||
[Key(0)]
|
||||
public string Kind { get; set; } = "nil";
|
||||
|
||||
[Key(1)]
|
||||
public string StringValue { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public double NumberValue { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public bool BoolValue { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
public string LegacyIdValue { get; set; } = "";
|
||||
|
||||
[Key(5)]
|
||||
public AetheriaBehaviorValue[] Children { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public AetheriaBehaviorMapEntry[] MapEntries { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
public string ItemKeyValue { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.legacy_catalog_quarantine", "aetheria.legacy_catalog_quarantine.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaLegacyCatalogQuarantine
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "Aetheria legacy catalog quarantine";
|
||||
|
||||
[Key(1)]
|
||||
public string RootPath { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public string CapturedAtUtc { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string CatalogFile { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string CatalogFingerprint { get; set; } = "";
|
||||
|
||||
[Key(5)]
|
||||
public long CatalogBytes { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public AetheriaLegacyCatalogFile[] NameFiles { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
public string[] Notes { get; set; } = [];
|
||||
|
||||
[Key(8)]
|
||||
public AetheriaLegacyCatalogFile[] SupplementalCatalogFiles { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaLegacyCatalogFile
|
||||
{
|
||||
[Key(0)]
|
||||
public string RelativePath { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public string Fingerprint { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.main_menu_state", "aetheria.main_menu_state.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaMainMenuState
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "Aetheria main menu state";
|
||||
|
||||
[Key(1)]
|
||||
public string ActiveSurfaceId { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public string LastCommandId { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string LastCommand { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string UpdatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.migration_ledger", "aetheria.migration_ledger.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaMigrationLedger
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "Aetheria legacy migration ledger";
|
||||
|
||||
[Key(1)]
|
||||
public string Source { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public string SourceFingerprint { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string LastMigrationAtUtc { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public AetheriaMigrationCount[] Counts { get; set; } = [];
|
||||
|
||||
[Key(5)]
|
||||
public string[] Notes { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public AetheriaLegacyCatalogEntrySummary[] LegacyCatalogEntries { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaMigrationCount
|
||||
{
|
||||
[Key(0)]
|
||||
public string DocumentType { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaLegacyCatalogEntrySummary
|
||||
{
|
||||
[Key(0)]
|
||||
public string LegacyId { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public int UnionKey { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public string DocumentKind { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.name_file", "aetheria.name_file.v2")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaNameFile
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("legacyId")]
|
||||
public string LegacyId { get; set; } = "";
|
||||
|
||||
[IgnoreMember]
|
||||
public string NameFileKey => string.IsNullOrWhiteSpace(LegacyId)
|
||||
? ""
|
||||
: $"aetheria.name_file:legacy:{LegacyId}";
|
||||
|
||||
[Key(2)]
|
||||
public int NameCount { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public string[] SampleNames { get; set; } = [];
|
||||
|
||||
[Key(4)]
|
||||
public string[] Names { get; set; } = [];
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.player_profile", "aetheria.player_profile.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaPlayerProfile
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("legacyId")]
|
||||
public string LegacyId { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("email")]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
[CultReference(typeof(AetheriaCorporation))]
|
||||
public string CorporationKey { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.runtime_session", "aetheria.runtime_session.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaRuntimeSession
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string RuntimeId { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("role")]
|
||||
public string Role { get; set; } = "local";
|
||||
|
||||
[Key(2)]
|
||||
public string StartedAtUtc { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string LastSeenAtUtc { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string Status { get; set; } = "running";
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,36 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.verse_host_settings", "aetheria.verse_host_settings.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaVerseHostSettings
|
||||
{
|
||||
[Key(0)] public string Schema { get; set; } = "aetheria.verse_host_settings.v1";
|
||||
[Key(1)] public string ServiceId { get; set; } = "aetheria.runtime";
|
||||
[Key(2)] public string VerseId { get; set; } = "aetheria.local";
|
||||
[Key(3)] public string RootVerse { get; set; } = "asgard";
|
||||
[Key(4)] public string CanonicalService { get; set; } = "asgard.aetheria";
|
||||
[Key(5)] public string LocatedService { get; set; } = "asgard.local.aetheria";
|
||||
[Key(6)] public string CultMeshAddress { get; set; } = "asgard.local.aetheria/eve";
|
||||
[Key(7)] public string Title { get; set; } = "Aetheria";
|
||||
[Key(8)] public string Visibility { get; set; } = "private";
|
||||
[Key(9)] public string LastUpdatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Aetheria.State.Documents;
|
||||
|
||||
[CultDocument("aetheria.world_state", "aetheria.world_state.v1")]
|
||||
[CultGlobal]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaWorldState
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name { get; set; } = "Aetheria";
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("worldId")]
|
||||
public string WorldId { get; set; } = "aetheria";
|
||||
|
||||
[Key(2)]
|
||||
public int SchemaEpoch { get; set; } = 1;
|
||||
|
||||
[Key(3)]
|
||||
public string CreatedAtUtc { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string UpdatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
namespace Aetheria.State.Migration;
|
||||
|
||||
public static class LegacyMigrationBoundary
|
||||
{
|
||||
public const string LegacyCacheNamespace = "Assets/Scripts/ServerShared/CultCache";
|
||||
public const string LegacyRethinkNamespace = "Assets/Scripts/ServerShared/NIH/RethinkDb";
|
||||
public const string LegacyGameDataFile = "GameData/AetherDB.msgpack";
|
||||
public const string LegacyPlayerSettingsFile = "GameData/PlayerSettings.msgpack";
|
||||
public const string LegacyLoadoutExtension = ".loadout";
|
||||
public const string LegacyZoneExtension = ".zone";
|
||||
|
||||
public static string[] ForbiddenLiveAuthorities { get; } =
|
||||
[
|
||||
"DatabaseEntry",
|
||||
"DatabaseCache",
|
||||
"JsonKnownTypes",
|
||||
"Newtonsoft.Json",
|
||||
"RethinkDb.Driver",
|
||||
"RethinkTableAttribute"
|
||||
];
|
||||
}
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
# Aetheria.State
|
||||
|
||||
`Aetheria.State` is the replacement owner for durable Aetheria game state.
|
||||
|
||||
It uses modern `GameCult.Caching`, `GameCult.Caching.MessagePack`, and
|
||||
`GameCult.Mesh`. It does not reference the old `Assets/Scripts/ServerShared`
|
||||
tree, RethinkDB, JsonKnownTypes, or Newtonsoft.Json.
|
||||
|
||||
The first live state files are `GameData/aetheria-world.cc` for daemon-owned
|
||||
Verse state and `GameData/aetheria-client.cc` for the client-owned active Verse
|
||||
target. `Aetheria.State.Import` generates the daemon-owned world file from
|
||||
checked-in legacy catalog inputs without embedding machine-local absolute
|
||||
paths. A full import emits one self-contained monolithic `.cc` suitable for a
|
||||
fresh clone or released package. Runtime-created `.cc.records` directories are
|
||||
mutable materializations, not part of the release contract. Legacy files such
|
||||
as `GameData/AetherDB.msgpack` are migration inputs only.
|
||||
|
||||
The typed runtime state model is split into player settings, loadout templates,
|
||||
run state, zone state, entity snapshots, item slots, weapon groups, action-bar
|
||||
bindings, stat grids, and runtime session documents. Runtime checkpoints
|
||||
include current action-bar bindings as stable typed targets: input control
|
||||
path, binding kind, item key, equipment index, behavior index,
|
||||
or weapon-group index. They also include current faction relationship rows as
|
||||
typed corporation references plus relationship tokens and numeric standings.
|
||||
Zone checkpoints include typed orbit and body rows for generated celestial
|
||||
state: orbit IDs, parent IDs, distance, phase, fixed positions, body kind,
|
||||
typed resource item keys, gravity/body multipliers, asteroid entries, and gas/sun visual
|
||||
parameters. Entity checkpoints include typed simulation stat grids for
|
||||
temperature, thermal mass, armor, max armor, and hull-conductivity axes.
|
||||
Player settings include player name, tutorial flag,
|
||||
story-file hash cursors, gameplay formatting, graphics preferences, input
|
||||
binding overrides, and action-bar inputs. Do not preserve `SavedGame`, Unity
|
||||
runtime settings projections, or runtime entity blueprints as opaque payloads in
|
||||
the new store; they are source shapes for projection, not portable state
|
||||
authority.
|
||||
|
||||
The Unity client no longer writes `PlayerSettings.msgpack`, `.loadout`, or
|
||||
`.zone` files. `AetheriaPlayerSettings` is the typed Verse replacement for
|
||||
`PlayerSettings.msgpack`, and `AetheriaLoadoutTemplate` is the typed Verse
|
||||
replacement for bespoke loadout files, with structured hull/equipment/cargo/
|
||||
docking item slots, nested child-entity loadouts, and weapon groups.
|
||||
`RuntimePlayerSettings` is the Unity session projection for menus, formatting,
|
||||
and input binding display. Menu and input-screen changes send typed Eve request
|
||||
documents to the provider-owned Aetheria Verse service; they are not portable
|
||||
state authority and must not mutate daemon-owned state inside Unity.
|
||||
The old `.cc.pending` runtime commit document lane is deleted. Gameplay state,
|
||||
loadout templates, and run checkpoints are accepted by the daemon/Verse owner
|
||||
through typed state operations and daemon command documents, not by
|
||||
renderer-side commit authority.
|
||||
`AetheriaRuntimeSession` is the typed Verse signal for the running daemon.
|
||||
`Aetheria.State.Daemon` owns the long-running `aetheria-daemon` heartbeat and
|
||||
publishes `starting`, `running`, and `stopping` states through `Aetheria.State`;
|
||||
legacy outbox status remains operation telemetry, not daemon-session ownership.
|
||||
The daemon has one combat writer in `AetheriaRuntimeDaemonSimulation`.
|
||||
Projectile movement and contact are computed by Ymir through the injected
|
||||
physics port and committed by that same daemon writer. Combat state remains in
|
||||
the native run/zone/entity snapshot graph;
|
||||
there is no separate abstract combat save model or optional second writer.
|
||||
Unity Eve surfaces construct typed `gamecult.eve.command.v1` command documents.
|
||||
`AetheriaRuntimeVerseClient` is the shared CultMesh client boundary for
|
||||
headless, Unity, and non-Unity runtimes. Typed command clients submit directly
|
||||
to the same Verse records; there is no command-port facade or cached submitter
|
||||
between client code and the Aetheria state graph.
|
||||
`AetheriaEveCommandBridge` is the provider-owned command acceptor for those
|
||||
records: it validates the provider, surface, and command template before
|
||||
running the narrow refresh handlers for the catalog and operations surfaces
|
||||
plus the typed player-settings mutation handlers for gameplay/graphics Eve
|
||||
controls and typed Verse-host visibility commands. Renderer callbacks do not
|
||||
accept commands or mutate game state locally.
|
||||
`AetheriaEveRuntimeBootstrap` mounts the `aetheria.operations` surface through a
|
||||
runtime-created `UIDocument` after scene load, with environment and command-line
|
||||
switches for diagnostics. UI Toolkit is now a live runtime lowering path, not
|
||||
only a package sample.
|
||||
The old `SavedGame`/`SavedZone` DTOs and `Galaxy` loader constructor have also
|
||||
been deleted; the new document family is live Verse run/zone state, not a
|
||||
bespoke save-file format.
|
||||
Daemon run frames project into Unity through the explicit
|
||||
`EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint` lowering
|
||||
path; authoritative blueprint construction remains named separately as a
|
||||
migration target for code that still has to move into the daemon.
|
||||
Main-menu gameplay boot also projects the daemon run through
|
||||
`Galaxy.ProjectObservedDaemonRun` rather than constructing or generating a
|
||||
Unity-owned run. The Unity handle for this shell is
|
||||
`ActionGameManager.ObservedGalaxy` to keep the client-side object named as an
|
||||
observation.
|
||||
The dead `SavedStory` JSON DTO is gone. `ZoneConstructionBlueprint` and
|
||||
`RuntimeEntityBlueprint` remain in-memory construction/loadout projections for
|
||||
Unity runtime code, with `RuntimeEntityBlueprintProjector` capturing and
|
||||
instantiating those projections. They no longer carry save-file or serializer
|
||||
vocabulary, and they do not declare MessagePack persistence attributes.
|
||||
|
||||
Keyboard layout rendering now builds the static ANSI-104 display as typed
|
||||
`InputLayout` rows and columns. It no longer reads a checked-in JSON text asset
|
||||
or uses `SimpleJson` for display geometry. The generated `AetheriaInput` wrapper
|
||||
still calls Unity's `InputActionAsset.FromJson`; that is an engine-owned
|
||||
lowering boundary under Aetheria's remapping layer, not a save format or state
|
||||
owner. The old generated `GameData/KeyboardLayouts/*.msgpack` cache and the
|
||||
checked-in `ansi104.json` display file are deleted. Remapping and action-bar
|
||||
edits are owned by typed `AetheriaPlayerSettings` commits, and the live input
|
||||
screen now lowers through Eve/UI Toolkit instead of the old drag/drop uGUI
|
||||
shell. Keyboard display layout edits remain runtime-only until Verse owns a
|
||||
typed layout document.
|
||||
The unused Unity Localization package and imported Google Sheets localization
|
||||
sample have been removed; the old transitive Unity Newtonsoft package is no
|
||||
longer in the manifest or package lock.
|
||||
|
||||
The old IMGUI CultCache database editor has been deleted, and `NameTools` no
|
||||
longer exports `NameFile/*.msgpack`. `AetherDB.msgpack` and existing name files
|
||||
remain legacy catalog inputs only; typed catalog records are the durable owner.
|
||||
Dead legacy catalog roots for user records and galaxy map-layer resources have
|
||||
been deleted from live Unity source. Surviving runtime DTOs no longer carry
|
||||
legacy catalog group/table annotations; their remaining metadata is projection
|
||||
and inspection metadata for the typed runtime bridge.
|
||||
Item instances now carry `AetheriaRuntimeItemReference`, a typed item-key
|
||||
facade. `ItemInstance.ItemKey` is the identity surface; the old `ItemId`
|
||||
legacy-GUID projection has been deleted from item instances.
|
||||
Cargo inventory indexes, consumable activation lookup, action-bar consumable
|
||||
quantity/fill, transfer lookup, trade owned counts, weapon ammo references, and
|
||||
item-use behavior references now use item keys. The old cargo/consumable GUID
|
||||
lookup overloads and the derived behavior `AmmoType`/`Item` GUID properties
|
||||
have been deleted. Action-bar consumable bindings now read the target `ItemKey`
|
||||
directly from typed runtime catalog rows instead of reconstructing it from
|
||||
legacy item IDs.
|
||||
Daemon snapshot DTOs carry typed `ItemKey`/`HullItemKey` fields only for
|
||||
loadout items, action-bar targets, body resources, entity hulls, and active
|
||||
consumables; daemon command application no longer reconstructs item keys from
|
||||
legacy item GUID fields.
|
||||
Daemon snapshot DTOs now carry typed `FactionKey` only for loadout entities,
|
||||
run entity snapshots, and faction relationship state. The old command-document
|
||||
`CorporationLegacyId` fallback fields are deleted.
|
||||
`EntityConstructionBlueprint` also carries `FactionKey` only. Loadout restore,
|
||||
entity construction, run entity snapshots, and faction relationship commits no
|
||||
longer rebuild corporation references from legacy `Faction.ID` GUIDs.
|
||||
Galaxy faction relationship projection is also typed-key-only: the old
|
||||
`Faction.Allegiance` GUID dictionary, GUID containment helper, and GUID faction
|
||||
resolver are deleted from the simulation shell.
|
||||
The remaining temporary `Faction` shell also uses `GeonameFileKey` and
|
||||
`BossHullItemKey`; it no longer parses name-file or boss-hull legacy IDs into
|
||||
GUID link fields.
|
||||
`Faction` equality, hashing, narrative constraints, sector display filtering,
|
||||
sector-map link/influence rendering, zone security ownership checks, and
|
||||
runtime faction-relationship ordering now use `FactionKey`. The legacy
|
||||
`Faction.ID` projection field is deleted from the temporary simulation shell.
|
||||
Run checkpoint snapshot DTOs carry typed `OrbitKey`, `ParentOrbitKey`, and
|
||||
`BodyKey` only for zone orbit/body snapshots. The old command-document
|
||||
`OrbitLegacyId`, `ParentLegacyId`, and `BodyLegacyId` fallback fields are
|
||||
deleted. Behavior-state daemon snapshots and readback snapshots carry typed
|
||||
body-key fields for resource scanner targets and mining tool asteroid belts;
|
||||
the old raw body ID commit/readback field names are deleted. `ResourceScanner`
|
||||
and `MiningTool` now retain typed body-key runtime state directly, while
|
||||
`Zone` owns parsing and resolution at the current Unity body runtime boundary.
|
||||
`Zone`, `Planet`, `AsteroidBelt`, and `Orbit` now own the runtime body/orbit
|
||||
key surfaces used for projection, and `ActionGameManager` no longer formats or
|
||||
resolves body/orbit keys locally while checkpointing or restoring state.
|
||||
Typed `AetheriaZoneState` orbit/body snapshot rows now use `OrbitKey`,
|
||||
`ParentOrbitKey`, and `BodyKey` property names all the way through the typed
|
||||
document and runtime catalog readback surface; the old `...Id` names were only
|
||||
legacy-shaped aliases for those key strings and are gone.
|
||||
Orbit-targeting agent runtime also now retains typed orbit keys directly:
|
||||
`PatrolOrbitsTask`, `MoveToOrbitState`, and `StationTowing` no longer own raw
|
||||
orbit GUID targets, and `Zone` resolves orbit keys for agent orbit movement.
|
||||
Orbital runtime entities and their construction blueprints now follow the same
|
||||
rule: `OrbitalEntity` and `OrbitalEntityConstructionBlueprint` retain
|
||||
`OrbitKey`, runtime loadout generation seeds the empty value as `""`, and
|
||||
`Zone` resolves orbit keys for orbital entity movement and dock camera parent
|
||||
lookup.
|
||||
Renderer and gameplay orbit consumers now stay on that keyed edge too:
|
||||
intro-cutscene planet selection, zone planet/belt loading, gas-giant parent
|
||||
lighting, asteroid-belt UI setup, and `ResourceScanner` planet range checks all
|
||||
read `OrbitKey` from runtime wrappers and ask `Zone` to resolve it instead of
|
||||
reading wrapper GUID orbit fields directly.
|
||||
`Planet` and `AsteroidBelt` runtime wrappers no longer publish separate wrapper
|
||||
orbit GUID fields either: they keep `OrbitKey` plus their `Orbit` runtime
|
||||
object, and `Zone` lowers asteroid transforms through `belt.Orbit.ParentOrbitKey`
|
||||
instead of reading a raw wrapper orbit GUID.
|
||||
`Zone`'s own public key-facing lookup surface is also now natively keyed:
|
||||
body and orbit lookups resolve through key-indexed runtime dictionaries rather
|
||||
than reparsing GUIDs from `BodyKey` or `OrbitKey` strings before touching the
|
||||
Guid-indexed substrate.
|
||||
The live asteroid helper surface follows the same rule: nearest-asteroid,
|
||||
asteroid-exists, transform-update, and mining paths now use typed body keys or
|
||||
runtime `AsteroidBelt` objects directly instead of Guid asteroid-belt helper
|
||||
overloads.
|
||||
The package runtime catalog snapshot indexes items by canonical item key; Unity
|
||||
gameplay, HUD, inventory, trade, ship, and zone helper lookups use `ItemKey`
|
||||
directly instead of deriving `ItemId` and searching by legacy GUID.
|
||||
The Unity runtime package snapshot also no longer exposes item, corporation, or
|
||||
name-file lookup by legacy ID, and its item/corporation/name-file DTOs no
|
||||
longer publish legacy relationship fields. Canonical legacy-ID lookup remains
|
||||
only in `Aetheria.State` migration/catalog inspection APIs.
|
||||
The shared runtime item catalog reader is also item-key-only now: its GUID index,
|
||||
`GetRuntimeItem(Guid)` entry point, raw GUID string fallback,
|
||||
`AetheriaRuntimeItemReference(Guid)` constructor, `FromLegacyId`, and reverse
|
||||
legacy-GUID projections have been deleted.
|
||||
`ItemManager` now reads typed item rows directly from the shared
|
||||
`AetheriaRuntimeCatalogSnapshot`; the redundant `AetheriaRuntimeItemCatalog`
|
||||
adapter layer has been deleted.
|
||||
The old generic `RuntimeCatalogLink<T>` abstraction, `ItemManager.GetData`
|
||||
hydration path, vague `ItemInstance.Data` identity field, and old
|
||||
`RuntimeItemReference.Projection` cache have been deleted; runtime item facts
|
||||
come from typed catalog rows.
|
||||
ItemManager's behavior construction no longer exposes a generic GUID reader or
|
||||
item GUID projection helper. Migrated legacy-id behavior payload fields carry
|
||||
`LegacyIdValue` as provenance/backfill only; gameplay item behavior reads the
|
||||
typed `ItemKeyValue` emitted by import/runtime projection.
|
||||
Loadout generation now applies typed catalog prefilters for item category,
|
||||
hardpoint type, shape fit, hull type, and behavior kind, then instantiates
|
||||
generated equipment directly from typed runtime rows.
|
||||
Typed item definition `Category` values are stable Aetheria tokens such as
|
||||
`hull`, `weapon`, `gear`, `cargo-bay`, and `docking-bay`; legacy union class
|
||||
names remain migration provenance, not live category predicates.
|
||||
The surviving trade menu also applies typed catalog prefilters for row size,
|
||||
hardpoint type, and behavior kind before legacy buy actions.
|
||||
The old `ItemData`/`EquippableItemData`/`HullData`/`WeaponItemData` DTO
|
||||
hierarchy has been deleted from live Unity source. Shared runtime geometry and
|
||||
stat primitives now live in `RuntimeGeometry.cs`; item facts come from typed
|
||||
runtime rows. The old `RuntimeCatalogEntry` and `RuntimeItemProjectionEntry`
|
||||
base names no longer appear in live source.
|
||||
The dead `InspectableRuntimeCatalogLinkAttribute` inspector metadata is gone;
|
||||
live Unity source no longer carries `LegacyPayloadKeyAttribute` either.
|
||||
Catalog import and migration readers own deterministic legacy field layout
|
||||
through explicit key maps, while runtime behavior construction uses an explicit
|
||||
typed behavior-kind mapper.
|
||||
Agent task DTOs no longer inherit the item projection base; they are local
|
||||
runtime AI work orders until a typed Verse task document exists.
|
||||
|
||||
`Aetheria.State.Import` captures the legacy catalog files into typed quarantine
|
||||
state: path, size, and SHA-256 fingerprint for `AetherDB.msgpack` plus
|
||||
`NameFile/*.msgpack`, and a migration ledger update. It also performs a bounded
|
||||
raw MessagePack union pass that maps stable catalog fields into typed
|
||||
item/faction/name-file documents without compiling Unity's legacy domain model
|
||||
into `Aetheria.State`. The current checked-in catalog maps to 115 item
|
||||
definitions, 12 factions, and 12 name files. Typed item definitions include
|
||||
legacy manufacturer IDs, price, shape dimensions, occupied cell counts, full
|
||||
shape-cell masks, interior shape masks for hull/cargo equipment, hull hardpoint
|
||||
definitions, behavior kind fingerprints, typed recursive behavior payloads, and
|
||||
stable equipment facets: hardpoint type, hull type, stack size, durability, and
|
||||
weapon range/caliber/type/fire/modifier classifications.
|
||||
Typed corporation documents include the legacy short name, the true description
|
||||
field from key 3, name-file and boss-hull legacy IDs, influence distance,
|
||||
allegiance count, full allegiance edges, and music bank IDs. Corporation
|
||||
documents are `aetheria.corporation.v2` records because runtime generation
|
||||
needs actual allegiance weights, not only display counts. Runtime checkpoints
|
||||
now persist session faction relationships into typed run state, and runtime
|
||||
sessions publish typed state-host liveness. Zone checkpoints persist generated
|
||||
orbit/body rows into `AetheriaZoneState`, so `ZoneConstructionBlueprint` is a
|
||||
one-shot construction input carrying typed orbit/body keys rather than the retained surface carrying celestial graph facts. Entity checkpoints
|
||||
persist hull simulation grids into `AetheriaEntitySnapshot`, so
|
||||
`EntityConstructionBlueprint` is no longer the only surface carrying those live
|
||||
values. Run checkpoints project the flattened live entity graph, including
|
||||
docked children, into exact typed entity snapshot records. Continue restore
|
||||
lowers the current zone entity graph from those records, including position,
|
||||
velocity, target/contact state, child/docking relationships,
|
||||
shutdown/heatsink state, thermal exposure, active consumable timers, and typed
|
||||
stat grids for temperature, thermal mass, armor, max armor, and hull
|
||||
conductivity. Continue also lowers typed weapon and behavior runtime rows back
|
||||
into live equipped-item and active-consumable behavior instances through
|
||||
behavior-owned restore methods; construction/loadout blueprints do not own
|
||||
behavior replay. Broader runtime object graphs without typed rows remain legacy
|
||||
until dedicated typed runtime documents exist. Live behavior
|
||||
instances expose typed behavior kind and group through `Behavior.Kind` and
|
||||
`Behavior.Group`; behavior construction now reads typed payload fields through
|
||||
`RuntimeBehaviorDefinition` instead of projecting through config DTOs. Stat
|
||||
modifiers ask live behavior instances for target stats instead of reading
|
||||
behavior config objects directly; base `Behavior` captures a runtime stat table
|
||||
at construction without config reflection.
|
||||
Heat, EnergyDraw, and Cooldown behavior instances now copy their constructor
|
||||
stat/scalar inputs into explicit runtime fields and no longer retain their
|
||||
config subclasses after construction.
|
||||
Wear, Visibility, Reflector, VelocityLimit, VelocityConversion, and ItemUsage
|
||||
also copy their constructor stat/key/scalar inputs into explicit runtime fields
|
||||
instead of retaining config subclasses.
|
||||
Capacitor, Shield, Thruster, MiningTool, ResourceScanner, Thermotoggle, Switch,
|
||||
HeatStorage, and Cockpit have also dropped runtime config retention. Thermotoggle
|
||||
adjustability is exposed on the live behavior instance, not through its config.
|
||||
Reactor, Radiator, Sensor, and StatModifier now copy their constructor
|
||||
performance stats, curves, flags, and target strings into explicit runtime
|
||||
fields; those live behaviors no longer retain their config subclasses.
|
||||
AetherDrive now follows the same rule for rotor geometry, performance stats,
|
||||
torque curve, audio parameters, and prefab path. Weapon, InstantWeapon,
|
||||
ConstantWeapon, ChargedWeapon, and LockWeapon now copy constructor stats,
|
||||
curves, ammo/reload fields, guided projectile profile fields, burst/cooldown
|
||||
fields, charge multipliers, and lock parameters into runtime-owned fields.
|
||||
Behavior instances no longer retain construction config DTOs after
|
||||
construction.
|
||||
Cockpit, HeatStorage, Switch, Trigger, and TurretController read directly from
|
||||
typed runtime behavior definitions.
|
||||
Heat, EnergyDraw, Cooldown, Wear, Visibility, Reflector, VelocityLimit,
|
||||
VelocityConversion, and ItemUsage also bypass the temporary config bridge and
|
||||
read typed behavior fields through `RuntimeBehaviorDefinition`, registering
|
||||
runtime performance stats explicitly for `StatModifier`.
|
||||
Capacitor, Shield, MiningTool, ResourceScanner, Thermotoggle, and Thruster now
|
||||
follow the same direct-definition path.
|
||||
Reactor, Radiator, Sensor, StatModifier, and AetherDrive also read typed
|
||||
payload fields through `RuntimeBehaviorDefinition`; AetherDrive registers its
|
||||
live performance-stat names explicitly for `StatModifier`.
|
||||
Weapon, InstantWeapon, AutoWeapon, ConstantWeapon, ChargedWeapon, LockWeapon,
|
||||
Launcher, and GuidedWeapon payloads also construct directly from
|
||||
`RuntimeBehaviorDefinition`. The old `RuntimeBehaviorConfig` bridge,
|
||||
`BehaviorPayloadReader`, and weapon config hierarchy are deleted.
|
||||
Typed name-file documents are `aetheria.name_file.v2` records. They carry the
|
||||
legacy ID, display name, count, compact sample names for surfaces, and the full
|
||||
name array used by `Galaxy`/Markov name generation through typed name-file
|
||||
keys rather than legacy `NameFile.Names`.
|
||||
Legacy GUID references that are `Guid.Empty` are imported as absent references,
|
||||
not as resolvable catalog links.
|
||||
Before materializing state, the importer clears the generated `.cc`,
|
||||
`.cc.records`, and `.cultmesh` outputs for the selected state path. Legacy
|
||||
catalog inputs are captured first and are not deleted. Full import writes the
|
||||
result through the monolithic CultCache store; no ignored directory record may
|
||||
be required to open the artifact. This lets schema changes rebuild the typed
|
||||
artifact instead of failing on a stale embedded schema catalog.
|
||||
|
||||
`AetheriaCatalogSnapshot` is the typed read surface over materialized catalog
|
||||
documents. It exposes trade items, legacy-ID migration lookup, typed
|
||||
manufacturer/corporation/name-file key lookup, corporation prefix lookup,
|
||||
equipment items, behavior lookup, and hardpoint lookup without reading runtime
|
||||
projection DTOs.
|
||||
|
||||
`AetheriaCatalogSurfaceProjector` publishes the first Eve-compatible provider
|
||||
surface from typed catalog state. The importer materializes
|
||||
`gamecult.eve.surface.v1` at `eve:surface:aetheria.catalog.operator` with
|
||||
summary, trade-catalog, and corporation views. This is a typed CultCache
|
||||
provider document and a local mirror of the Eve contract until the shared Eve
|
||||
Unity UI Toolkit lowering package exists.
|
||||
|
||||
`Aetheria.State.Unity` is the Unity-facing runtime read facade over typed state.
|
||||
It resolves the active Verse through the client-owned `aetheria-client.cc`
|
||||
target document, then opens the selected typed state source. Today that source
|
||||
is still a local `.cc` file transport, so the facade emits immutable catalog
|
||||
read models for trade, equipment, behavior, hardpoint, manufacturer,
|
||||
corporation, and name-file queries, exposes typed item shape masks for layout
|
||||
and fitting consumers, exposes typed interior masks and hardpoints for
|
||||
equipment/cargo layout consumers, exposes typed behavior payload read models,
|
||||
can read typed player settings for Unity boot, and can read the published Eve
|
||||
catalog surface. It does not deserialize legacy runtime projection objects and
|
||||
does not write daemon-owned Verse state. Unity can use this as the first
|
||||
package boundary once CultLib/Eve runtime packaging is available.
|
||||
Unity `MainMenu` now lowers a typed Verse settings shell over that split:
|
||||
client-target edits persist locally in `aetheria-client.cc`, while Verse-host
|
||||
visibility changes append provider-owned Eve request documents against the
|
||||
selected local Verse state file.
|
||||
`Galaxy` now consumes this package-owned runtime catalog for faction selection
|
||||
and name generation, so generated sectors use typed corporation v2 and
|
||||
`aetheria.name_file.v2` records instead of legacy `Faction`/`NameFile`
|
||||
catalog documents. Loadout manufacturer-distance weighting also resolves
|
||||
manufacturers through typed corporation keys rather than parsing manufacturer
|
||||
legacy IDs. `Galaxy` still projects typed corporations into temporary `Faction`
|
||||
DTOs, including local GUID equality for the surviving simulation surface,
|
||||
because the surrounding simulation has not yet been rebuilt around typed
|
||||
corporation records. The DTOs now carry typed faction/name-file keys and typed
|
||||
allegiance edges so they cannot reclaim catalog lookup authority.
|
||||
|
||||
`Aetheria.State.Smoke` writes and reopens full typed player settings plus a
|
||||
typed loadout template in addition to world, catalog, run state, zone state, and
|
||||
an entity snapshot. It also writes and reopens `AetheriaRuntimeSession`, the
|
||||
provider-owned `aetheria.player_settings` Eve surface, and player-settings Eve
|
||||
commands that mutate typed significant-digit and minimap-asteroid settings
|
||||
through the provider bridge. The smoke checks that the Eve provider advertises
|
||||
the runtime-session schema and player-settings command surface. The run smoke
|
||||
proves a typed run can reference a typed zone, the zone can preserve
|
||||
orbit/body rows and reference typed entity snapshots, and entity snapshots can
|
||||
preserve equipment slots, weapon groups, and simulation stat grids without
|
||||
reviving `PlayerSettings.msgpack`, `.loadout`, or `.zone` serialization.
|
||||
Unity `MainMenu` now lowers that same player-settings Eve document contract
|
||||
locally through UI Toolkit instead of hand-authoring separate gameplay/graphics
|
||||
widgets; the menu shell keeps name entry and back-navigation while gameplay
|
||||
still owns command semantics through `ActionGameManager.RequestRuntimePlayerSettingsCommand`.
|
||||
The run document also carries `CurrentEntityKey`, so Continue restore binds the
|
||||
saved player entity by exact typed record identity instead of reconstructing it
|
||||
from a transient entity-slot index. Canonical typed run state no longer stores
|
||||
`CurrentZoneEntityIndex`; older stored runs are translated through the package
|
||||
reader into `CurrentEntityKey`. The older runtime commit importer has
|
||||
been removed from the first-party state host and Unity package.
|
||||
|
||||
`Aetheria.State.Verify` opens a materialized state file and checks that the
|
||||
typed migration ledger matches the actual item, corporation, and name-file
|
||||
records in the `.cc` store. It also verifies the canonical legacy-ID lookup API
|
||||
for one migrated record of each catalog kind, checks that name-file v2 records
|
||||
carry their full name arrays, and checks that expanded item and corporation
|
||||
catalog facts survived materialization. The verifier also exercises the typed
|
||||
catalog snapshot query surface, shape-mask invariants, the typed behavior
|
||||
payload tree, and the published Eve catalog surface. The current catalog
|
||||
materializes 65 behavior payloads, 661 behavior fields, and 18 behavior-side
|
||||
legacy-ID references. Legacy content currently includes at least one overhanging
|
||||
hardpoint; the verifier preserves the payload and checks hardpoint local masks
|
||||
rather than silently converting content repair into migration. The verifier also
|
||||
fails if live `Assets/Scripts` source grows back serializer or legacy database
|
||||
symbols such as MessagePack, Newtonsoft, JsonKnownTypes, RethinkDB, or old
|
||||
`DatabaseEntry`/`DatabaseLink` references; the remaining MessagePack usage is a
|
||||
named package/import CultCache transport boundary, not gameplay state authority.
|
||||
The package boundary is also fenced: MessagePack symbols may only appear in the
|
||||
runtime catalog reader, CultCache document store, and typed runtime/Eve
|
||||
command document files until generated CultCache serializers replace that
|
||||
temporary Unity-side codec. The verifier also checks that the Eve runtime
|
||||
bootstrap exists, mounts the operations surface through the UI Toolkit presenter,
|
||||
and keeps renderer commands routed through the typed Eve command inbox.
|
||||
Use it after import when `GameData/aetheria-world.cc` changes.
|
||||
|
||||
`Aetheria.State.Unity.Smoke` opens the materialized state through the runtime
|
||||
facade and proves read-only Unity-facing catalog access without the legacy
|
||||
catalog reader. Live Unity code uses typed Eve or daemon request documents
|
||||
instead of runtime commit outboxes. Runtime and Eve requests are typed `.cc`
|
||||
records with embedded schema catalogs; their current record payload codec is
|
||||
still temporary package code until Unity consumes generated CultCache
|
||||
serializers directly.
|
||||
|
||||
`Aetheria.State.Daemon` opens the CultMesh Verse daemon, publishes daemon-owned
|
||||
Verse identity/health/game/editor surfaces, accepts observed Eve command
|
||||
documents on startup, and executes observed daemon command records each tick.
|
||||
For a bounded one-shot daemon tick:
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj -- --once
|
||||
```
|
||||
|
||||
The daemon publishes `AetheriaEveCommandAcceptanceStatus`, the
|
||||
`aetheria.operations` Eve surface, and the
|
||||
typed `aetheria.player_settings` Eve surface after request acceptance attempts,
|
||||
so observed depth, accepted/applied/rejected counts, failures, timestamps, and
|
||||
player settings controls are typed state rather than console-only status. It also
|
||||
persists daemon-owned `aetheria.verse_host_settings.v1`, with boot-time
|
||||
defaults or overrides for service id, Verse id, visibility, and CultMesh
|
||||
address. When visibility is `public`, the daemon also serves a
|
||||
derived CultMesh Verse catalog from that same typed host document, using the
|
||||
located service name as the discoverable `cultnet://...:3075` endpoint. The
|
||||
operations surface and provider advertisement project from that typed host
|
||||
document instead of hardcoded daemon identity strings. The provider
|
||||
advertisement still publishes `gamecult.eve.provider_advertisement.v1` for the
|
||||
`aetheria` provider, advertising the catalog, operations, and player-settings
|
||||
surfaces plus the typed schemas witnessed by the local `.cc` state file.
|
||||
|
||||
Remote Unity clients should not pretend a public Verse is local authority. Use
|
||||
`Aetheria.State.Replica` to pull a cache-only local witness from the daemon:
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\Aetheria.State.Replica\Aetheria.State.Replica.csproj -- sync --endpoint cultnet://host:3075 --game-data-root .\GameData --verse-id aetheria.public
|
||||
```
|
||||
|
||||
That writes `GameData\Verses\<verse>.cc` as a replica projection of daemon
|
||||
truth. The selected remote client target resolves to that replica path. The
|
||||
daemon still owns accepted state; the replica is only the local read surface
|
||||
Unity lowers from.
|
||||
|
||||
The Unity Verse-settings shell can also trigger the same one-shot sync through
|
||||
the `Aetheria.State.Replica` bridge when the repo-local tool is available. That
|
||||
keeps target selection local while the replica organ owns daemon transport.
|
||||
|
||||
The old standalone `Aetheria.State.DrainCommands` applicator is deleted. Command
|
||||
acceptance is daemon behavior now; bounded operation uses `Aetheria.State.Daemon
|
||||
--once` so the same Verse member publishes the same provider, health, command
|
||||
boundary, and Eve surfaces.
|
||||
|
||||
Current rebuild notes:
|
||||
|
||||
- `MessagePack` 3.1.4 is pulled transitively through CultLib and is currently
|
||||
flagged by NuGet advisory `GHSA-hv8m-jj95-wg3x`; update CultLib's
|
||||
`GameCult.Caching.MessagePack` package ownership rather than adding a local
|
||||
Aetheria override.
|
||||
- `CultRecordRef<T>` should become the persisted reference shape once the
|
||||
MessagePack source-generator path handles the GameCult formatter cleanly.
|
||||
Until then, v1 profile documents store explicit record-key strings at the
|
||||
persistence boundary.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# Aetheria Client Runtime Conformance
|
||||
|
||||
This is the client-runtime authority map for removing Unity as a privileged
|
||||
gameplay body without replacing it with a new privileged runtime.
|
||||
|
||||
The daemon owns Aetheria game state, simulation, generated level content,
|
||||
assets, render-field declarations, Eve/CultUI surfaces, and typed operations.
|
||||
Eve owns the game-agnostic surface language: layout, interaction, state
|
||||
pointers, world-state lowering, render-field instructions, and 2D/3D
|
||||
scalar/vector field visualization contracts. Client runtimes lower those
|
||||
surfaces and submit typed operations. A runtime may own presentation, input
|
||||
hardware, local caches, native buffers, and frame timing. It must not own
|
||||
gameplay truth merely because it can draw the game.
|
||||
|
||||
The desired end state is renderless Aetheria. Aetheria should not preserve a
|
||||
Unity, Godot, Electron, or browser side path for game behavior. If a runtime
|
||||
needs an Aetheria-specific rendering rule to show the game, that rule belongs in
|
||||
daemon-authored Eve/CultUI or in a game-agnostic Eve lowering primitive.
|
||||
|
||||
## Runtime Roles
|
||||
|
||||
| Runtime | Role | Pass condition |
|
||||
| --- | --- | --- |
|
||||
| Hermodr | Unspecialized Eve/browser lowering sanity check for the RTS surface. | It reconstructs the Aetheria RTS gameplay surface from daemon-advertised Eve surfaces, typed state pointers, typed operations, render-field declarations, and CultMesh CDN assets without Aetheria-specific renderer code. |
|
||||
| Electron | Player-facing Starbridge RTS client. | It renders the same daemon-authored RTS surface through the shared Eve package, with only app shell, launch, packaging, and runtime ergonomics outside Eve. |
|
||||
| Unity | Shared pilot reference client for Terminus, Starbridge, and Arena; also a demolition target. | It behaves as a renderer/input shell over daemon state and Eve/CultUI/world-state surfaces; remaining GameObject, UI Toolkit, camera, input, and presentation adapters are classified shims or renderer-owned concerns. It owns no Hangar, loadout, progression, or mode-session truth. |
|
||||
| Godot | Future unspecialized Eve/runtime parity target for the ARPG surface. | It reconstructs the ARPG gameplay surface from daemon API plus Eve/CultUI/render-field specs and CultMesh CDN assets, not from a Unity port or daemon-side Godot mode. |
|
||||
|
||||
Hermodr is the purity test for the RTS surface. Electron is the shipped RTS
|
||||
client. Those are different jobs. If Electron can display something Hermodr
|
||||
cannot reconstruct from Eve plus daemon API, either Electron is using a private
|
||||
shortcut or Eve/CultMesh is missing a reusable primitive.
|
||||
|
||||
Godot has the same relationship to the ARPG surface that Hermodr has to the RTS
|
||||
surface. It should prove the ARPG client can be rebuilt from generic Eve,
|
||||
CultMesh state, daemon-owned assets, and engine-native lowering. It should not
|
||||
inherit Unity scene authority, Unity coordinate leakage, Unity UI Toolkit
|
||||
assumptions, or Unity-only gameplay adapters.
|
||||
|
||||
## Forbidden Shapes
|
||||
|
||||
- No daemon-side `Godot` mode, `Electron` mode, `Hermodr` mode, or `Unity`
|
||||
behavior branch for gameplay state.
|
||||
- No renderer-local Aetheria compensator that changes game behavior instead of
|
||||
being promoted into Eve/CultMesh or daemon-authored state.
|
||||
- No Aetheria-specific Hermodr plugin for RTS map semantics.
|
||||
- No Godot port of Unity scene code as the ARPG authority.
|
||||
- No Electron-only field, asset, or command interpretation that Hermodr cannot
|
||||
consume through shared Eve/CultMesh primitives.
|
||||
- No Aetheria-specific Unity, Godot, Electron, or Hermodr renderer compensator
|
||||
for 2D or 3D scalar/vector field visualization.
|
||||
- No daemon-published Eve surface that requires a named runtime to understand
|
||||
Aetheria gameplay semantics.
|
||||
- No Aetheria-owned Unity/Godot/Electron world renderer that should instead be a
|
||||
game-agnostic Eve lowerer.
|
||||
|
||||
## Acceptance Gates
|
||||
|
||||
1. The daemon publishes the RTS surface as Eve/CultUI plus typed state pointers,
|
||||
operation handles, render-field declarations, and CultMesh CDN asset refs.
|
||||
2. Hermodr lowers that RTS surface without Aetheria-specific renderer code.
|
||||
3. Electron renders the same RTS surface through the shared Eve package.
|
||||
4. The daemon publishes the ARPG surface with the same kind of portable surface
|
||||
contract.
|
||||
5. Unity consumes the ARPG contract as a renderer/input shell, with remaining
|
||||
shims listed for deletion.
|
||||
6. Godot lowers the ARPG contract without inheriting Unity authority.
|
||||
7. Eve lowerers can reconstruct the required 2D and 3D gameplay surfaces from
|
||||
daemon-authored state pointers, field documents, asset refs, and operation
|
||||
descriptors without Aetheria runtime branches.
|
||||
8. World-state lowerers live in Eve or shared runtime packages, while Aetheria
|
||||
contributes authored surfaces, state documents, assets, and fixtures.
|
||||
|
||||
Until those gates pass, Aetheria is daemon-authoritative in pieces, not free of
|
||||
Unity as a complete client dependency.
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
# 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 handles, 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.
|
||||
|
||||
## Canonical State Before Projection
|
||||
|
||||
CultMesh should make one canonical typed document feel native everywhere. If a
|
||||
feature's game state is `AetheriaRuntimeFooDocument`, the daemon, Unity,
|
||||
Electron, browser, tools, and future Rust clients should all hold managed
|
||||
handles to that document type. Authority, prediction, debouncing,
|
||||
reconciliation, quorum, locality, and transport are handle/runtime behavior,
|
||||
not reasons for each application to create a private "truth" object and then a
|
||||
client projection copy.
|
||||
|
||||
A projection is a different state shape, not the normal visibility mechanism.
|
||||
Use projected documents or query surfaces for hidden-information filtering,
|
||||
derived aggregation, viewport/windowing, SoA/native memory layout, lossy UI
|
||||
summaries, Eve/CultUI surfaces, or temporary compatibility bridges. Do not
|
||||
project merely because a client needs to read daemon-owned state.
|
||||
|
||||
This rule is cross-runtime. C#, Rust, TypeScript, Unity, browser/WASM, and tool
|
||||
hosts should all express the same intent: grab a typed document/collection/query
|
||||
handle, read it for display, or mutate/submit through it when authority policy
|
||||
allows. CultMesh supplies the managed access conventions and generated handles
|
||||
that make that simple.
|
||||
|
||||
## 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(...)` 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`, `CultMeshDocumentHandle`, `CultMeshDocumentCatalog`, `CultMeshCollectionHandle`, `CultMeshStatePointer`, `CultMeshStateBindingDescriptor`, `CultMeshOperationBindingDescriptor`, `CultMeshOperationInvocationDescriptor`, native slice descriptors, authority claims, route hints, fluent context builders, and handle helpers like `CultMesh.rectFromBounds(...)`, `CultMesh.viewportRequest(...)`, `CultMesh.query(...)`, `CultMesh.querySource(...)`, `CultMesh.operation(...)`, `CultMesh.document(...)`, `CultMesh.documents(...)`, `CultMesh.collection(...)`, `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` builds Aetheria runtime surface source data, then lowers it to the canonical `GameCult.Eve.Surface.EveSurfaceDocument` published through CultMesh. CultMesh owns state and operation binding descriptors; Eve owns the surface document and CultUI composition model. Mesh no longer defines a parallel Eve surface DTO.
|
||||
- `E:/Projects/Eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs` also exposes component `EmbeddedDocuments` as first-class CultUI slots. Aetheria runtime surfaces mirror that shape with `AetheriaRuntimeEmbeddedDocumentSlot`, so daemon-owned UI can compose nested synced surfaces such as inventory dropdowns without a Unity-only model, facade, or projector.
|
||||
- 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 handle surface 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.
|
||||
|
||||
### Nested CultUI Surface Discovery
|
||||
|
||||
Nested CultUI surfaces are a shared Eve/CultMesh feature, not an Aetheria-only
|
||||
inventory workaround. Discovery starts in `E:/Projects/Eve/tools/parity/parity-manifest.json`,
|
||||
which lists the canonical `embedded-surface` fixture and the active runtime
|
||||
claims for web, Flutter desktop/Linux/Android, iOS/UIKit, Android/Kotlin, Unity
|
||||
UI Toolkit, and Rust document sync. A renderer that claims CultUI GUI parity must
|
||||
advertise `embeddedDocuments` there and require the `embedded-surface` fixture.
|
||||
Every target runtime must be named in that matrix as tested, pending, or
|
||||
unsupported; an omitted runtime is a broken discovery story, not a harmless
|
||||
documentation gap.
|
||||
|
||||
Run the shared evidence from the owning repos:
|
||||
|
||||
```powershell
|
||||
cd E:\Projects\Eve
|
||||
node --test web\eve-dsl.test.mjs
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1
|
||||
|
||||
cd E:\Projects\CultLib\packages\cultnet-rs
|
||||
cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync
|
||||
```
|
||||
|
||||
When Flutter is installed, also run from `E:/Projects/Eve/flutter/eve_parity`:
|
||||
|
||||
```powershell
|
||||
flutter test --plain-name embedded_surface_fixture_contract
|
||||
```
|
||||
|
||||
Aetheria's local proof stays thin: build the Unity package and run
|
||||
`Aetheria.State.Verify`. The verifier should prove Aetheria consumes the shared
|
||||
contract through `AetheriaRuntimeSurfaceDocuments`, `AetheriaRuntimeEmbeddedDocumentSlot`,
|
||||
and the Eve UI Toolkit lowerer, not through local projectors, facades, or
|
||||
adapter-shaped child state.
|
||||
|
||||
### 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 helper layers. 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 response document 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.query(...)` 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 query handle owns locality defaults. Because `CultMeshQuerySurface` preserves `sources` and `routeHint`, the RTS client exposes `queryDiagnostics()` 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
|
||||
using var client = await AetheriaClient.OpenAsync(statePath, runtimeId);
|
||||
var resolver = client.State.CreateEveSurfaceCultMeshStateRefResolver();
|
||||
var selected = resolver(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<T>`-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 query")
|
||||
.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 handle.
|
||||
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 handle surface 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 handle surface without manually
|
||||
constructing query contexts or knowing where the local publication files live.
|
||||
|
||||
The same rule applies to projected documents and collections. Browser,
|
||||
Electron, Unity, Eve, and tool runtimes should ask CultMesh for a typed
|
||||
document or collection handle, bind it to the Verse once, and then call
|
||||
`latest`, `watch`, `replace`, or `watchChanges`. Local CultCache records,
|
||||
remote Verse documents, shared-memory queries, and quorum-backed
|
||||
authorities are configuration details behind that handle. Generated Aetheria
|
||||
RTS handles should target this shape instead of emitting schema dictionaries,
|
||||
transport-specific lookups, or hand-written state-file readers.
|
||||
|
||||
## 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.
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
# 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();
|
||||
|
||||
var currentDockingBay = await aetheria.Current
|
||||
.DockingBay
|
||||
.LatestAsync();
|
||||
|
||||
using var inventorySubscription = aetheria.Current
|
||||
.Inventory
|
||||
.Watch(inventory => RenderInventory(inventory));
|
||||
|
||||
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();
|
||||
|
||||
const dockingBay = await aetheria.current()
|
||||
.dockingBay()
|
||||
.latest();
|
||||
|
||||
const stopInventory = aetheria.current()
|
||||
.inventory()
|
||||
.watch(renderInventory);
|
||||
|
||||
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, locality-specific wiring, and renderer-side facade adaptation.
|
||||
|
||||
The architectural correction is simple and strict: a normal gameplay feature has
|
||||
one canonical typed document. The daemon may be the authority for that document,
|
||||
but daemon authority is not a second state model. The shared runtime assembly
|
||||
defines the document, the daemon mutates/publishes it, and every runtime grabs a
|
||||
managed handle to that same type for display, input, prediction, or testing.
|
||||
Projection documents exist only when the state shape is intentionally different:
|
||||
hidden-information filtering, expensive aggregation, viewport/windowing,
|
||||
SoA/native layout, lossy UI summaries, Eve/CultUI surfaces, or compatibility
|
||||
bridges with a removal stage.
|
||||
|
||||
The worst current pattern is not one bad helper. It is a repeated protocol walk:
|
||||
|
||||
1. read one projected document;
|
||||
2. extract a record key, index, schema-specific row, or state reference;
|
||||
3. read another projected document or local frame;
|
||||
4. resolve the key through a renderer-local facade index;
|
||||
5. adapt the result into a domain object;
|
||||
6. wire manual refresh or blocking reads around the whole path.
|
||||
|
||||
That entire stack must collapse behind one typed CultMesh document, collection, query, or operation handle. If a client wants current docking, current inventory, current target, current stats, zone contacts, station refit state, or visible render objects, the caller should make one semantic CultMesh call and receive a typed reactive value. CultMesh owns cache hydration, routing, sync, invalidation, watch lifetimes, schema binding, derived-state execution, and locality choice.
|
||||
|
||||
The facade/projector/adapter/surface-builder chain is no longer an acceptable
|
||||
style. A single adapter at a real boundary can be honest; a chain of translation
|
||||
objects to recover one domain value is heretek architecture. Replace it with a
|
||||
canonical document handle, a generated typed query/operation handle, a state
|
||||
pointer, or a native view descriptor.
|
||||
|
||||
## 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.
|
||||
8. Client code must not manually compose state access from multiple protocol layers. A semantic state read is one CultMesh call from the caller's point of view.
|
||||
9. Renderer-local object facades are presentation caches. They are not the API for game state, inventory, docking, targeting, or station services.
|
||||
10. Do not split "daemon truth" from "CultMesh typed state" for ordinary gameplay. Use one canonical document, then let authority policy decide who can mutate, predict, or only observe it.
|
||||
11. Add projections only for a distinct derived/filter/window/native/UI shape. "A client needs to read it" is not a projection justification.
|
||||
12. Do not add facade/projector/adapter/surface-builder chains. One real boundary adapter is allowed; stacked translation layers are a missing CultMesh primitive.
|
||||
|
||||
## 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.
|
||||
- No new client API may require a caller to join projected documents, frame rows, record keys, and renderer-local facades to obtain one domain value.
|
||||
- No new feature guide, example, or verifier rule may describe separate daemon
|
||||
truth and client typed state when one canonical document would work.
|
||||
- No verifier may require a facade/projector/adapter/surface-builder chain as
|
||||
proof of correctness. It should require the semantic typed handle and reject
|
||||
the translation chain.
|
||||
- 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<string, object?>` 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 CultMesh for typed query surfaces or projected documents when
|
||||
the requested shape is derived, filtered, windowed, or native-layout-specific.
|
||||
They do not reconstruct world truth from frames unless they are inside a
|
||||
generated/internal query executor, and they do not add projections for canonical
|
||||
state that a typed document handle can expose directly.
|
||||
|
||||
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 3A: Collapse State Access To Typed Reactive Handles
|
||||
|
||||
Status: started; `AetheriaRuntimeVerseClient.Aetheria()` now exposes the first
|
||||
C# domain facade for current, station, and zone projected documents,
|
||||
`AetheriaClient` delegates to that shared facade, and the broad Unity
|
||||
menu/HUD/map/render projected-state reads now use typed state handles instead
|
||||
of transitional `Current*Async()`, `StationRefitAsync()`, `SectorMapAsync()`,
|
||||
`ZoneContactsAsync()`, or `ZoneRenderAsync()` helpers. `AetheriaClientState`
|
||||
also indexes projected state documents by shared document type so callers can
|
||||
retrieve or watch a known typed document with one call while CultMesh owns the
|
||||
bound live feed. The facade also indexes those handles by `CultDocument`
|
||||
schema name/version and can adapt a handle to a different local CLR document
|
||||
type with the same schema identity, so caller code can start treating shared
|
||||
schema identity as the contract even before CultMesh owns alias conversion
|
||||
directly.
|
||||
|
||||
Goal: every client-facing state access becomes a single CultMesh document, collection, query, or pointer call. The caller names the domain state it wants and gets a typed reactive value. It does not manually inspect daemon frames, current-state projections, station rows, record keys, local facade indexes, or transport route details.
|
||||
|
||||
Targets:
|
||||
|
||||
- Current entity.
|
||||
- Current docking.
|
||||
- Current docking bay.
|
||||
- Current inventory.
|
||||
- Current target and target details.
|
||||
- Current ship settings.
|
||||
- Station refit state.
|
||||
- Trade cargo targets.
|
||||
- Zone contacts.
|
||||
- Selected object.
|
||||
- Runtime catalog collections.
|
||||
- Player settings and input bindings.
|
||||
|
||||
Desired C# shape:
|
||||
|
||||
```csharp
|
||||
var current = client.Aetheria().Current;
|
||||
|
||||
using var docking = current.Docking.Watch(RenderDocking);
|
||||
using var bay = current.DockingBay.Watch(RenderDockingBay);
|
||||
using var inventory = current.Inventory.Watch(RenderInventory);
|
||||
|
||||
var refit = await client.Aetheria()
|
||||
.Station
|
||||
.Refit
|
||||
.LatestAsync();
|
||||
|
||||
var contacts = await client.Aetheria()
|
||||
.LatestAsync<AetheriaRuntimeZoneContactsDocument>();
|
||||
|
||||
var contactsHandle = client.Aetheria()
|
||||
.DocumentBySchema(AetheriaRuntimeDaemonSchemas.ZoneContacts);
|
||||
|
||||
using var render = client
|
||||
.Watch<AetheriaRuntimeZoneRenderDocument>(RenderZone);
|
||||
```
|
||||
|
||||
Desired TS shape:
|
||||
|
||||
```ts
|
||||
const current = client.aetheria().current();
|
||||
|
||||
const stopDocking = current.docking().watch(renderDocking);
|
||||
const stopBay = current.dockingBay().watch(renderDockingBay);
|
||||
const stopInventory = current.inventory().watch(renderInventory);
|
||||
|
||||
const refit = await client.aetheria()
|
||||
.station()
|
||||
.refit()
|
||||
.latest();
|
||||
```
|
||||
|
||||
Work:
|
||||
|
||||
- Keep pushing shared CultMesh typed reactive document and collection handles down. Typed document handles and schema-compatible CLR alias lookup now live in CultMesh through `CultMeshDocumentHandle<TDocument>` and `CultMeshDocumentCatalog`; remaining work should remove client-side joins and generated facade gaps rather than rebuilding schema dictionaries in Aetheria.
|
||||
- Generate Aetheria current/station/zone/catalog accessors from schema metadata.
|
||||
- Move derived-state joins, such as current docking bay from current docking plus station refit plus entity records, into generated/internal query executors.
|
||||
- Replace remaining Unity menu/HUD/render calls to `TryResolve*` and `_observedFacadeIndex` with typed handles or generated/internal render adapters.
|
||||
- Replace blocking `GetAwaiter().GetResult()` reads in client presentation code with watch/latest handles that own lifetimes.
|
||||
- Keep renderer-local facade indexes only inside render adapter internals while native/query views are still being migrated.
|
||||
- Add verifiers that treat multi-hop state reads in client code as failures.
|
||||
|
||||
Gates:
|
||||
|
||||
- `InventoryMenu`, `InventoryPanel`, `TradeMenu`, `LocalMenu`, `SchematicDisplay`, `SectorRenderer`, `MapRenderer`, and `ZoneRenderer` do not manually join state projections to obtain domain values.
|
||||
- Shared projected documents are reachable through `client.Aetheria().Document<TDocument>()`, including same-schema CLR aliases, `client.Aetheria().DocumentBySchema(schemaVersion)`, `client.Aetheria().LatestAsync<TDocument>()`, and `client.Aetheria().Watch<TDocument>()`; the underlying type/schema lookup is delegated to CultMesh rather than an Aetheria-owned schema registry.
|
||||
- Client-facing code contains no `_observedFacadeIndex.TryResolve*` outside Unity render adapter internals.
|
||||
- Client-facing code contains no `ResolveClient().Current*Async().GetAwaiter().GetResult()` or equivalent blocking state reads.
|
||||
- `TryGetTypedCurrentDockingBayFacade` and similar transitional helpers are deleted rather than renamed.
|
||||
- Verifiers assert the semantic API shape, not the transitional facade-adaptation scaffolding.
|
||||
|
||||
## 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<T>`/`NativeSlice<T>` 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.
|
||||
- Aetheria owns committed physics state and performs gameplay physics queries
|
||||
and integration through Ymir.
|
||||
- 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.
|
||||
- Hermodr can reconstruct the RTS gameplay surface as an unspecialized Eve
|
||||
lowering from daemon API, Eve/CultUI, render-field declarations, and CultMesh
|
||||
CDN assets; Electron may be the shipped RTS shell but cannot own private
|
||||
gameplay interpretation.
|
||||
- Godot can become the ARPG equivalent conformance target by lowering the
|
||||
daemon-authored ARPG Eve/CultUI contract, not by inheriting Unity scene
|
||||
authority or triggering daemon-side runtime branches.
|
||||
- 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.
|
||||
|
|
@ -1,570 +0,0 @@
|
|||
# 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 spatial computation remains a C# or Unity-shaped side service. The C# Ymir contracts and Unity bridge are useful reference material, but the rebuilt daemon needs an embedded Rust Ymir kernel behind its injected physics ports.
|
||||
|
||||
## 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["Daemon sim\nmovement, AI, combat, contacts"]
|
||||
Frame["Frame document\nrun snapshot + facts"]
|
||||
Projection["Game viewport documents\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 daemon-owned Aetheria simulation, 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 and reads daemon-authored managed documents
|
||||
through the CultMesh document catalog.
|
||||
- TS decodes the daemon-authored document slot layout, but must not duplicate
|
||||
projection authority or rebuild view/state from raw daemon frames.
|
||||
- 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, and the obsolete remote committed-fact import behavior that must become Pilot candidate selection plus Commander finality. |
|
||||
| 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/document fetch to preserve; remote committed-fact import is migration evidence, not target authority. |
|
||||
| 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. |
|
||||
| Daemon sim | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs` | Gameplay intents, hostile AI, combat meaning, heat, and projection of mandatory Ymir world results. |
|
||||
| Ymir world adapter | `Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs` | Projects daemon state into Ymir. Positive daemon gravity-depth magnitudes become positive attracting radial strengths; the adapter returns body, contact, position, and velocity facts for the daemon to commit without owning sign policy. |
|
||||
| Game viewport documents | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.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.Web/Electron/aetheria-rts-local-documents.ts` | Current Electron command sending, CultMesh document catalog/query reads, and typed daemon document decoding. |
|
||||
| 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 maps daemon SoA bodies into typed 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. |
|
||||
|
||||
## Combat Ownership
|
||||
|
||||
Combat has one live writer in `AetheriaRuntimeDaemonSimulation`. Projectile
|
||||
movement and contact are delegated through `IAetheriaRuntimeProjectilePhysics`
|
||||
to Ymir. The abandoned abstract pressure-model kernel was deleted rather than
|
||||
ported; it had no production caller and would have created a second authority
|
||||
over contacts, damage, heat, and weapon cooldowns.
|
||||
|
||||
## 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 daemon-owned Aetheria simulation.
|
||||
- Build a daemon frame.
|
||||
- Publish committed command facts.
|
||||
- Import remote committed command facts (current behavior to delete or
|
||||
quarantine; target Pilot output enters before finality as candidate
|
||||
evidence and Commander correction/replay selects one canonical fact).
|
||||
- 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 --> Sim
|
||||
Sim --> 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.
|
||||
- The Aetheria simulation owns committed physical truth and performs stepping, overlaps, casts, contacts, broadphase, and spatial-field computation through Rust Ymir.
|
||||
- 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 the calling simulation transaction
|
||||
- `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 committed physical truth belongs to the active Aetheria simulation
|
||||
authority. Its Rust daemon invokes Ymir through a narrow in-process port and
|
||||
commits the result in the same tick. A remote implementation may preserve that
|
||||
port, but must not create a parallel Aetheria world or writer.
|
||||
|
||||
## 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 = YmirPhysicsQueries.OverlapCircle(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
|
||||
using var client = await AetheriaClient.OpenAsync(statePath, runtimeId);
|
||||
var resolver = client.State.CreateEveSurfaceCultMeshStateRefResolver();
|
||||
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.
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# 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. The Rust daemon owns committed physical truth and performs step, overlap,
|
||||
cast, contact, and broadphase computation through embedded Rust Ymir.
|
||||
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 reintroduces bespoke endpoint transport 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.
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
# 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
|
||||
Pilot daemon candidate evidence -> Commander selection/finality -> shared 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 local 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 local viewport hook | `Aetheria.State.Daemon/Program.cs:420`, `:501`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs` | Daemon snapshot hook now delegates viewport construction to the shared typed `AetheriaRuntimeGameViewportDocuments` 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 local 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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaRuntimeGameViewportDocuments.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/AetheriaUnityObservedZoneContextFactory.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, game viewport payloads, nested daemon snapshot payloads, Verse
|
||||
authority policy, authority rule, and authority lease;
|
||||
- `AetheriaRuntimeGameViewportDocuments` 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 game viewport construction to
|
||||
`AetheriaRuntimeGameViewportDocuments` instead of carrying map projection logic
|
||||
inline;
|
||||
- `Aetheria.State.AuthoritySmoke` verifies the projection behavior against a
|
||||
local daemon frame;
|
||||
- Electron map reads now fetch daemon-authored managed viewport documents
|
||||
through CultMesh query handles instead of rebuilding viewports locally from
|
||||
the latest daemon frame;
|
||||
- Electron selected-object, inventory/cargo, daemon health, and authority status
|
||||
panels now decode typed daemon documents instead of deriving panel state
|
||||
sideways from the map payload;
|
||||
- Electron map/panel reads use the CultMesh document catalog and managed query
|
||||
records instead of loopback `sendSnapshotRequest` or local publication reader
|
||||
wrappers;
|
||||
- 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 local 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.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,221 +0,0 @@
|
|||
# Verse Authority Policy
|
||||
|
||||
Aetheria authority is Verse policy, not topology. A Raven Unity client, a
|
||||
Starfire RTS client, a headless host, and later an MMO witness quorum should all
|
||||
use the same policy boundary:
|
||||
|
||||
```text
|
||||
typed operation
|
||||
-> resolve subject and claim kind
|
||||
-> resolve authority mode from the active Verse policy
|
||||
-> build immutable tick envelope and jurisdiction assignment
|
||||
-> simulate Commander default candidate
|
||||
-> accept, compare, defer, or reject Pilot candidate
|
||||
-> correct/replay Commander provisional state on validated mismatch
|
||||
-> commit one finalized fact
|
||||
```
|
||||
|
||||
Starbridge does not use quorum consensus on the hot path. The Commander daemon
|
||||
simulates everything by default and owns the canonical log. Policy assigns
|
||||
prediction jurisdiction to Pilot daemons for their own ship, daemon-assigned
|
||||
nearest environment entities, and assigned combat engagements. This is the
|
||||
reverse of ordinary server-authoritative client prediction: when a valid Pilot
|
||||
result disagrees, the Commander daemon corrects to the Pilot result.
|
||||
|
||||
## Policy Modes
|
||||
|
||||
The policy schema names the authority shapes Aetheria needs without requiring
|
||||
all of them to be implemented at once.
|
||||
|
||||
- `any-trusted-runtime`: a trusted runtime may submit matching operations or
|
||||
candidates. It does not gain canonical finality.
|
||||
- `host-authoritative`: only the configured host runtime is eligible to produce
|
||||
matching candidates or operations.
|
||||
- `delegated-runtime`: one configured runtime may submit matching operations or
|
||||
candidates.
|
||||
- `owning-runtime`: the runtime assigned to the subject is eligible to produce
|
||||
matching candidates or operations. This is schema-level intent for now;
|
||||
ownership resolution must be supplied before this mode is enabled.
|
||||
- `interest-lease`: a runtime with a matching unexpired lease may produce a
|
||||
prediction candidate for the leased fact slots. The lease grants neither log
|
||||
mutation nor finality.
|
||||
- `witness-quorum`: eligible witnesses publish observations; policy forms a
|
||||
candidate; authority commits a fact. This is not implemented in the current
|
||||
Aetheria hot path.
|
||||
- `operator-finality`: operator or host finality is required before the fact is
|
||||
committed.
|
||||
- `mergeable-crdt`: independently authored state converges through explicit
|
||||
merge laws. This is for state with real merge semantics, not simulation facts.
|
||||
|
||||
Unsupported modes fail closed with an explicit policy rejection. This keeps the
|
||||
schema open without pretending future authority structures already exist.
|
||||
Eligibility never implies direct canonical-log mutation; the active fact
|
||||
strategy still decides candidate comparison and finality.
|
||||
|
||||
The implementation order is product-grounded: Terminus proves local authority,
|
||||
Arena proves server authority, and Starbridge proves Commander-default,
|
||||
Pilot-corrected mixed authority. Witness-authoritative operation begins only
|
||||
after the Arena and Starbridge configurations pass live, restart, deterministic
|
||||
replay, and negative-authority proofs through this same typed policy seam.
|
||||
|
||||
## Command Claims
|
||||
|
||||
Daemon commands are lowered to a subject and claim kind before execution:
|
||||
|
||||
```text
|
||||
subject key: entity/run/zone/item key affected by the operation
|
||||
claim kind: movement, targeting, combat, inventory, economy, ai, metadata...
|
||||
author runtime: command.AuthorRuntimeId, falling back to ClientId
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
SetMoveVector(entity.player.raven)
|
||||
-> subject entity.player.raven
|
||||
-> claim movement
|
||||
|
||||
SetTarget(entity.player.raven -> entity.hostile.3)
|
||||
-> subject entity.player.raven
|
||||
-> claim targeting
|
||||
|
||||
TradePurchase(station -> player cargo)
|
||||
-> subject target/destination entity
|
||||
-> claim economy
|
||||
```
|
||||
|
||||
The command executor only sees authorized commands. It should remain boring:
|
||||
validate payload shape, mutate the run, emit intents. Authority decisions belong
|
||||
to the policy router.
|
||||
|
||||
## Starbridge Prediction Jurisdiction
|
||||
|
||||
The Commander daemon owns default simulation and finality for every fact slot.
|
||||
Policy narrows the slots where a Pilot candidate can correct that default:
|
||||
|
||||
```text
|
||||
entity.player.raven
|
||||
simulation-fact.*
|
||||
delegated-runtime: raven-unity
|
||||
|
||||
environment.assignment.raven.*
|
||||
simulation-fact.*
|
||||
interest-lease
|
||||
|
||||
engagement.assignment.raven.*
|
||||
simulation-fact.*
|
||||
delegated-runtime: raven-unity
|
||||
|
||||
*
|
||||
simulation-fact.*
|
||||
host-authoritative: commander-daemon
|
||||
```
|
||||
|
||||
The Commander player owns operation/claim authorship for everything outside
|
||||
Pilot jurisdiction; the Commander daemon still simulates the default result for
|
||||
all slots. Nearest-entity and engagement assignments are Commander-published
|
||||
typed state with an epoch and exact effective tick. A Pilot cannot calculate a
|
||||
larger jurisdiction from its divergent local positions. Overlap is resolved to
|
||||
one holder before the tick, never by packet arrival order.
|
||||
|
||||
Commander and Pilot candidates are comparable only when they share the exact
|
||||
session, simulation/content version, state root, run/jurisdiction epoch,
|
||||
tick/substep, fact kind, canonical subject, engagement/claim id, causal input
|
||||
set, and deterministic seed position. Equality is typed semantic equality for
|
||||
that fact kind. Presentation differences do not veto gameplay facts.
|
||||
|
||||
Inside an open Pilot slot, the Commander result is provisional. A valid
|
||||
different Pilot result wins; validation proves producer identity, versions,
|
||||
state/input roots, jurisdiction, causal inputs, schema, and gameplay invariants.
|
||||
It does not require agreement with the Commander output. The Commander daemon
|
||||
rolls back or corrects and deterministically replays dependent provisional
|
||||
state.
|
||||
|
||||
The tick envelope names the deterministic barrier/deadline policy and eligible
|
||||
candidate set. The finality receipt records how that set closed. If no eligible
|
||||
Pilot candidate is present when the barrier closes, the Commander result
|
||||
stands. Replay consumes the recorded candidate/selection chronology rather than
|
||||
re-running wall-clock or packet timing. Restart preserves whether a slot is
|
||||
open or closed plus its eligible candidates. Late candidates cannot rewrite
|
||||
final history. Disconnect or seat transfer changes jurisdiction only at a
|
||||
committed future epoch.
|
||||
|
||||
## Simulation Host Packaging
|
||||
|
||||
Aetheria should not require the current C# daemon executable to be the only
|
||||
packaging shape forever. It does require the Aetheria simulation host role to be
|
||||
daemon-shaped: a runtime that owns the typed state graph, applies Verse policy,
|
||||
advances simulation, publishes high-performance views, serves assets, and emits
|
||||
Eve/CultUI surfaces.
|
||||
|
||||
A standalone process, embedded native host, or future WASM/native kernel can
|
||||
package that role. Those are deployment choices, not gameplay authority modes.
|
||||
Unity, Godot, Electron, Hermodr, and browser shells are render/input lowerers
|
||||
unless they are explicitly running the daemon-shaped simulation host behind the
|
||||
same typed CultMesh boundary. A renderer does not gain authority because it
|
||||
spawned, embedded, or happens to sit beside the host.
|
||||
|
||||
The important boundary is not the executable. It is the policy document:
|
||||
|
||||
```text
|
||||
runtime CultMesh client
|
||||
-> typed operation or prediction candidate
|
||||
-> Verse jurisdiction and candidate gate
|
||||
-> Commander default simulation and candidate selection
|
||||
-> deterministic correction/replay when Pilot wins
|
||||
-> one finalized fact log and local projections
|
||||
```
|
||||
|
||||
If no standalone daemon process is deployed, an embedded host may still run the
|
||||
same Commander-daemon simulation/finality role and publish the same typed
|
||||
documents. The client shell remains a renderer/input surface. Pilot simulation
|
||||
may also be embedded beside its renderer, but its durable output is candidate
|
||||
evidence; only the Commander daemon appends finalized state.
|
||||
|
||||
## Current Local-Mirror Constraint
|
||||
|
||||
The current C# implementation is not a pure remote-client architecture yet.
|
||||
Unity can select a remote CultMesh Verse, but gameplay boot still requires that
|
||||
Verse to be hydrated into a readable local CultCache replica before the Unity
|
||||
shell starts. The Electron Starbridge client likewise uses local daemon
|
||||
publications while using CultMesh for typed command submission.
|
||||
|
||||
That constraint is acceptable as a migration bridge, but it must not become the
|
||||
architecture. The end state is:
|
||||
|
||||
```text
|
||||
CultMesh transport/subscription
|
||||
-> typed Verse documents and operations
|
||||
-> local runtime projection/cache when useful
|
||||
-> renderer/input surface
|
||||
```
|
||||
|
||||
The local cache is an optimization and offline/debugging aid. It is not the
|
||||
authority boundary.
|
||||
|
||||
## Rust/WASM Simulation Kernel Migration
|
||||
|
||||
If browser-only clients are expected to take over simulation authority when no
|
||||
local .NET daemon is available, the daemon should be split into an embeddable
|
||||
simulation kernel and thin host shells. Rust is the strongest candidate for that
|
||||
kernel because the same core can target native hosts and WebAssembly without
|
||||
preserving the current local process/file assumptions.
|
||||
|
||||
The staged migration is:
|
||||
|
||||
1. Freeze the simulation contract in typed CultMesh/CultCache documents:
|
||||
command inputs, authority policy inputs, tick inputs, committed facts,
|
||||
frame/projection outputs, and deterministic error outputs.
|
||||
2. Keep the C# daemon as the compatibility host while extracting every hot
|
||||
simulation decision behind that contract.
|
||||
3. Port the deterministic simulation kernel to Rust with matching schema
|
||||
bindings and fixture-based parity tests against the C# host.
|
||||
4. Compile the Rust kernel to native and WASM. Native and WASM hosts package
|
||||
the same daemon-shaped simulation role behind the same typed CultMesh
|
||||
contract.
|
||||
5. Replace local-file-only readers with CultMesh subscriptions plus optional
|
||||
local cache hydration, so remote Verse access does not require a C# process
|
||||
on the same machine.
|
||||
|
||||
The C# daemon should then become one host shell among several, not the canonical
|
||||
definition of simulation authority.
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# Aetheria Verse Daemon Shape
|
||||
|
||||
Aetheria should follow the Odin/VoidBot daemon shape instead of growing a private Unity-shaped command system.
|
||||
|
||||
## Authority
|
||||
|
||||
Odin is the all-seer, not the owner. It discovers Aetheria's provider advertisement and interface bindings, indexes the schema/catalog surface, and gives agents a registry overview. Idunn keeps the daemon alive from daemon-published health and command-boundary records. Bifrost hosts the MCP crossing for Codex and other xeno agents. Eve/CultUI defines presentation shape. The Aetheria daemon remains the side-effect owner.
|
||||
|
||||
The daemon owns authority over Aetheria simulation state, typed command
|
||||
application, health publication, command boundaries, provider advertisement,
|
||||
and provider-owned Eve GUI/TUI surfaces. Authority does not imply a private
|
||||
daemon truth plus a separate client state copy. Wherever possible, the daemon
|
||||
mutates and publishes the canonical shared typed document, and Unity, a browser,
|
||||
a terminal, Blender, or another runtime grabs a managed handle to that same
|
||||
document type through CultMesh.
|
||||
|
||||
Projection documents are for different shapes: hidden-information filtering,
|
||||
derived aggregation, viewport/windowing, SoA/native render layout, lossy UI
|
||||
summaries, Eve/CultUI surfaces, and named compatibility bridges. A projection is
|
||||
not required merely because a non-daemon runtime needs to read state.
|
||||
|
||||
Witness-authoritative networking sharpens this rule. Unity should not be a dumb terminal; it may hold local projection caches, prediction state, render-native SoA views, and eventually immutable witness observations for facts it could actually observe under the active Verse rules. Those local records are admissible testimony, not committed truth. Observation, prediction, consensus candidates, and committed facts are separate stages. Unity can lower input documents, predict local presentation, and publish typed witness documents through CultMesh, but it must not mutate canonical Aetheria state or promote a local projection cache into authority.
|
||||
|
||||
Unity-side entity graph creation must name the boundary it is crossing. `EntityConstructionBlueprintProjector.InstantiateAuthoritativeFromBlueprint` is for local authoritative construction paths that still need to be migrated into the daemon; daemon frames use `EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint` to lower observed state into Unity objects. A Unity frame consumer must not call the authoritative construction entry point.
|
||||
|
||||
The same rule applies at the galaxy shell. `Galaxy.ProjectObservedSectorMap` may build a temporary Unity navigation/rendering projection from the typed sector-map document. Main menu boot must not call a `Galaxy` constructor directly, run procedural generation, or read whole daemon frames for this path; the daemon owns the run and Unity lowers typed projection state. The Unity-side handle is `AetheriaUnityObservedRunProjection`, not an `ActionGameManager` gameplay property, because the graph is a quarantined scene adapter rather than portable client state.
|
||||
|
||||
VoidBot's Odin MCP shape is the useful boundary model for agents: list providers through Odin, list Verses through Odin, load a provider-owned interface context through Odin, and invoke only commands that the provider interface advertises. The MCP is not a Brokkr special case and not a raw daemon socket wrapper.
|
||||
|
||||
Brokkr is just one daemon that happens to publish Unity editor surfaces. Aetheria should behave the same way: publish provider advertisements and Eve GUI/TUI surfaces into the local Verse, let Odin index them, and let Bifrost expose ergonomic MCP tools that lower or invoke those surfaces without taking ownership.
|
||||
|
||||
## Publications
|
||||
|
||||
Required Aetheria daemon publications are the live typed schemas in `AetheriaRuntimeDaemonSchemas`:
|
||||
|
||||
- `gamecult.aetheria.daemon_provider_advertisement.v1`: Verse id, provider ids, daemon id, schema catalog, state witnesses, Eve surface keys, command boundaries, and transport profile metadata.
|
||||
- `gamecult.aetheria.daemon_health.v1`: daemon id, Verse id, state path, frame id, command acceptance status, simulation status, publication source, transport, and command-boundary path.
|
||||
- `gamecult.aetheria.daemon_command_boundary.v1`: typed command schemas accepted by the daemon, authority requirements, receipt schema, and delivery transport.
|
||||
- `gamecult.aetheria.daemon_frame.v1` plus `gamecult.aetheria.daemon_soa_view.v1`: authoritative simulation state for renderer clients.
|
||||
- `gamecult.aetheria.daemon_game_surface.v1` and `gamecult.aetheria.daemon_editor_surface.v1`: GUI/TUI views of the same provider-owned state.
|
||||
|
||||
The provider advertisement is not a dashboard index. It is the daemon's promise about what it owns, where its witnesses live, what command boundary it accepts, and what transports can reach it.
|
||||
|
||||
Aetheria currently has two advertisement layers during the bridge:
|
||||
|
||||
- `gamecult.eve.provider_advertisement.v1` is the Odin-visible Eve provider card for the Aetheria Verse daemon and compatibility surfaces such as catalog, operations, and player settings.
|
||||
- `gamecult.aetheria.daemon_provider_advertisement.v1` is the daemon-owned Aetheria runtime contract for authoritative frames, SoA views, game/editor GUI surfaces, game/editor TUI surfaces, health, and command boundaries.
|
||||
|
||||
The bridge layer must point at daemon-owned witnesses instead of becoming a second source of truth. Long term, Odin should discover the daemon-owned provider advertisement and interface bindings directly; the older state-host card should become a compatibility adapter or disappear.
|
||||
|
||||
## Queues
|
||||
|
||||
Queues are an implementation detail. Eve commands and daemon commands are typed `gamecult.eve.command.v1` and `gamecult.aetheria.daemon_command.v1` records in the Aetheria state graph; the old `.pending` runtime commit outbox is gone. None of those folders are the public API, daemon identity, or a substitute for CultNet/CultMesh typed records. Anything still named `Queue*`, `CommandLog`, `Inbox`, `mailbox`, `.eve.commands`, `.daemon.commands`, `.cc.pending`, or `PendingCultCacheStore` in Unity-facing daemon/Eve command code should lower to typed command documents and be demoted behind typed operation methods as the CultNet command boundaries come online.
|
||||
|
||||
`AetheriaRuntimeVerseClient` is the only shared submission boundary for typed command records. Client-side command lowerers may use typed runtime clients over that same Verse client. Do not add command ports, cached submitters, mailboxes, or queue-like buses between clients and the typed Verse graph. They must not drain those records or mutate state in response; ownership stays with the daemon Verse member.
|
||||
|
||||
Migration rule: do not expose string command names and payload maps as public Aetheria APIs. Compatibility adapters may decode external Eve requests at the edge, but persisted Aetheria command documents and Unity gameplay calls should use typed command bodies and typed operation methods.
|
||||
|
||||
Daemon-published game surface buttons select typed daemon command documents by command id (`aetheria.daemon.commands.*`). They must not smuggle daemon command fields through generic Eve payload maps such as `commandKind`, `weaponGroup`, or `scalarValue`; richer commands need explicit typed body builders.
|
||||
|
||||
Unity menu handlers should not construct Aetheria command bodies by spelunking `EveSurfaceCommandRequest.Payload`. Pass the request to the shared typed command client at the Eve edge, then submit the resulting `gamecult.eve.command.v1` or `gamecult.aetheria.daemon_command.v1` document.
|
||||
|
||||
## Cut Line
|
||||
|
||||
If a status/control surface matters to operators or agents, publish it as typed CultCache state or a `.cc` witness plus an Eve/CultUI CultMesh surface. Do not add a private HTTP dashboard, Unity-only inspector, JSON status blob, or agent-specific daemon wrapper as canonical truth.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue