1440 lines
55 KiB
C#
1440 lines
55 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Aetheria.State;
|
|
using CultMath;
|
|
using GameCult.Aetheria.State.Verse;
|
|
using MessagePack;
|
|
using MessagePack.Formatters;
|
|
using MessagePack.Resolvers;
|
|
|
|
MessagePackSerializer.DefaultOptions = MessagePackSerializerOptions.Standard.WithResolver(
|
|
CompositeResolver.Create(
|
|
new IMessagePackFormatter[] { new CultMathFloat2Formatter(), new CultMathFloat3Formatter() },
|
|
new IFormatterResolver[] { StandardResolver.Instance }));
|
|
var options = FreezeOptions.Parse(args);
|
|
VerifyCultMathVectorSerializationCompatibility();
|
|
Directory.CreateDirectory(options.OutputDirectory);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(options.StatePath) ?? ".");
|
|
|
|
await ExportFixtureSetAsync(
|
|
options,
|
|
"baseline",
|
|
options.StatePath,
|
|
options.OutputDirectory,
|
|
Array.Empty<AetheriaRuntimeDaemonCommandDocument>())
|
|
.ConfigureAwait(false);
|
|
|
|
if (options.IncludeCommandBatches)
|
|
{
|
|
var commandStateDirectory = Path.Combine(options.OutputDirectory, ".state");
|
|
await ExportFixtureSetAsync(
|
|
options,
|
|
"command-batch-pilot",
|
|
Path.Combine(commandStateDirectory, "command-batch-pilot.cc"),
|
|
Path.Combine(options.OutputDirectory, "command-batch-pilot"),
|
|
BuildPilotCommands())
|
|
.ConfigureAwait(false);
|
|
|
|
await ExportFixtureSetAsync(
|
|
options,
|
|
"command-batch-interaction",
|
|
Path.Combine(commandStateDirectory, "command-batch-interaction.cc"),
|
|
Path.Combine(options.OutputDirectory, "command-batch-interaction"),
|
|
BuildInteractionCommands())
|
|
.ConfigureAwait(false);
|
|
|
|
await ExportFixtureSetAsync(
|
|
options,
|
|
"command-batch-refit",
|
|
Path.Combine(commandStateDirectory, "command-batch-refit.cc"),
|
|
Path.Combine(options.OutputDirectory, "command-batch-refit"),
|
|
BuildRefitCommands())
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
ExportAuthorityDecisionFixtures(Path.Combine(options.OutputDirectory, "authority-decisions"));
|
|
ExportYmirQueryFixtures(Path.Combine(options.OutputDirectory, "ymir-queries"));
|
|
|
|
Console.WriteLine($"Wrote Aetheria freeze fixtures to {options.OutputDirectory}");
|
|
|
|
static async Task ExportFixtureSetAsync(
|
|
FreezeOptions options,
|
|
string scenario,
|
|
string statePath,
|
|
string outputDirectory,
|
|
IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> commands)
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(statePath) ?? ".");
|
|
if (options.CleanState)
|
|
DeleteStateFamily(statePath);
|
|
|
|
if (commands.Count > 0)
|
|
await SeedCommandsAsync(statePath, commands).ConfigureAwait(false);
|
|
|
|
if (options.RunDaemon)
|
|
await RunDaemonOnceAsync(options, statePath, scenario).ConfigureAwait(false);
|
|
|
|
var manifest = new FreezeManifest
|
|
{
|
|
Scenario = scenario,
|
|
GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
Root = options.Root,
|
|
StatePath = statePath,
|
|
OutputDirectory = outputDirectory,
|
|
RanDaemon = options.RunDaemon,
|
|
SeededCommandCount = commands.Count
|
|
};
|
|
|
|
if (commands.Count > 0)
|
|
WriteArtifact(outputDirectory, manifest, "commands.seeded", AetheriaRuntimeDaemonSchemas.Command + "[]", commands);
|
|
|
|
await using var node = await AetheriaStateNode
|
|
.OpenAsync(statePath, runtimeId: "aetheria-freeze", startServer: false)
|
|
.ConfigureAwait(false);
|
|
|
|
var frame = await node.MutableDocument<AetheriaRuntimeDaemonFrameDocument>(
|
|
AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest)
|
|
.ReadAsync()
|
|
.ConfigureAwait(false);
|
|
if (frame == null)
|
|
throw new InvalidOperationException("Freeze export cannot continue because the daemon latest frame is missing.");
|
|
WriteArtifact(outputDirectory, manifest, "daemon-frame.latest", AetheriaRuntimeDaemonSchemas.Frame, frame);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeDaemonHealthDocument>(node, AetheriaRuntimeVerseRecordKeys.DaemonHealth).ConfigureAwait(false) is { } health)
|
|
WriteArtifact(outputDirectory, manifest, "daemon-health.latest", AetheriaRuntimeDaemonSchemas.Health, health);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeDaemonProviderAdvertisementDocument>(node, AetheriaRuntimeVerseRecordKeys.DaemonProviderAdvertisement).ConfigureAwait(false) is { } provider)
|
|
WriteArtifact(outputDirectory, manifest, "daemon-provider.latest", AetheriaRuntimeDaemonSchemas.ProviderAdvertisement, provider);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeDaemonCommandBoundaryDocument>(node, AetheriaRuntimeVerseRecordKeys.DaemonCommandBoundary).ConfigureAwait(false) is { } commandBoundary)
|
|
WriteArtifact(outputDirectory, manifest, "daemon-command-boundary.latest", AetheriaRuntimeDaemonSchemas.CommandBoundary, commandBoundary);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeVerseAuthorityPolicyDocument>(node, AetheriaRuntimeVerseRecordKeys.VerseAuthorityPolicy).ConfigureAwait(false) is { } authorityPolicy)
|
|
WriteArtifact(outputDirectory, manifest, "verse-authority-policy.latest", AetheriaRuntimeVerseAuthoritySchemas.Policy, authorityPolicy);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeStarbridgeSessionSummaryDocument>(node, AetheriaRuntimeVerseRecordKeys.StarbridgeSessionSummary).ConfigureAwait(false) is { } starbridgeSummary)
|
|
WriteArtifact(outputDirectory, manifest, "starbridge-session-summary.latest", AetheriaRuntimeDaemonSchemas.StarbridgeSessionSummary, starbridgeSummary);
|
|
|
|
if (await TryReadDocumentAsync<AetheriaRuntimeDaemonSoaViewDocument>(node, AetheriaRuntimeVerseRecordKeys.DaemonSoaViewLatest).ConfigureAwait(false) is { } soaView)
|
|
WriteArtifact(outputDirectory, manifest, "daemon-soa-view.latest", AetheriaRuntimeDaemonSchemas.SoaView, soaView);
|
|
|
|
var viewport = new AetheriaRuntimeViewportBounds
|
|
{
|
|
MinX = options.ViewportMinX,
|
|
MinY = options.ViewportMinY,
|
|
MaxX = options.ViewportMaxX,
|
|
MaxY = options.ViewportMaxY
|
|
};
|
|
WriteArtifact(outputDirectory, manifest, "document.game-viewport", AetheriaRuntimeDaemonSchemas.GameViewport, AetheriaRuntimeGameDocuments.Viewport(frame, viewport));
|
|
WriteArtifact(outputDirectory, manifest, "document.objects-viewport", AetheriaRuntimeDaemonSchemas.ObjectsViewport, AetheriaRuntimeGameDocuments.ObjectsViewport(frame, viewport));
|
|
WriteArtifact(outputDirectory, manifest, "document.gravity-viewport", AetheriaRuntimeDaemonSchemas.GravityViewport, AetheriaRuntimeGameDocuments.GravityViewport(frame, viewport));
|
|
WriteArtifact(outputDirectory, manifest, "document.current-zone", AetheriaRuntimeDaemonSchemas.CurrentZone, AetheriaRuntimeGameDocuments.CurrentZone(frame));
|
|
WriteArtifact(outputDirectory, manifest, "document.current-entity", AetheriaRuntimeDaemonSchemas.CurrentEntity, AetheriaRuntimeGameDocuments.CurrentEntity(frame));
|
|
WriteArtifact(outputDirectory, manifest, "document.sector-map", AetheriaRuntimeDaemonSchemas.SectorMap, AetheriaRuntimeGameDocuments.SectorMap(frame));
|
|
WriteArtifact(outputDirectory, manifest, "document.zone-render", AetheriaRuntimeDaemonSchemas.ZoneRender, AetheriaRuntimeGameDocuments.ZoneRender(frame));
|
|
|
|
var selectedEntityIndex = ResolveSelectedEntityIndex(frame);
|
|
WriteArtifact(outputDirectory, manifest, "document.selected-object", AetheriaRuntimeDaemonSchemas.SelectedObject, AetheriaRuntimeGameDocuments.SelectedObject(frame, selectedEntityIndex));
|
|
WriteArtifact(outputDirectory, manifest, "document.inventory", AetheriaRuntimeDaemonSchemas.Inventory, AetheriaRuntimeGameDocuments.Inventory(frame, selectedEntityIndex));
|
|
|
|
WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest);
|
|
}
|
|
|
|
static void ExportAuthorityDecisionFixtures(string outputDirectory)
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
var fixtures = BuildAuthorityDecisionFixtures();
|
|
var manifest = new FreezeManifest
|
|
{
|
|
Scenario = "authority-decisions",
|
|
GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
OutputDirectory = outputDirectory,
|
|
RanDaemon = false,
|
|
SeededCommandCount = fixtures.Cases.Length
|
|
};
|
|
WriteArtifact(outputDirectory, manifest, "authority-decisions", "gamecult.aetheria.freeze.authority_decisions.v1", fixtures);
|
|
WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest);
|
|
}
|
|
|
|
static Task<T?> TryReadDocumentAsync<T>(AetheriaStateNode node, GameCult.Caching.CultRecordKey recordKey)
|
|
where T : class
|
|
{
|
|
return TryReadAsync(() => node.MutableDocument<T>(recordKey).ReadAsync());
|
|
}
|
|
|
|
static async Task<T?> TryReadAsync<T>(Func<Task<T?>> read)
|
|
where T : class
|
|
{
|
|
try
|
|
{
|
|
return await read().ConfigureAwait(false);
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static void ExportYmirQueryFixtures(string outputDirectory)
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
var fixtures = BuildYmirQueryFixtures();
|
|
var manifest = new FreezeManifest
|
|
{
|
|
Scenario = "ymir-queries",
|
|
GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
OutputDirectory = outputDirectory,
|
|
RanDaemon = false,
|
|
SeededCommandCount = fixtures.Cases.Length
|
|
};
|
|
WriteArtifact(outputDirectory, manifest, "ymir-queries", "gamecult.aetheria.freeze.ymir_queries.v1", fixtures);
|
|
WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest);
|
|
}
|
|
|
|
static async Task SeedCommandsAsync(string statePath, IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> commands)
|
|
{
|
|
await using var node = await AetheriaStateNode
|
|
.OpenAsync(statePath, "aetheria-freeze-command-seeder", enableDurableShardLogs: false)
|
|
.ConfigureAwait(false);
|
|
foreach (var command in commands)
|
|
await node.SubmitDaemonCommandAsync(command).ConfigureAwait(false);
|
|
await node.FlushAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
static async Task RunDaemonOnceAsync(FreezeOptions options, string statePath, string scenario)
|
|
{
|
|
var daemonProject = Path.Combine(options.Root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj");
|
|
if (!File.Exists(daemonProject))
|
|
throw new FileNotFoundException("Could not find daemon project.", daemonProject);
|
|
|
|
var daemonArgs = string.Join(
|
|
" ",
|
|
new[]
|
|
{
|
|
"run",
|
|
"--project",
|
|
Quote(daemonProject),
|
|
"--",
|
|
"--once",
|
|
"--root",
|
|
Quote(options.Root),
|
|
"--state",
|
|
Quote(statePath),
|
|
"--daemon-id",
|
|
$"aetheria-freeze-{scenario}",
|
|
"--session-id",
|
|
scenario,
|
|
"--client-cultmesh-port",
|
|
"0"
|
|
});
|
|
|
|
var start = new ProcessStartInfo("dotnet", daemonArgs)
|
|
{
|
|
WorkingDirectory = options.Root,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
};
|
|
|
|
using var process = Process.Start(start) ?? throw new InvalidOperationException("Failed to start daemon process.");
|
|
var stdout = process.StandardOutput.ReadToEndAsync();
|
|
var stderr = process.StandardError.ReadToEndAsync();
|
|
await process.WaitForExitAsync().ConfigureAwait(false);
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Aetheria daemon fixture run failed with exit code {process.ExitCode}.\nSTDOUT:\n{await stdout.ConfigureAwait(false)}\nSTDERR:\n{await stderr.ConfigureAwait(false)}");
|
|
}
|
|
}
|
|
|
|
static void DeleteStateFamily(string statePath)
|
|
{
|
|
var directory = Path.GetDirectoryName(statePath);
|
|
var fileName = Path.GetFileName(statePath);
|
|
if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName) || !Directory.Exists(directory))
|
|
return;
|
|
|
|
foreach (var path in Directory.EnumerateFileSystemEntries(directory, fileName + "*"))
|
|
{
|
|
if (Directory.Exists(path))
|
|
Directory.Delete(path, recursive: true);
|
|
else
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
static IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> BuildPilotCommands()
|
|
{
|
|
const string actor = "global:aetheria.run_state.local-starbridge.zone.0.entity.1.v1";
|
|
const string target = "global:aetheria.run_state.local-starbridge.zone.0.entity.4.v1";
|
|
return new[]
|
|
{
|
|
Command("pilot-001-set-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, command =>
|
|
{
|
|
command.TargetEntityKey = target;
|
|
}),
|
|
Command("pilot-002-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, command =>
|
|
{
|
|
command.DirectionX = 1;
|
|
command.DirectionY = 0.25;
|
|
command.ScalarValue = 1;
|
|
}),
|
|
Command("pilot-003-look", AetheriaRuntimeDaemonCommandKinds.SetLookDirection, actor, command =>
|
|
{
|
|
command.DirectionX = 0.25;
|
|
command.DirectionY = 1;
|
|
}),
|
|
Command("pilot-004-tractor", AetheriaRuntimeDaemonCommandKinds.SetTractorPower, actor, command =>
|
|
{
|
|
command.ScalarValue = 0.5;
|
|
}),
|
|
Command("pilot-005-sensor-ping", AetheriaRuntimeDaemonCommandKinds.SensorPing, actor),
|
|
Command("pilot-006-toggle-shield", AetheriaRuntimeDaemonCommandKinds.ToggleShieldEnabled, actor),
|
|
Command("pilot-007-fire-weapon-group", AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup, actor, command =>
|
|
{
|
|
command.WeaponGroup = 0;
|
|
})
|
|
};
|
|
}
|
|
|
|
static IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> BuildInteractionCommands()
|
|
{
|
|
const string actor = "global:aetheria.run_state.local-starbridge.zone.0.entity.1.v1";
|
|
const string station = "global:aetheria.run_state.local-starbridge.zone.0.entity.0.v1";
|
|
return new[]
|
|
{
|
|
Command("interaction-001-dock-nearest", AetheriaRuntimeDaemonCommandKinds.DockNearest, actor),
|
|
Command("interaction-002-dock", AetheriaRuntimeDaemonCommandKinds.Dock, actor, command =>
|
|
{
|
|
command.TargetEntityKey = station;
|
|
}),
|
|
Command("interaction-003-undock", AetheriaRuntimeDaemonCommandKinds.Undock, actor),
|
|
Command("interaction-004-interact", AetheriaRuntimeDaemonCommandKinds.Interact, actor),
|
|
Command("interaction-005-tow", AetheriaRuntimeDaemonCommandKinds.TowToStation, actor, command =>
|
|
{
|
|
command.TargetEntityKey = station;
|
|
})
|
|
};
|
|
}
|
|
|
|
static IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> BuildRefitCommands()
|
|
{
|
|
const string actor = "global:aetheria.run_state.local-starbridge.zone.0.entity.1.v1";
|
|
const string station = "global:aetheria.run_state.local-starbridge.zone.0.entity.0.v1";
|
|
return new[]
|
|
{
|
|
Command("refit-001-rename", AetheriaRuntimeDaemonCommandKinds.SetEntityName, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.TextValue = "Vanguard Freeze";
|
|
}),
|
|
Command("refit-002-transfer-cargo", AetheriaRuntimeDaemonCommandKinds.TransferCargoItem, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.TextValue = "repair-parts";
|
|
command.ScalarValue = 1;
|
|
command.CargoTransfer.OriginEntityKey = station;
|
|
command.CargoTransfer.OriginCargoIndex = 0;
|
|
command.CargoTransfer.DestinationEntityKey = actor;
|
|
command.CargoTransfer.DestinationCargoIndex = 0;
|
|
command.CargoTransfer.SourceX = 0;
|
|
command.CargoTransfer.SourceY = 0;
|
|
command.CargoTransfer.DestinationX = 2;
|
|
command.CargoTransfer.DestinationY = 0;
|
|
command.CargoTransfer.HasDestinationPosition = true;
|
|
}),
|
|
Command("refit-003-trade-purchase", AetheriaRuntimeDaemonCommandKinds.TradePurchase, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.EquipmentIndex = 0;
|
|
command.PositionX = 1;
|
|
command.PositionY = 0;
|
|
command.ScalarValue = 25;
|
|
command.TextValue = "reactor-fuel";
|
|
command.TradePurchase.ItemKey = "reactor-fuel";
|
|
command.TradePurchase.Quantity = 1;
|
|
command.TradePurchase.StationCargoIndex = 0;
|
|
command.TradePurchase.TargetCargoIndex = 0;
|
|
command.TradePurchase.SourceX = 1;
|
|
command.TradePurchase.SourceY = 0;
|
|
}),
|
|
Command("refit-004-equip-from-cargo", AetheriaRuntimeDaemonCommandKinds.EquipItem, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.PositionX = 1;
|
|
command.PositionY = 0;
|
|
command.TextValue = "field-rations";
|
|
command.EquipmentTransfer.SourceKind = "cargo";
|
|
command.EquipmentTransfer.OriginEntityKey = actor;
|
|
command.EquipmentTransfer.OriginIndex = 0;
|
|
command.EquipmentTransfer.DestinationEntityKey = actor;
|
|
command.EquipmentTransfer.SourceX = 1;
|
|
command.EquipmentTransfer.SourceY = 0;
|
|
command.EquipmentTransfer.DestinationX = 1;
|
|
command.EquipmentTransfer.DestinationY = 0;
|
|
command.EquipmentTransfer.HasDestinationPosition = true;
|
|
}),
|
|
Command("refit-005-store-equipped", AetheriaRuntimeDaemonCommandKinds.StoreItem, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.TextValue = "sensor-array";
|
|
command.StoreItem.OriginEntityKey = actor;
|
|
command.StoreItem.SourceEquipmentIndex = 0;
|
|
command.StoreItem.DestinationEntityKey = actor;
|
|
command.StoreItem.DestinationCargoIndex = 0;
|
|
command.StoreItem.DestinationX = 3;
|
|
command.StoreItem.DestinationY = 0;
|
|
command.StoreItem.HasDestinationPosition = true;
|
|
}),
|
|
Command("refit-006-set-docked-current-ship", AetheriaRuntimeDaemonCommandKinds.SetDockedCurrentShip, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
}),
|
|
Command("refit-007-pick-up-loot", AetheriaRuntimeDaemonCommandKinds.PickUpLoot, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.TextValue = "loose-salvage";
|
|
command.ScalarValue = 2;
|
|
command.PositionX = -40;
|
|
command.PositionY = 0;
|
|
command.PositionZ = -35;
|
|
}),
|
|
Command("refit-008-restore-loadout", AetheriaRuntimeDaemonCommandKinds.RestoreLoadout, actor, command =>
|
|
{
|
|
command.TargetEntityKey = actor;
|
|
command.LoadoutRestore.DockedEntityKey = actor;
|
|
command.LoadoutRestore.TemplateName = "freeze-template";
|
|
command.LoadoutRestore.Price = 0;
|
|
})
|
|
};
|
|
}
|
|
|
|
static AuthorityDecisionFixtureSet BuildAuthorityDecisionFixtures()
|
|
{
|
|
const string verseId = "aetheria.freeze";
|
|
const string host = "aetheria-daemon";
|
|
const string otherRuntime = "raven-client";
|
|
const string delegatedRuntime = "starbridge-commander";
|
|
const string actor = "global:aetheria.run_state.local-starbridge.zone.0.entity.1.v1";
|
|
const string raider = "global:aetheria.run_state.local-starbridge.zone.0.entity.4.v1";
|
|
var validLease = new AetheriaRuntimeAuthorityLeaseDocument
|
|
{
|
|
LeaseId = "lease-commander-vanguard-movement",
|
|
VerseId = verseId,
|
|
RuntimeId = delegatedRuntime,
|
|
SubjectPrefix = actor,
|
|
ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Movement },
|
|
ValidFromUtc = "2026-01-01T00:00:00.0000000Z",
|
|
ExpiresAtUtc = "2100-01-01T00:00:00.0000000Z",
|
|
Scope = "nearby-interest"
|
|
};
|
|
|
|
var cases = new[]
|
|
{
|
|
AuthorityCase(
|
|
"trusted-coop-allows-delegated-movement",
|
|
AetheriaRuntimeVerseAuthorityPolicyDocument.TrustedCoop(verseId, host),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-001-delegated-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, delegatedRuntime),
|
|
host),
|
|
AuthorityCase(
|
|
"host-authoritative-allows-host",
|
|
HostPolicy(verseId, host),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-002-host-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, host),
|
|
host),
|
|
AuthorityCase(
|
|
"host-authoritative-rejects-delegated-runtime",
|
|
HostPolicy(verseId, host),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-003-delegated-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, delegatedRuntime),
|
|
host),
|
|
AuthorityCase(
|
|
"delegated-runtime-allows-commander-targeting",
|
|
DelegatedPolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Targeting, delegatedRuntime),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-004-commander-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, delegatedRuntime, command => command.TargetEntityKey = raider),
|
|
host),
|
|
AuthorityCase(
|
|
"delegated-runtime-rejects-other-targeting",
|
|
DelegatedPolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Targeting, delegatedRuntime),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-005-other-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, otherRuntime, command => command.TargetEntityKey = raider),
|
|
host),
|
|
AuthorityCase(
|
|
"interest-lease-allows-delegated-movement",
|
|
LeasePolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Movement),
|
|
new[] { validLease },
|
|
AuthorityCommand("auth-006-delegated-lease-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, delegatedRuntime),
|
|
host),
|
|
AuthorityCase(
|
|
"interest-lease-rejects-delegated-without-lease",
|
|
LeasePolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Movement),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-007-delegated-no-lease-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, delegatedRuntime),
|
|
host),
|
|
AuthorityCase(
|
|
"owning-runtime-is-not-implemented",
|
|
OwningPolicy(verseId, host, actor),
|
|
Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>(),
|
|
AuthorityCommand("auth-008-owning-mode-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, otherRuntime),
|
|
host)
|
|
};
|
|
|
|
return new AuthorityDecisionFixtureSet
|
|
{
|
|
Schema = "gamecult.aetheria.freeze.authority_decisions.v1",
|
|
GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
Cases = cases
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureSet BuildYmirQueryFixtures()
|
|
{
|
|
var stepBody = YmirBody("ship", 1, 2, 1);
|
|
stepBody.velocity = YmirVec2Of(3, -4);
|
|
var stepRequest = new YmirStepRequest
|
|
{
|
|
deltaTime = 0.5f,
|
|
world = YmirWorldOf(stepBody)
|
|
};
|
|
|
|
var radialBody = YmirBody("ship", 5, 0, 1);
|
|
var radialRequest = new YmirStepRequest
|
|
{
|
|
deltaTime = 1.0f,
|
|
world = YmirWorldOf(radialBody)
|
|
};
|
|
radialRequest.world.fields = new[]
|
|
{
|
|
new YmirRadialField
|
|
{
|
|
id = "push",
|
|
position = YmirVec2Of(0, 0),
|
|
strength = 10,
|
|
radius = 10
|
|
}
|
|
};
|
|
|
|
var mover = YmirBody("mover", 0, 0, 1);
|
|
mover.velocity = YmirVec2Of(2, 0);
|
|
mover.mass = 1;
|
|
var wall = YmirBody("wall", 1.5f, 0, 1);
|
|
wall.isStatic = true;
|
|
var contactRequest = new YmirStepRequest
|
|
{
|
|
deltaTime = 0.1f,
|
|
world = YmirWorldOf(mover, wall)
|
|
};
|
|
|
|
var overlapSphereRequest = new YmirSphereOverlapRequest
|
|
{
|
|
center = YmirVec3Of(0, 0, 0),
|
|
radius = 3,
|
|
bodies = new[]
|
|
{
|
|
YmirSphereBody("far", 4, 0, 0, 2),
|
|
YmirSphereBody("near", 1, 0, 0, 0.25f),
|
|
YmirSphereBody("miss", 10, 0, 0, 1)
|
|
}
|
|
};
|
|
|
|
var overlapCircleRequest = new YmirCircleOverlapRequest
|
|
{
|
|
center = YmirVec2Of(0, 0),
|
|
radius = 3,
|
|
world = YmirWorldOf(
|
|
YmirBody("far", 4, 0, 2),
|
|
YmirBody("near", 1, 0, 0.25f),
|
|
YmirBody("miss", 10, 0, 1))
|
|
};
|
|
|
|
var castSphereRequest = new YmirSphereCastRequest
|
|
{
|
|
origin = YmirVec3Of(0, 0, 0),
|
|
direction = YmirVec3Of(2, 0, 0),
|
|
distance = 10,
|
|
radius = 1,
|
|
bodies = new[] { YmirSphereBody("target", 4, 0, 0, 1) }
|
|
};
|
|
|
|
var castCircleRequest = new YmirCircleCastRequest
|
|
{
|
|
origin = YmirVec2Of(0, 0),
|
|
direction = YmirVec2Of(1, 0),
|
|
distance = 10,
|
|
radius = 1,
|
|
world = YmirWorldOf(
|
|
YmirBody("far", 8, 0, 1),
|
|
YmirBody("near", 4, 0, 1),
|
|
YmirBody("miss", 4, 4, 1))
|
|
};
|
|
|
|
var invalidCastCircleRequest = new YmirCircleCastRequest
|
|
{
|
|
origin = YmirVec2Of(0, 0),
|
|
direction = YmirVec2Of(0, 0),
|
|
distance = 1,
|
|
radius = 1,
|
|
world = YmirWorldOf(YmirBody("target", 0, 0, 1))
|
|
};
|
|
|
|
return new YmirQueryFixtureSet
|
|
{
|
|
Schema = "gamecult.aetheria.freeze.ymir_queries.v1",
|
|
GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
Cases = new[]
|
|
{
|
|
YmirStepCase("step-integrates-dynamic-body", "Dynamic bodies integrate velocity without mutating the input world.", stepRequest, YmirPhysicsQueries.Step(stepRequest)),
|
|
YmirStepCase("step-applies-radial-field", "Radial fields apply linear falloff acceleration before integration.", radialRequest, YmirPhysicsQueries.Step(radialRequest)),
|
|
YmirStepCase("step-reports-contact-and-separates", "Dynamic/static body overlap produces a contact and separates the dynamic body.", contactRequest, YmirPhysicsQueries.Step(contactRequest)),
|
|
YmirSphereOverlapCase("overlap-sphere-sorts-surface-distance", "Sphere overlap hits sort by surface distance, then id.", overlapSphereRequest, YmirPhysicsQueries.OverlapSphere(overlapSphereRequest)),
|
|
YmirCircleOverlapCase("overlap-circle-sorts-surface-distance", "Circle overlap hits sort by surface distance, then id.", overlapCircleRequest, YmirPhysicsQueries.OverlapCircle(overlapCircleRequest)),
|
|
YmirSphereCastCase("cast-sphere-impact-point-normal", "Sphere casts report impact distance, point, and normal.", castSphereRequest, YmirPhysicsQueries.CastSphere(castSphereRequest)),
|
|
YmirCircleCastCase("cast-circle-sorts-impact-distance", "Circle cast hits sort by impact distance, then id.", castCircleRequest, YmirPhysicsQueries.CastCircle(castCircleRequest)),
|
|
YmirCircleCastCase("cast-circle-invalid-direction-skips", "Invalid cast direction returns no hits.", invalidCastCircleRequest, YmirPhysicsQueries.CastCircle(invalidCastCircleRequest))
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureCase YmirStepCase(
|
|
string caseId,
|
|
string intent,
|
|
YmirStepRequest request,
|
|
YmirStepResult result)
|
|
{
|
|
return new YmirQueryFixtureCase
|
|
{
|
|
CaseId = caseId,
|
|
QueryKind = "step",
|
|
Intent = intent,
|
|
Request = new YmirQueryFixturePayload
|
|
{
|
|
DeltaTime = request.deltaTime,
|
|
World = ToFixtureWorld(request.world)
|
|
},
|
|
Result = new YmirQueryFixturePayload
|
|
{
|
|
World = ToFixtureWorld(result.world),
|
|
Contacts = ToFixtureContacts(result.contacts)
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureCase YmirSphereOverlapCase(
|
|
string caseId,
|
|
string intent,
|
|
YmirSphereOverlapRequest request,
|
|
YmirSphereOverlapResult result)
|
|
{
|
|
return new YmirQueryFixtureCase
|
|
{
|
|
CaseId = caseId,
|
|
QueryKind = "overlap-sphere",
|
|
Intent = intent,
|
|
Request = new YmirQueryFixturePayload
|
|
{
|
|
Center3 = ToFixtureVec3(request.center),
|
|
Radius = request.radius,
|
|
SphereBodies = ToFixtureSphereBodies(request.bodies)
|
|
},
|
|
Result = new YmirQueryFixturePayload
|
|
{
|
|
SphereOverlapHits = ToFixtureSphereOverlapHits(result.hits)
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureCase YmirCircleOverlapCase(
|
|
string caseId,
|
|
string intent,
|
|
YmirCircleOverlapRequest request,
|
|
YmirCircleOverlapResult result)
|
|
{
|
|
return new YmirQueryFixtureCase
|
|
{
|
|
CaseId = caseId,
|
|
QueryKind = "overlap-circle",
|
|
Intent = intent,
|
|
Request = new YmirQueryFixturePayload
|
|
{
|
|
Center2 = ToFixtureVec2(request.center),
|
|
Radius = request.radius,
|
|
World = ToFixtureWorld(request.world)
|
|
},
|
|
Result = new YmirQueryFixturePayload
|
|
{
|
|
CircleOverlapHits = ToFixtureCircleOverlapHits(result.hits)
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureCase YmirSphereCastCase(
|
|
string caseId,
|
|
string intent,
|
|
YmirSphereCastRequest request,
|
|
YmirSphereCastResult result)
|
|
{
|
|
return new YmirQueryFixtureCase
|
|
{
|
|
CaseId = caseId,
|
|
QueryKind = "cast-sphere",
|
|
Intent = intent,
|
|
Request = new YmirQueryFixturePayload
|
|
{
|
|
Origin3 = ToFixtureVec3(request.origin),
|
|
Direction3 = ToFixtureVec3(request.direction),
|
|
Distance = request.distance,
|
|
Radius = request.radius,
|
|
SphereBodies = ToFixtureSphereBodies(request.bodies)
|
|
},
|
|
Result = new YmirQueryFixturePayload
|
|
{
|
|
SphereCastHits = ToFixtureSphereCastHits(result.hits)
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirQueryFixtureCase YmirCircleCastCase(
|
|
string caseId,
|
|
string intent,
|
|
YmirCircleCastRequest request,
|
|
YmirCircleCastResult result)
|
|
{
|
|
return new YmirQueryFixtureCase
|
|
{
|
|
CaseId = caseId,
|
|
QueryKind = "cast-circle",
|
|
Intent = intent,
|
|
Request = new YmirQueryFixturePayload
|
|
{
|
|
Origin2 = ToFixtureVec2(request.origin),
|
|
Direction2 = ToFixtureVec2(request.direction),
|
|
Distance = request.distance,
|
|
Radius = request.radius,
|
|
World = ToFixtureWorld(request.world)
|
|
},
|
|
Result = new YmirQueryFixturePayload
|
|
{
|
|
CircleCastHits = ToFixtureCircleCastHits(result.hits)
|
|
}
|
|
};
|
|
}
|
|
|
|
static YmirFixtureWorld ToFixtureWorld(YmirWorld world)
|
|
{
|
|
return new YmirFixtureWorld
|
|
{
|
|
Time = world?.time ?? 0,
|
|
Bodies = ToFixtureBodies(world?.bodies),
|
|
Fields = ToFixtureFields(world?.fields)
|
|
};
|
|
}
|
|
|
|
static YmirFixtureBody[] ToFixtureBodies(IReadOnlyList<YmirPhysicsBody>? bodies)
|
|
{
|
|
return (bodies ?? Array.Empty<YmirPhysicsBody>())
|
|
.Select(body => new YmirFixtureBody
|
|
{
|
|
Id = body?.id ?? "",
|
|
Position = ToFixtureVec2(body?.position ?? default),
|
|
Velocity = ToFixtureVec2(body?.velocity ?? default),
|
|
Direction = ToFixtureVec2(body?.direction ?? default),
|
|
AngularVelocity = body?.angularVelocity ?? 0,
|
|
Torque = body?.torque ?? 0,
|
|
MomentOfInertia = body?.momentOfInertia ?? 0,
|
|
Radius = body?.radius ?? 0,
|
|
Mass = body?.mass ?? 0,
|
|
IsStatic = body?.isStatic ?? false,
|
|
Restitution = body?.restitution ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureSphereBody[] ToFixtureSphereBodies(IReadOnlyList<YmirSphereQueryBody>? bodies)
|
|
{
|
|
return (bodies ?? Array.Empty<YmirSphereQueryBody>())
|
|
.Select(body => new YmirFixtureSphereBody
|
|
{
|
|
Id = body?.id ?? "",
|
|
Position = ToFixtureVec3(body?.position ?? default),
|
|
Radius = body?.radius ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureField[] ToFixtureFields(IReadOnlyList<YmirRadialField>? fields)
|
|
{
|
|
return (fields ?? Array.Empty<YmirRadialField>())
|
|
.Select(field => new YmirFixtureField
|
|
{
|
|
Id = field?.id ?? "",
|
|
Position = ToFixtureVec2(field?.position ?? default),
|
|
Strength = field?.strength ?? 0,
|
|
Radius = field?.radius ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureContact[] ToFixtureContacts(IReadOnlyList<YmirContactEvent>? contacts)
|
|
{
|
|
return (contacts ?? Array.Empty<YmirContactEvent>())
|
|
.Select(contact => new YmirFixtureContact
|
|
{
|
|
BodyA = contact?.bodyA ?? "",
|
|
BodyB = contact?.bodyB ?? "",
|
|
Point = ToFixtureVec2(contact?.point ?? default),
|
|
Normal = ToFixtureVec2(contact?.normal ?? default),
|
|
Penetration = contact?.penetration ?? 0,
|
|
RelativeSpeed = contact?.relativeSpeed ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureCircleOverlapHit[] ToFixtureCircleOverlapHits(IReadOnlyList<YmirCircleOverlapHit>? hits)
|
|
{
|
|
return (hits ?? Array.Empty<YmirCircleOverlapHit>())
|
|
.Select(hit => new YmirFixtureCircleOverlapHit
|
|
{
|
|
BodyId = hit?.bodyId ?? "",
|
|
Point = ToFixtureVec2(hit?.point ?? default),
|
|
Normal = ToFixtureVec2(hit?.normal ?? default),
|
|
Penetration = hit?.penetration ?? 0,
|
|
Distance = hit?.distance ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureSphereOverlapHit[] ToFixtureSphereOverlapHits(IReadOnlyList<YmirSphereOverlapHit>? hits)
|
|
{
|
|
return (hits ?? Array.Empty<YmirSphereOverlapHit>())
|
|
.Select(hit => new YmirFixtureSphereOverlapHit
|
|
{
|
|
BodyId = hit?.bodyId ?? "",
|
|
Point = ToFixtureVec3(hit?.point ?? default),
|
|
Normal = ToFixtureVec3(hit?.normal ?? default),
|
|
Penetration = hit?.penetration ?? 0,
|
|
Distance = hit?.distance ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureCircleCastHit[] ToFixtureCircleCastHits(IReadOnlyList<YmirCircleCastHit>? hits)
|
|
{
|
|
return (hits ?? Array.Empty<YmirCircleCastHit>())
|
|
.Select(hit => new YmirFixtureCircleCastHit
|
|
{
|
|
BodyId = hit?.bodyId ?? "",
|
|
Point = ToFixtureVec2(hit?.point ?? default),
|
|
Normal = ToFixtureVec2(hit?.normal ?? default),
|
|
Distance = hit?.distance ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static YmirFixtureSphereCastHit[] ToFixtureSphereCastHits(IReadOnlyList<YmirSphereCastHit>? hits)
|
|
{
|
|
return (hits ?? Array.Empty<YmirSphereCastHit>())
|
|
.Select(hit => new YmirFixtureSphereCastHit
|
|
{
|
|
BodyId = hit?.bodyId ?? "",
|
|
Point = ToFixtureVec3(hit?.point ?? default),
|
|
Normal = ToFixtureVec3(hit?.normal ?? default),
|
|
Distance = hit?.distance ?? 0
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
static float2 ToFixtureVec2(YmirVec2 value)
|
|
{
|
|
return new float2(value.x, value.y);
|
|
}
|
|
|
|
static float3 ToFixtureVec3(YmirVec3 value)
|
|
{
|
|
return new float3(value.x, value.y, value.z);
|
|
}
|
|
|
|
static void VerifyCultMathVectorSerializationCompatibility()
|
|
{
|
|
var old2 = MessagePackSerializer.Serialize(new LegacyFixtureVec2(1.25f, -2.5f));
|
|
var current2 = MessagePackSerializer.Serialize(new float2(1.25f, -2.5f));
|
|
if (!old2.AsSpan().SequenceEqual(current2))
|
|
throw new InvalidOperationException("CultMath.float2 changed the frozen MessagePack vector layout.");
|
|
|
|
var old3 = MessagePackSerializer.Serialize(new LegacyFixtureVec3(1.25f, -2.5f, 4.75f));
|
|
var current3 = MessagePackSerializer.Serialize(new float3(1.25f, -2.5f, 4.75f));
|
|
if (!old3.AsSpan().SequenceEqual(current3))
|
|
throw new InvalidOperationException("CultMath.float3 changed the frozen MessagePack vector layout.");
|
|
}
|
|
|
|
static YmirWorld YmirWorldOf(params YmirPhysicsBody[] bodies)
|
|
{
|
|
return new YmirWorld
|
|
{
|
|
time = 0,
|
|
bodies = bodies,
|
|
fields = Array.Empty<YmirRadialField>()
|
|
};
|
|
}
|
|
|
|
static YmirPhysicsBody YmirBody(string id, float x, float y, float radius)
|
|
{
|
|
return new YmirPhysicsBody
|
|
{
|
|
id = id,
|
|
position = YmirVec2Of(x, y),
|
|
direction = YmirVec2Of(1, 0),
|
|
radius = radius,
|
|
mass = 1,
|
|
momentOfInertia = 1
|
|
};
|
|
}
|
|
|
|
static YmirSphereQueryBody YmirSphereBody(string id, float x, float y, float z, float radius)
|
|
{
|
|
return new YmirSphereQueryBody
|
|
{
|
|
id = id,
|
|
position = YmirVec3Of(x, y, z),
|
|
radius = radius
|
|
};
|
|
}
|
|
|
|
static YmirVec2 YmirVec2Of(float x, float y)
|
|
{
|
|
return new YmirVec2 { x = x, y = y };
|
|
}
|
|
|
|
static YmirVec3 YmirVec3Of(float x, float y, float z)
|
|
{
|
|
return new YmirVec3 { x = x, y = y, z = z };
|
|
}
|
|
|
|
static AuthorityDecisionCase AuthorityCase(
|
|
string caseId,
|
|
AetheriaRuntimeVerseAuthorityPolicyDocument policy,
|
|
IReadOnlyList<AetheriaRuntimeAuthorityLeaseDocument> leases,
|
|
AetheriaRuntimeDaemonCommandDocument command,
|
|
string localRuntimeId)
|
|
{
|
|
var decision = AetheriaRuntimeAuthorityRouter.Authorize(command, policy, leases, localRuntimeId);
|
|
return new AuthorityDecisionCase
|
|
{
|
|
CaseId = caseId,
|
|
LocalRuntimeId = localRuntimeId,
|
|
Policy = policy,
|
|
Leases = leases.ToArray(),
|
|
Command = command,
|
|
Decision = new AuthorityDecisionSnapshot
|
|
{
|
|
Authorized = decision.Authorized,
|
|
Reason = decision.Reason,
|
|
Mode = decision.Mode,
|
|
SubjectKey = decision.SubjectKey,
|
|
ClaimKind = decision.ClaimKind,
|
|
AuthorRuntimeId = decision.AuthorRuntimeId,
|
|
RuleId = decision.RuleId
|
|
}
|
|
};
|
|
}
|
|
|
|
static AetheriaRuntimeVerseAuthorityPolicyDocument HostPolicy(string verseId, string hostRuntimeId)
|
|
{
|
|
return new AetheriaRuntimeVerseAuthorityPolicyDocument
|
|
{
|
|
VerseId = verseId,
|
|
PolicyId = "aetheria.freeze.host-authoritative.v1",
|
|
HostRuntimeId = hostRuntimeId,
|
|
DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative,
|
|
DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DedicatedDaemon,
|
|
UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z",
|
|
Rules = new[]
|
|
{
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.host.default",
|
|
SubjectPrefix = "*",
|
|
ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any },
|
|
Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
static AetheriaRuntimeVerseAuthorityPolicyDocument DelegatedPolicy(
|
|
string verseId,
|
|
string hostRuntimeId,
|
|
string subjectPrefix,
|
|
string claimKind,
|
|
string runtimeId)
|
|
{
|
|
return new AetheriaRuntimeVerseAuthorityPolicyDocument
|
|
{
|
|
VerseId = verseId,
|
|
PolicyId = "aetheria.freeze.delegated.v1",
|
|
HostRuntimeId = hostRuntimeId,
|
|
DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative,
|
|
DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted,
|
|
UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z",
|
|
Rules = new[]
|
|
{
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.delegated.subject-claim",
|
|
SubjectPrefix = subjectPrefix,
|
|
ClaimKinds = new[] { claimKind },
|
|
Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime,
|
|
RuntimeIds = new[] { runtimeId },
|
|
Priority = 100
|
|
},
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.host.default",
|
|
SubjectPrefix = "*",
|
|
ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any },
|
|
Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
static AetheriaRuntimeVerseAuthorityPolicyDocument LeasePolicy(
|
|
string verseId,
|
|
string hostRuntimeId,
|
|
string subjectPrefix,
|
|
string claimKind)
|
|
{
|
|
return new AetheriaRuntimeVerseAuthorityPolicyDocument
|
|
{
|
|
VerseId = verseId,
|
|
PolicyId = "aetheria.freeze.interest-lease.v1",
|
|
HostRuntimeId = hostRuntimeId,
|
|
DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative,
|
|
DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted,
|
|
UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z",
|
|
Rules = new[]
|
|
{
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.lease.subject-claim",
|
|
SubjectPrefix = subjectPrefix,
|
|
ClaimKinds = new[] { claimKind },
|
|
Mode = AetheriaRuntimeAuthorityModes.InterestLease,
|
|
LeaseScope = "nearby-interest",
|
|
Priority = 100
|
|
},
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.host.default",
|
|
SubjectPrefix = "*",
|
|
ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any },
|
|
Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
static AetheriaRuntimeVerseAuthorityPolicyDocument OwningPolicy(string verseId, string hostRuntimeId, string subjectPrefix)
|
|
{
|
|
return new AetheriaRuntimeVerseAuthorityPolicyDocument
|
|
{
|
|
VerseId = verseId,
|
|
PolicyId = "aetheria.freeze.owning-runtime.v1",
|
|
HostRuntimeId = hostRuntimeId,
|
|
DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative,
|
|
DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted,
|
|
UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z",
|
|
Rules = new[]
|
|
{
|
|
new AetheriaRuntimeAuthorityRule
|
|
{
|
|
RuleId = "freeze.owning.subject",
|
|
SubjectPrefix = subjectPrefix,
|
|
ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Movement },
|
|
Mode = AetheriaRuntimeAuthorityModes.OwningRuntime,
|
|
Priority = 100
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
static AetheriaRuntimeDaemonCommandDocument AuthorityCommand(
|
|
string commandId,
|
|
AetheriaRuntimeDaemonCommandKinds kind,
|
|
string actor,
|
|
string authorRuntimeId,
|
|
Action<AetheriaRuntimeDaemonCommandDocument>? configure = null)
|
|
{
|
|
var command = Command(commandId, kind, actor, configure);
|
|
command.AuthorRuntimeId = authorRuntimeId;
|
|
command.ClientId = authorRuntimeId;
|
|
return command;
|
|
}
|
|
|
|
static AetheriaRuntimeDaemonCommandDocument Command(
|
|
string commandId,
|
|
AetheriaRuntimeDaemonCommandKinds kind,
|
|
string actor,
|
|
Action<AetheriaRuntimeDaemonCommandDocument>? configure = null)
|
|
{
|
|
var command = AetheriaRuntimeDaemonCommandDocument.Create(
|
|
kind,
|
|
"aetheria-freeze-fixture",
|
|
"freeze",
|
|
observedFrameId: -1,
|
|
actorEntityKey: actor);
|
|
command.CommandId = commandId;
|
|
command.IssuedAtUtc = "2026-01-01T00:00:00.0000000Z";
|
|
configure?.Invoke(command);
|
|
return command;
|
|
}
|
|
|
|
static int ResolveSelectedEntityIndex(AetheriaRuntimeDaemonFrameDocument frame)
|
|
{
|
|
var contextRun = frame.Run ?? new AetheriaRuntimeRunCheckpointCommit();
|
|
var current = contextRun.CurrentEntityKey ?? "";
|
|
var marker = ".entity.";
|
|
var markerIndex = current.LastIndexOf(marker, StringComparison.Ordinal);
|
|
if (markerIndex >= 0)
|
|
{
|
|
var start = markerIndex + marker.Length;
|
|
var end = current.IndexOf('.', start);
|
|
var token = end < 0 ? current[start..] : current[start..end];
|
|
if (int.TryParse(token, out var parsed))
|
|
return parsed;
|
|
}
|
|
|
|
var currentZone = (contextRun.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
|
|
.FirstOrDefault(zone => zone != null && zone.ZoneIndex == contextRun.CurrentZoneIndex) ??
|
|
(contextRun.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>()).FirstOrDefault();
|
|
return (currentZone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
|
|
.FirstOrDefault()?.EntityIndex ?? -1;
|
|
}
|
|
|
|
static void WriteArtifact<T>(string outputDirectory, FreezeManifest manifest, string name, string schemaId, T document)
|
|
{
|
|
var jsonFile = name + ".json";
|
|
var msgpackFile = name + ".msgpack";
|
|
WriteJson(Path.Combine(outputDirectory, jsonFile), document);
|
|
File.WriteAllBytes(Path.Combine(outputDirectory, msgpackFile), MessagePackSerializer.Serialize(document));
|
|
manifest.Artifacts.Add(new FreezeArtifact
|
|
{
|
|
Name = name,
|
|
SchemaId = schemaId,
|
|
Json = jsonFile,
|
|
MessagePack = msgpackFile,
|
|
DocumentType = typeof(T).FullName ?? typeof(T).Name
|
|
});
|
|
}
|
|
|
|
static void WriteJson<T>(string path, T value)
|
|
{
|
|
var json = JsonSerializer.Serialize(value, FreezeJson.Options);
|
|
File.WriteAllText(path, json);
|
|
}
|
|
|
|
static string Quote(string value)
|
|
{
|
|
return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
|
|
}
|
|
|
|
internal sealed class FreezeManifest
|
|
{
|
|
public string Scenario { get; set; } = "";
|
|
public string GeneratedAtUtc { get; set; } = "";
|
|
public string Root { get; set; } = "";
|
|
public string StatePath { get; set; } = "";
|
|
public string OutputDirectory { get; set; } = "";
|
|
public bool RanDaemon { get; set; }
|
|
public int SeededCommandCount { get; set; }
|
|
public List<FreezeArtifact> Artifacts { get; } = new();
|
|
}
|
|
|
|
internal sealed class FreezeArtifact
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public string SchemaId { get; set; } = "";
|
|
public string DocumentType { get; set; } = "";
|
|
public string Json { get; set; } = "";
|
|
public string MessagePack { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal readonly record struct LegacyFixtureVec2(
|
|
[property: Key(0)] float x,
|
|
[property: Key(1)] float y);
|
|
|
|
[MessagePackObject]
|
|
internal readonly record struct LegacyFixtureVec3(
|
|
[property: Key(0)] float x,
|
|
[property: Key(1)] float y,
|
|
[property: Key(2)] float z);
|
|
|
|
internal sealed class CultMathFloat2Formatter : IMessagePackFormatter<float2>
|
|
{
|
|
public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options)
|
|
{
|
|
writer.WriteArrayHeader(2);
|
|
writer.Write(value.x);
|
|
writer.Write(value.y);
|
|
}
|
|
|
|
public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
|
{
|
|
var count = reader.ReadArrayHeader();
|
|
if (count != 2) throw new MessagePackSerializationException($"Expected float2 array length 2, got {count}.");
|
|
return new float2(reader.ReadSingle(), reader.ReadSingle());
|
|
}
|
|
}
|
|
|
|
internal sealed class CultMathFloat3Formatter : IMessagePackFormatter<float3>
|
|
{
|
|
public void Serialize(ref MessagePackWriter writer, float3 value, MessagePackSerializerOptions options)
|
|
{
|
|
writer.WriteArrayHeader(3);
|
|
writer.Write(value.x);
|
|
writer.Write(value.y);
|
|
writer.Write(value.z);
|
|
}
|
|
|
|
public float3 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
|
{
|
|
var count = reader.ReadArrayHeader();
|
|
if (count != 3) throw new MessagePackSerializationException($"Expected float3 array length 3, got {count}.");
|
|
return new float3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle());
|
|
}
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirQueryFixtureSet
|
|
{
|
|
[Key(0)] public string Schema { get; set; } = "";
|
|
[Key(1)] public string GeneratedAtUtc { get; set; } = "";
|
|
[Key(2)] public YmirQueryFixtureCase[] Cases { get; set; } = Array.Empty<YmirQueryFixtureCase>();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirQueryFixtureCase
|
|
{
|
|
[Key(0)] public string CaseId { get; set; } = "";
|
|
[Key(1)] public string QueryKind { get; set; } = "";
|
|
[Key(2)] public string Intent { get; set; } = "";
|
|
[Key(3)] public YmirQueryFixturePayload Request { get; set; } = new();
|
|
[Key(4)] public YmirQueryFixturePayload Result { get; set; } = new();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirQueryFixturePayload
|
|
{
|
|
[Key(0)] public float? DeltaTime { get; set; }
|
|
[Key(1)] public float? Radius { get; set; }
|
|
[Key(2)] public float? Distance { get; set; }
|
|
[Key(3)] public float2? Center2 { get; set; }
|
|
[Key(4)] public float3? Center3 { get; set; }
|
|
[Key(5)] public float2? Origin2 { get; set; }
|
|
[Key(6)] public float3? Origin3 { get; set; }
|
|
[Key(7)] public float2? Direction2 { get; set; }
|
|
[Key(8)] public float3? Direction3 { get; set; }
|
|
[Key(9)] public YmirFixtureWorld? World { get; set; }
|
|
[Key(10)] public YmirFixtureSphereBody[] SphereBodies { get; set; } = Array.Empty<YmirFixtureSphereBody>();
|
|
[Key(11)] public YmirFixtureCircleOverlapHit[] CircleOverlapHits { get; set; } = Array.Empty<YmirFixtureCircleOverlapHit>();
|
|
[Key(12)] public YmirFixtureSphereOverlapHit[] SphereOverlapHits { get; set; } = Array.Empty<YmirFixtureSphereOverlapHit>();
|
|
[Key(13)] public YmirFixtureCircleCastHit[] CircleCastHits { get; set; } = Array.Empty<YmirFixtureCircleCastHit>();
|
|
[Key(14)] public YmirFixtureSphereCastHit[] SphereCastHits { get; set; } = Array.Empty<YmirFixtureSphereCastHit>();
|
|
[Key(15)] public YmirFixtureContact[] Contacts { get; set; } = Array.Empty<YmirFixtureContact>();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureWorld
|
|
{
|
|
[Key(0)] public float Time { get; set; }
|
|
[Key(1)] public YmirFixtureBody[] Bodies { get; set; } = Array.Empty<YmirFixtureBody>();
|
|
[Key(2)] public YmirFixtureField[] Fields { get; set; } = Array.Empty<YmirFixtureField>();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureBody
|
|
{
|
|
[Key(0)] public string Id { get; set; } = "";
|
|
[Key(1)] public float2 Position { get; set; }
|
|
[Key(2)] public float2 Velocity { get; set; }
|
|
[Key(3)] public float2 Direction { get; set; }
|
|
[Key(4)] public float AngularVelocity { get; set; }
|
|
[Key(5)] public float Torque { get; set; }
|
|
[Key(6)] public float MomentOfInertia { get; set; }
|
|
[Key(7)] public float Radius { get; set; }
|
|
[Key(8)] public float Mass { get; set; }
|
|
[Key(9)] public bool IsStatic { get; set; }
|
|
[Key(10)] public float Restitution { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureSphereBody
|
|
{
|
|
[Key(0)] public string Id { get; set; } = "";
|
|
[Key(1)] public float3 Position { get; set; }
|
|
[Key(2)] public float Radius { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureField
|
|
{
|
|
[Key(0)] public string Id { get; set; } = "";
|
|
[Key(1)] public float2 Position { get; set; }
|
|
[Key(2)] public float Strength { get; set; }
|
|
[Key(3)] public float Radius { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureContact
|
|
{
|
|
[Key(0)] public string BodyA { get; set; } = "";
|
|
[Key(1)] public string BodyB { get; set; } = "";
|
|
[Key(2)] public float2 Point { get; set; }
|
|
[Key(3)] public float2 Normal { get; set; }
|
|
[Key(4)] public float Penetration { get; set; }
|
|
[Key(5)] public float RelativeSpeed { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureCircleOverlapHit
|
|
{
|
|
[Key(0)] public string BodyId { get; set; } = "";
|
|
[Key(1)] public float2 Point { get; set; }
|
|
[Key(2)] public float2 Normal { get; set; }
|
|
[Key(3)] public float Penetration { get; set; }
|
|
[Key(4)] public float Distance { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureSphereOverlapHit
|
|
{
|
|
[Key(0)] public string BodyId { get; set; } = "";
|
|
[Key(1)] public float3 Point { get; set; }
|
|
[Key(2)] public float3 Normal { get; set; }
|
|
[Key(3)] public float Penetration { get; set; }
|
|
[Key(4)] public float Distance { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureCircleCastHit
|
|
{
|
|
[Key(0)] public string BodyId { get; set; } = "";
|
|
[Key(1)] public float2 Point { get; set; }
|
|
[Key(2)] public float2 Normal { get; set; }
|
|
[Key(3)] public float Distance { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class YmirFixtureSphereCastHit
|
|
{
|
|
[Key(0)] public string BodyId { get; set; } = "";
|
|
[Key(1)] public float3 Point { get; set; }
|
|
[Key(2)] public float3 Normal { get; set; }
|
|
[Key(3)] public float Distance { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class AuthorityDecisionFixtureSet
|
|
{
|
|
[Key(0)] public string Schema { get; set; } = "";
|
|
[Key(1)] public string GeneratedAtUtc { get; set; } = "";
|
|
[Key(2)] public AuthorityDecisionCase[] Cases { get; set; } = Array.Empty<AuthorityDecisionCase>();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class AuthorityDecisionCase
|
|
{
|
|
[Key(0)] public string CaseId { get; set; } = "";
|
|
[Key(1)] public string LocalRuntimeId { get; set; } = "";
|
|
[Key(2)] public AetheriaRuntimeVerseAuthorityPolicyDocument Policy { get; set; } = new();
|
|
[Key(3)] public AetheriaRuntimeAuthorityLeaseDocument[] Leases { get; set; } = Array.Empty<AetheriaRuntimeAuthorityLeaseDocument>();
|
|
[Key(4)] public AetheriaRuntimeDaemonCommandDocument Command { get; set; } = new();
|
|
[Key(5)] public AuthorityDecisionSnapshot Decision { get; set; } = new();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
internal sealed class AuthorityDecisionSnapshot
|
|
{
|
|
[Key(0)] public bool Authorized { get; set; }
|
|
[Key(1)] public string Reason { get; set; } = "";
|
|
[Key(2)] public string Mode { get; set; } = "";
|
|
[Key(3)] public string SubjectKey { get; set; } = "";
|
|
[Key(4)] public string ClaimKind { get; set; } = "";
|
|
[Key(5)] public string AuthorRuntimeId { get; set; } = "";
|
|
[Key(6)] public string RuleId { get; set; } = "";
|
|
}
|
|
|
|
internal static class FreezeJson
|
|
{
|
|
public static readonly JsonSerializerOptions Options = new()
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
}
|
|
|
|
internal sealed class FreezeOptions
|
|
{
|
|
public string Root { get; init; } = "";
|
|
public string StatePath { get; init; } = "";
|
|
public string OutputDirectory { get; init; } = "";
|
|
public bool RunDaemon { get; init; } = true;
|
|
public bool CleanState { get; init; } = true;
|
|
public bool IncludeCommandBatches { get; init; } = true;
|
|
public double ViewportMinX { get; init; } = -1000;
|
|
public double ViewportMinY { get; init; } = -1000;
|
|
public double ViewportMaxX { get; init; } = 1000;
|
|
public double ViewportMaxY { get; init; } = 1000;
|
|
|
|
public static FreezeOptions Parse(IReadOnlyList<string> args)
|
|
{
|
|
var root = FullPath(ReadOption(args, "--root"), Directory.GetCurrentDirectory());
|
|
var defaultState = Path.Combine(root, "obj", "freeze", "aetheria-freeze-state.cc");
|
|
var defaultOutput = Path.Combine(root, "Aetheria.State", "fixtures", "rust-rebuild-freeze");
|
|
return new FreezeOptions
|
|
{
|
|
Root = root,
|
|
StatePath = FullPath(ReadOption(args, "--state"), defaultState),
|
|
OutputDirectory = FullPath(ReadOption(args, "--out"), defaultOutput),
|
|
RunDaemon = !HasFlag(args, "--no-run-daemon"),
|
|
CleanState = !HasFlag(args, "--no-clean-state"),
|
|
IncludeCommandBatches = !HasFlag(args, "--no-command-batches"),
|
|
ViewportMinX = ReadDouble(args, "--viewport-min-x") ?? -1000,
|
|
ViewportMinY = ReadDouble(args, "--viewport-min-y") ?? -1000,
|
|
ViewportMaxX = ReadDouble(args, "--viewport-max-x") ?? 1000,
|
|
ViewportMaxY = ReadDouble(args, "--viewport-max-y") ?? 1000
|
|
};
|
|
}
|
|
|
|
private static string FullPath(string value, string fallback)
|
|
{
|
|
return Path.GetFullPath(string.IsNullOrWhiteSpace(value) ? fallback : value);
|
|
}
|
|
|
|
private static string ReadOption(IReadOnlyList<string> args, string name)
|
|
{
|
|
for (var i = 0; i < args.Count - 1; i++)
|
|
{
|
|
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
|
|
return args[i + 1];
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
private static double? ReadDouble(IReadOnlyList<string> args, string name)
|
|
{
|
|
return double.TryParse(ReadOption(args, name), out var value) ? value : null;
|
|
}
|
|
|
|
private static bool HasFlag(IReadOnlyList<string> args, string name)
|
|
{
|
|
return args.Any(arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
}
|