Move RTS body icon authority to daemon

This commit is contained in:
Meta Vens 2026-07-05 15:12:29 +01:00
commit 91b79265b9
13 changed files with 125 additions and 35 deletions

BIN
Aetheria RTS.lnk Normal file

Binary file not shown.

View file

@ -339,6 +339,7 @@ export type BodyView = {
radius: number;
isAsteroidBelt: boolean;
iconAsset: AssetRef;
iconSize: number;
};

View file

@ -70,6 +70,19 @@ type AetheriaPublicationDocumentSpec = CultMeshPublicationDocumentBinding & {
readonly remoteRecordKey?: string;
};
type AetheriaRuntimeRtsQueryExecutors = {
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;
@ -211,6 +224,7 @@ export class AetheriaCultMeshClient {
starbridgeSession: async () => readStarbridgeSessionSummaryDocument(await this.fetchStarbridgeSessionSummaryDocument()),
assetManifest: async () => readAssetManifestDocument(await this.fetchAssetManifestDocument()),
};
this.queryExecutors = executors;
this.queries = createAetheriaRuntimeRtsQueryHandles(
executors,
this.queryVerse.context.routeHint,
@ -251,6 +265,7 @@ export class AetheriaCultMeshClient {
private readonly publicationDescription: string;
private readonly publications: CultMeshDocumentCatalog;
private readonly queryExecutors: AetheriaRuntimeRtsQueryExecutors;
private readonly queries: ReturnType<typeof createAetheriaRuntimeRtsQueryHandles>;
private readonly documents: AetheriaRuntimeRtsDocuments;
private readonly operations: AetheriaRuntimeRtsOperationHandles;
@ -278,27 +293,27 @@ export class AetheriaCultMeshClient {
}
public async mapViewport(request: ViewportRequest): Promise<ViewportResponse> {
return this.aetheria.zone().viewport.within(request);
return this.queryExecutors.mapViewport(request);
}
public async objectsViewport(request: ViewportRequest): Promise<ObjectsViewportResponse> {
return this.aetheria.zone().objects.visibleWithin(request);
return this.queryExecutors.objectsViewport(request);
}
public async gravityViewport(request: ViewportRequest): Promise<GravityViewportResponse> {
return this.aetheria.zone().gravity.within(request);
return this.queryExecutors.gravityViewport(request);
}
public async renderSplatsViewport(request: ViewportRequest): Promise<RenderSplatsViewportResponse> {
return this.aetheria.zone().renderSplats.within(request);
return this.queryExecutors.renderSplatsViewport(request);
}
public async selectedObject(request: SelectedObjectRequest): Promise<SelectedObjectDocument> {
return this.aetheria.selectedObject(request.entityIndex);
return this.queryExecutors.selectedObject(request);
}
public async inventory(request: SelectedObjectRequest): Promise<InventoryDocument> {
return this.aetheria.inventory(request.entityIndex);
return this.queryExecutors.inventory(request);
}
public async daemonHealth(): Promise<DaemonHealthDocument> {
@ -533,16 +548,16 @@ export class AetheriaCultMeshClient {
selectedObject,
inventory,
] = await Promise.all([
this.aetheria.zone().gravity.within(request.viewport),
this.aetheria.daemon.health(),
this.aetheria.daemon.authorityStatus(),
this.aetheria.daemon.starbridgeSession(),
this.aetheria.daemon.assetManifest(),
this.queryExecutors.gravityViewport(request.viewport),
this.queryExecutors.daemonHealth(),
this.queryExecutors.authorityStatus(),
this.queryExecutors.starbridgeSession(),
this.queryExecutors.assetManifest(),
selectedEntityIndex >= 0
? this.aetheria.selectedObject(selectedEntityIndex)
? this.queryExecutors.selectedObject({ entityIndex: selectedEntityIndex })
: Promise.resolve(null),
selectedEntityIndex >= 0
? this.aetheria.inventory(selectedEntityIndex)
? this.queryExecutors.inventory({ entityIndex: selectedEntityIndex })
: Promise.resolve(null),
]);
@ -658,14 +673,14 @@ function normalizeDaemonTarget(target: string | AetheriaCultMeshDaemonTarget): R
async function retryTransientPublicationRead<T>(read: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < 8; attempt += 1) {
for (let attempt = 0; attempt < 40; attempt += 1) {
try {
return await read();
} catch (error) {
lastError = error;
if (!isTransientPublicationReadError(error))
throw error;
await delay(20 + attempt * 15);
await delay(25 + attempt * 8);
}
}
throw lastError;
@ -673,7 +688,10 @@ async function retryTransientPublicationRead<T>(read: () => Promise<T>): Promise
function isTransientPublicationReadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("EBUSY") || message.includes("EPERM") || message.includes("ENOENT");
return message.includes("EBUSY") ||
message.includes("EPERM") ||
message.includes("ENOENT") ||
message.includes("did not contain schema");
}
function buildMainMenuSurface(

View file

@ -254,7 +254,8 @@ export type AetheriaRuntimeDaemonFrameDocumentSlot =
| "rejectedImportedFactIds"
| "duplicateImportedFactIds"
| "cumulativeImportedFactIds"
| "cumulativeRejectedImportedFactIds";
| "cumulativeRejectedImportedFactIds"
| "renderSettings";
export const aetheriaRuntimeDaemonFrameDocumentSlots: Record<AetheriaRuntimeDaemonFrameDocumentSlot, number> = {
schema: 0,
@ -278,6 +279,7 @@ export const aetheriaRuntimeDaemonFrameDocumentSlots: Record<AetheriaRuntimeDaem
duplicateImportedFactIds: 18,
cumulativeImportedFactIds: 19,
cumulativeRejectedImportedFactIds: 20,
renderSettings: 21,
};
export type AetheriaRuntimeDaemonHealthDocumentSlot =
@ -1099,7 +1101,8 @@ export type AetheriaRuntimeBodySnapshotCommitSlot =
| "gravityWellDepth"
| "gravityWaveRadius"
| "gravityWaveDepth"
| "gravityWaveSpeed";
| "gravityWaveSpeed"
| "iconSize";
export const aetheriaRuntimeBodySnapshotCommitSlots: Record<AetheriaRuntimeBodySnapshotCommitSlot, number> = {
kind: 1,
@ -1122,6 +1125,7 @@ export const aetheriaRuntimeBodySnapshotCommitSlots: Record<AetheriaRuntimeBodyS
gravityWaveRadius: 19,
gravityWaveDepth: 20,
gravityWaveSpeed: 21,
iconSize: 22,
};
export type AetheriaRuntimeSunVisualCommitSlot =
@ -1322,7 +1326,8 @@ export type AetheriaRuntimeRtsBodyViewSlot =
| "radius"
| "isAsteroidBelt"
| "body"
| "iconAsset";
| "iconAsset"
| "iconSize";
export const aetheriaRuntimeRtsBodyViewSlots: Record<AetheriaRuntimeRtsBodyViewSlot, number> = {
bodyKey: 0,
@ -1335,6 +1340,7 @@ export const aetheriaRuntimeRtsBodyViewSlots: Record<AetheriaRuntimeRtsBodyViewS
isAsteroidBelt: 7,
body: 8,
iconAsset: 9,
iconSize: 10,
};
export type AetheriaRuntimeStarbridgeScenarioDocumentSlot =
@ -1863,6 +1869,7 @@ export type BodyView = {
radius: number;
isAsteroidBelt: boolean;
iconAsset: AssetRef;
iconSize: number;
};

View file

@ -565,6 +565,7 @@ function toBodyView(body: unknown[]): BodyView {
radius: Math.max(32, num(body[bodySlots.bodyRadiusMultiplier]) * 70),
isAsteroidBelt: kind.toLowerCase().includes("asteroid"),
iconAsset: bodyIconAsset(kind),
iconSize: num(body[bodySlots.iconSize]),
};
}

View file

@ -10,6 +10,8 @@ const sources = [
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveCommandDocument.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTradeItemQueries.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs"),
resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRenderSplatDocuments.cs"),
@ -1528,6 +1530,7 @@ export type BodyView = {
radius: number;
isAsteroidBelt: boolean;
iconAsset: AssetRef;
iconSize: number;
};
`;
}

View file

@ -30,7 +30,7 @@ using var discoveryHost = new AetheriaVerseDiscoveryHost(node);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
await EnsurePlayableRunDocumentsAsync(node, startedAtUtc).ConfigureAwait(false);
await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false);
await EnsureStarbridgeSessionDocumentsAsync(node, options).ConfigureAwait(false);
var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false);
await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false);
@ -129,7 +129,8 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
: currentFrame.SessionId;
var run = HasPlayableRun(currentFrame?.Run) && HasRtsScenario(currentFrame?.Run)
? currentFrame!.Run
: await ReadRuntimeRunCheckpointAsync(node).ConfigureAwait(false) ?? new AetheriaRuntimeRunCheckpointCommit();
: await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false) ?? new AetheriaRuntimeRunCheckpointCommit();
ApplyDaemonRenderSettings(run, options.RenderSettings);
var loadoutTemplates = node.Cache
.GetAll<AetheriaLoadoutTemplate>()
@ -175,6 +176,7 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
CumulativeAppliedCommandIds = currentFrame?.CumulativeAppliedCommandIds ?? currentFrame?.AppliedCommandIds ?? Array.Empty<string>(),
CumulativeRejectedCommandIds = currentFrame?.CumulativeRejectedCommandIds ?? currentFrame?.RejectedCommandIds ?? Array.Empty<string>(),
Catalog = node.RuntimeCatalog().Latest(),
RenderSettings = options.RenderSettings,
StarbridgeScenario = starbridgeScenario,
StarbridgeSession = starbridgeSession,
BuildPublications = buildPublications,
@ -1684,12 +1686,15 @@ static async Task EnsureTradeValuePolicyAsync(AetheriaStateNode node, string now
now)).ConfigureAwait(false);
}
static async Task EnsurePlayableRunDocumentsAsync(AetheriaStateNode node, string now)
static async Task EnsurePlayableRunDocumentsAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
string now)
{
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(settings?.ActiveRunKey))
{
var existingRun = await ReadRuntimeRunCheckpointAsync(node).ConfigureAwait(false);
var existingRun = await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false);
if (HasPlayableRun(existingRun) && HasRtsScenario(existingRun))
return;
}
@ -1846,7 +1851,9 @@ static async Task EnsureVerseAuthorityPolicyAsync(
.ConfigureAwait(false);
}
static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointAsync(AetheriaStateNode node)
static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointAsync(
AetheriaStateNode node,
AetheriaRuntimeDaemonRenderSettings renderSettings)
{
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(settings?.ActiveRunKey))
@ -1864,7 +1871,7 @@ static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointA
if (zone == null)
continue;
zones.Add(await ToRuntimeZoneAsync(node, zone, zoneIndex).ConfigureAwait(false));
zones.Add(await ToRuntimeZoneAsync(node, zone, zoneIndex, renderSettings).ConfigureAwait(false));
}
return new AetheriaRuntimeRunCheckpointCommit
@ -1893,7 +1900,8 @@ static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointA
static async Task<AetheriaRuntimeZoneSnapshotCommit> ToRuntimeZoneAsync(
AetheriaStateNode node,
AetheriaZoneState zone,
int zoneIndex)
int zoneIndex,
AetheriaRuntimeDaemonRenderSettings renderSettings)
{
var entityKeys = zone.EntityKeys ?? Array.Empty<string>();
var entityIndices = entityKeys
@ -1919,7 +1927,7 @@ static async Task<AetheriaRuntimeZoneSnapshotCommit> ToRuntimeZoneAsync(
OwnerFactionIndex = zone.OwnerFactionIndex,
Entities = entities,
Orbits = (zone.Orbits ?? Array.Empty<AetheriaOrbitSnapshot>()).Select(ToRuntimeOrbit).ToArray(),
Bodies = (zone.Bodies ?? Array.Empty<AetheriaBodySnapshot>()).Select(body => ToRuntimeBody(body, entityIndices)).ToArray(),
Bodies = (zone.Bodies ?? Array.Empty<AetheriaBodySnapshot>()).Select(body => ToRuntimeBody(body, entityIndices, renderSettings)).ToArray(),
DroppedPickups = (zone.DroppedPickups ?? Array.Empty<AetheriaDroppedPickupSnapshot>()).Select(ToRuntimePickup).ToArray(),
GravityTerrainRadius = zone.GravityTerrainRadius,
GravityTerrainDepth = zone.GravityTerrainDepth,
@ -2036,7 +2044,8 @@ static AetheriaRuntimeOrbitSnapshotCommit ToRuntimeOrbit(AetheriaOrbitSnapshot o
static AetheriaRuntimeBodySnapshotCommit ToRuntimeBody(
AetheriaBodySnapshot body,
IReadOnlyDictionary<string, int> entityIndices)
IReadOnlyDictionary<string, int> entityIndices,
AetheriaRuntimeDaemonRenderSettings renderSettings)
{
return new AetheriaRuntimeBodySnapshotCommit
{
@ -2106,10 +2115,22 @@ static AetheriaRuntimeBodySnapshotCommit ToRuntimeBody(
GravityWellDepth = body.GravityWellDepth,
GravityWaveRadius = body.GravityWaveRadius,
GravityWaveDepth = body.GravityWaveDepth,
GravityWaveSpeed = body.GravityWaveSpeed
GravityWaveSpeed = body.GravityWaveSpeed,
IconSize = renderSettings.ResolveBodyIconSize(body.Mass)
};
}
static void ApplyDaemonRenderSettings(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonRenderSettings renderSettings)
{
foreach (var zone in run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
{
foreach (var body in zone.Bodies ?? Array.Empty<AetheriaRuntimeBodySnapshotCommit>())
body.IconSize = renderSettings.ResolveBodyIconSize(body.Mass);
}
}
static AetheriaRuntimeDroppedPickupCommit ToRuntimePickup(AetheriaDroppedPickupSnapshot pickup)
{
return new AetheriaRuntimeDroppedPickupCommit
@ -2283,6 +2304,8 @@ internal sealed class AetheriaDaemonHostOptions
public TimeSpan TickInterval { get; init; } = TimeSpan.FromMilliseconds(20);
public TimeSpan ApiPublicationInterval { get; init; } = TimeSpan.FromSeconds(1);
public double FixedDeltaSeconds { get; init; } = 0.02;
public AetheriaRuntimeDaemonRenderSettings RenderSettings { get; init; } =
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public bool Once { get; init; }
public static AetheriaDaemonHostOptions Parse(IReadOnlyList<string> args)

View file

@ -532,6 +532,10 @@ namespace GameCult.Aetheria.State.Verse
[Key(20)]
public IReadOnlyList<string> CumulativeRejectedImportedFactIds { get; set; } = Array.Empty<string>();
[Key(21)]
public AetheriaRuntimeDaemonRenderSettings RenderSettings { get; set; } =
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public static AetheriaRuntimeDaemonFrameDocument Create(
AetheriaRuntimeRunCheckpointCommit run,
string daemonId,
@ -540,7 +544,8 @@ namespace GameCult.Aetheria.State.Verse
double simulationTimeSeconds,
double fixedDeltaSeconds,
bool isAuthoritative = true,
string stateSource = "daemon")
string stateSource = "daemon",
AetheriaRuntimeDaemonRenderSettings? renderSettings = null)
{
return new AetheriaRuntimeDaemonFrameDocument
{
@ -552,7 +557,8 @@ namespace GameCult.Aetheria.State.Verse
FixedDeltaSeconds = fixedDeltaSeconds,
IsAuthoritative = isAuthoritative,
StateSource = string.IsNullOrWhiteSpace(stateSource) ? "daemon" : stateSource,
Run = run ?? new AetheriaRuntimeRunCheckpointCommit()
Run = run ?? new AetheriaRuntimeRunCheckpointCommit(),
RenderSettings = renderSettings ?? AetheriaRuntimeDaemonRenderSettings.AetheriaDefault
};
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using MessagePack;
#nullable enable
@ -42,6 +43,7 @@ namespace GameCult.Aetheria.State.Verse
}
}
[MessagePackObject(true)]
public readonly struct AetheriaRuntimeExponentialCurve
{
public AetheriaRuntimeExponentialCurve(double exponent, double multiplier, double constant)
@ -61,8 +63,25 @@ namespace GameCult.Aetheria.State.Verse
}
}
[MessagePackObject(true)]
public readonly struct AetheriaRuntimeDaemonRenderSettings
{
public static AetheriaRuntimeDaemonRenderSettings AetheriaDefault { get; } =
new AetheriaRuntimeDaemonRenderSettings(
default,
default,
default,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
bodyIconSizeCurve: new AetheriaRuntimeExponentialCurve(0.15, 10.0, 25.0),
bodyRadiusCurve: default,
lightRadiusCurve: default,
gravityWaveFrequencyCurve: default);
public AetheriaRuntimeDaemonRenderSettings(
AetheriaRuntimeExponentialCurve temperatureEmissionCurve,
AetheriaRuntimeExponentialLerp lockIndicatorFrequency,

View file

@ -30,6 +30,8 @@ namespace GameCult.Aetheria.State.Verse
public AetheriaRuntimeStarbridgeScenarioDocument? StarbridgeScenario { get; set; }
public AetheriaRuntimeStarbridgeSessionDocument? StarbridgeSession { get; set; }
public AetheriaRuntimeCatalogSnapshot? Catalog { get; set; }
public AetheriaRuntimeDaemonRenderSettings RenderSettings { get; set; } =
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public bool BuildPublications { get; set; } = true;
}
@ -136,7 +138,8 @@ namespace GameCult.Aetheria.State.Verse
options.SessionId,
options.FrameId,
options.SimulationTimeSeconds,
options.FixedDeltaSeconds);
options.FixedDeltaSeconds,
renderSettings: options.RenderSettings);
frame.AppliedCommandIds = operationResult.AppliedCommandIds;
frame.RejectedCommandIds = operationResult.RejectedCommandIds;
frame.AccountedCommandIds = accountedBeforeTick

View file

@ -99,7 +99,7 @@ namespace GameCult.Aetheria.State.Verse
ZoneName = string.IsNullOrWhiteSpace(zone.Name) ? $"Zone {zone.ZoneIndex}" : zone.Name,
Viewport = normalizedViewport,
GravityInfluences = visibleBodies.Select(ToGravityInfluence).ToArray(),
Bodies = visibleBodies.Select(ToBodyView).ToArray(),
Bodies = visibleBodies.Select(body => ToBodyView(body, frame.RenderSettings)).ToArray(),
TerrainRadius = zone.GravityTerrainRadius,
TerrainDepth = zone.GravityTerrainDepth,
TerrainDepthExponent = zone.GravityTerrainDepthExponent,
@ -850,7 +850,9 @@ namespace GameCult.Aetheria.State.Verse
return obj;
}
private static AetheriaRuntimeRtsBodyView ToBodyView(AetheriaRuntimeBodySnapshotCommit body)
private static AetheriaRuntimeRtsBodyView ToBodyView(
AetheriaRuntimeBodySnapshotCommit body,
AetheriaRuntimeDaemonRenderSettings renderSettings)
{
var view = new AetheriaRuntimeRtsBodyView
{
@ -862,7 +864,8 @@ namespace GameCult.Aetheria.State.Verse
Y = body.GravityInfluenceCenterZ,
Radius = Math.Max(32, body.BodyRadiusMultiplier * 70),
IsAsteroidBelt = (body.Kind ?? "").IndexOf("asteroid", StringComparison.OrdinalIgnoreCase) >= 0,
Body = body
Body = body,
IconSize = body.IconSize > 0 ? body.IconSize : renderSettings.ResolveBodyIconSize(body.Mass)
};
view.IconAsset = AetheriaRuntimeAssets.ResolveBodyIcon(view);
return view;

View file

@ -1498,5 +1498,8 @@ namespace GameCult.Aetheria.State.Verse
[Key(9)]
public AetheriaRuntimeAssetRef IconAsset { get; set; } =
AetheriaRuntimeAssetRef.Empty(AetheriaRuntimeAssetKinds.Sprite);
[Key(10)]
public double IconSize { get; set; }
}
}

View file

@ -599,6 +599,9 @@ namespace GameCult.Aetheria.State.Verse
[Key(21)]
public double GravityWaveSpeed { get; set; }
[Key(22)]
public double IconSize { get; set; }
}
[MessagePackObject]