diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 83601edf0..8ff9013e6 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -324,6 +324,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Require(first.HullItemKey == second.HullItemKey && first.Equipment.Select(value => value.ItemKey).SequenceEqual(second.Equipment.Select(value => value.ItemKey)), "same seed, map, faction and catalog must produce the same loadout"); + Require(first.Receipt.Seed == 42 && first.Receipt.SourceZoneIndex == 0 && + first.Receipt.AvailabilityFactionKey == "forge" && + first.Receipt.Selections.Any(value => value.Role == "hull" && value.ItemKey == "available-hull" && + value.ManufacturerKey == "forge" && value.ManufacturerDistance == 1 && value.Allegiance == 1), + "generated loadouts must carry daemon-authored source and selection provenance"); Require(station.Equipment.Any(value => value.ItemKey == "docking-bay") && station.Equipment.Any(value => value.ItemKey == "cargo-bay") && station.Equipment.Any(value => value.ItemKey == "capacitor") && diff --git a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs index 6f4706512..a6c65c7c2 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs @@ -6,6 +6,7 @@ public sealed class AetheriaDaemonLoadoutGenerator { private readonly AetheriaRuntimeCatalogSnapshot _catalog; private readonly Random _random; + private readonly uint _seed; private readonly int _zoneIndex; private readonly IReadOnlyDictionary _homeZones; private readonly IReadOnlyDictionary> _adjacency; @@ -20,7 +21,8 @@ public sealed class AetheriaDaemonLoadoutGenerator double priceExponent = 0.5) { _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); - _random = new Random(seed == 0 ? 1u : seed); + _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)); @@ -77,7 +79,37 @@ public sealed class AetheriaDaemonLoadoutGenerator var cargo = PackCargo(cargoBay, availabilityFactionKey, scenarioCargo, string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)); - return new AetheriaDaemonLoadout(hull.ItemKey, slots.ToArray(), cargo); + var selectedKeys = new[] { (Role: "hull", Key: hull.ItemKey) } + .Concat(slots.Select(value => (Role: "equipment", 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() + }; + return new AetheriaDaemonLoadout(hull.ItemKey, slots.ToArray(), cargo, 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()) + .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( @@ -314,4 +346,5 @@ public sealed class AetheriaDaemonLoadoutGenerator public sealed record AetheriaDaemonLoadout( string HullItemKey, AetheriaEntityItemSlot[] Equipment, - AetheriaLoadoutItemSlot[] Cargo); + AetheriaLoadoutItemSlot[] Cargo, + AetheriaLoadoutGenerationReceipt Receipt); diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index 71816342f..a6306ccbf 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -242,6 +242,7 @@ internal static class AetheriaDaemonZoneGenerator Velocity = Vec2(vx, vy), FactionKey = faction, HullItemKey = loadout.HullItemKey, + LoadoutGeneration = loadout.Receipt, IsActive = true, HeatsinksEnabled = true, TractorPower = 0, diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index f5499ab23..9b535a297 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -2598,7 +2598,24 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( Contacts = (entity.Contacts ?? Array.Empty()) .Select(contact => ToRuntimeContact(contact, entityIndices)) .Where(contact => contact.TargetEntityIndex >= 0) - .ToArray() + .ToArray(), + LoadoutGeneration = entity.LoadoutGeneration == null ? null : new AetheriaRuntimeLoadoutGenerationReceiptCommit + { + Seed = entity.LoadoutGeneration.Seed, + SourceZoneIndex = entity.LoadoutGeneration.SourceZoneIndex, + AvailabilityFactionKey = entity.LoadoutGeneration.AvailabilityFactionKey ?? "", + PriceExponent = entity.LoadoutGeneration.PriceExponent, + Selections = (entity.LoadoutGeneration.Selections ?? Array.Empty()) + .Select(value => new AetheriaRuntimeLoadoutGenerationSelectionCommit + { + Role = value.Role ?? "", + ItemKey = value.ItemKey ?? "", + ManufacturerKey = value.ManufacturerKey ?? "", + Price = value.Price, + ManufacturerDistance = value.ManufacturerDistance, + Allegiance = value.Allegiance + }).ToArray() + } }; AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(runtimeEntity, catalog); return runtimeEntity; diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 54a31acc4..e000159cb 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -1244,7 +1244,9 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root) "FitsHardpoint(candidate, hardpoint)", "HasBehavior(candidate, controllerKind)", "for (var draw = 0; draw < 16 && pool.Count > 0; draw++)", - "FindFit(interiorCells, width, height, item, occupied)" + "FindFit(interiorCells, width, height, item, occupied)", + "AetheriaLoadoutGenerationReceipt", + "Selections = selectedKeys.Select" }; var daemonText = daemonProgram + "\n" + daemonZoneGenerator + "\n" + daemonLoadoutGenerator; diff --git a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs index 73ba3c4ee..c337c481c 100644 --- a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs +++ b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs @@ -607,6 +607,30 @@ public sealed class AetheriaEntitySnapshot [Key(31)] public string[] AgentTaskCapabilities { get; set; } = []; + + [Key(32)] + public AetheriaLoadoutGenerationReceipt? LoadoutGeneration { get; set; } +} + +[MessagePackObject] +public sealed class AetheriaLoadoutGenerationReceipt +{ + [Key(0)] public uint Seed { get; set; } + [Key(1)] public int SourceZoneIndex { get; set; } + [Key(2)] public string AvailabilityFactionKey { get; set; } = ""; + [Key(3)] public double PriceExponent { get; set; } + [Key(4)] public AetheriaLoadoutGenerationSelection[] Selections { get; set; } = []; +} + +[MessagePackObject] +public sealed class AetheriaLoadoutGenerationSelection +{ + [Key(0)] public string Role { get; set; } = ""; + [Key(1)] public string ItemKey { get; set; } = ""; + [Key(2)] public string ManufacturerKey { get; set; } = ""; + [Key(3)] public int Price { get; set; } + [Key(4)] public int ManufacturerDistance { get; set; } + [Key(5)] public double Allegiance { get; set; } } [MessagePackObject] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 0f6fed9bb..50ec06326 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -638,12 +638,42 @@ namespace GameCult.Aetheria.State.Verse ["targetCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.SetTarget), ["actionCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup) }; + if (entity.LoadoutGeneration != null) + { + props["loadoutGenerationSeed"] = entity.LoadoutGeneration.Seed.ToString(CultureInfo.InvariantCulture); + props["loadoutGenerationZone"] = entity.LoadoutGeneration.SourceZoneIndex.ToString(CultureInfo.InvariantCulture); + props["loadoutAvailabilityFaction"] = entity.LoadoutGeneration.AvailabilityFactionKey ?? ""; + props["loadoutPriceExponent"] = entity.LoadoutGeneration.PriceExponent.ToString("0.###", CultureInfo.InvariantCulture); + } return new AetheriaRuntimeSurfaceComponent( $"aetheria.daemon.game.world.entity.{entity.EntityIndex}", "world.entity3d", props, - Array.Empty()); + LoadoutGenerationItems(entity)); + } + + private static AetheriaRuntimeSurfaceComponent[] LoadoutGenerationItems( + AetheriaRuntimeEntitySnapshotCommit entity) + { + return (entity.LoadoutGeneration?.Selections ?? Array.Empty()) + .Select((value, index) => new AetheriaRuntimeSurfaceComponent( + $"aetheria.daemon.game.world.entity.{entity.EntityIndex}.loadout.{index}", + "loadout.item", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = value.ItemKey ?? "", + ["status"] = value.Role ?? "", + ["detail"] = $"{value.ManufacturerKey} | {value.Price} credits | distance {value.ManufacturerDistance}", + ["badges"] = $"allegiance:{value.Allegiance:0.###}", + ["itemKey"] = value.ItemKey ?? "", + ["manufacturerKey"] = value.ManufacturerKey ?? "", + ["price"] = value.Price.ToString(CultureInfo.InvariantCulture), + ["manufacturerDistance"] = value.ManufacturerDistance.ToString(CultureInfo.InvariantCulture), + ["allegiance"] = value.Allegiance.ToString("0.###", CultureInfo.InvariantCulture) + }, + Array.Empty())) + .ToArray(); } private static AetheriaRuntimeSurfaceComponent PlayableWorldProjectile( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index f61f447f7..806d438ec 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -961,6 +961,30 @@ namespace GameCult.Aetheria.State.Verse [Key(43)] public string HomeEntityId { get; set; } = ""; + + [Key(44)] + public AetheriaRuntimeLoadoutGenerationReceiptCommit? LoadoutGeneration { get; set; } + } + + [MessagePackObject] + public sealed class AetheriaRuntimeLoadoutGenerationReceiptCommit + { + [Key(0)] public uint Seed { get; set; } + [Key(1)] public int SourceZoneIndex { get; set; } + [Key(2)] public string AvailabilityFactionKey { get; set; } = ""; + [Key(3)] public double PriceExponent { get; set; } + [Key(4)] public IReadOnlyList Selections { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class AetheriaRuntimeLoadoutGenerationSelectionCommit + { + [Key(0)] public string Role { get; set; } = ""; + [Key(1)] public string ItemKey { get; set; } = ""; + [Key(2)] public string ManufacturerKey { get; set; } = ""; + [Key(3)] public int Price { get; set; } + [Key(4)] public int ManufacturerDistance { get; set; } + [Key(5)] public double Allegiance { get; set; } } [MessagePackObject] diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 45b14f6a3..e9cf82f80 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -17,7 +17,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. |---|---|---|---|---|---|---| | Run/session state | `ServerShared`, `ActionGameManager` | Aetheria daemon/CultCache | provider/session advertisement | all clients | owner-exists; wired; projected; not proven | restart and multi-client convergence | | Entity identity/loadout | `ServerShared/Entity*` | daemon | typed entity and inventory surfaces | EveUnity/Electron | owner-exists; wired; projected; not proven | loadout round trip and reconnect | -| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, hardpoints require exact type/cell/rotation fit and controller role, and cargo/capacitor plus station docking equipment must fit remaining hull space; stations draw up to sixteen faction-available equipment rows and pack largest-first into the selected cargo bay's actual interior shape; Terminus explicitly maps combat-role factions to catalog availability corporations | multi-zone generated-world parity, faction-local stream/call-order parity, and generation provenance projection | +| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, hardpoints require exact type/cell/rotation fit and controller role, and cargo/capacitor plus station docking equipment must fit remaining hull space; stations draw up to sixteen faction-available equipment rows and pack largest-first into the selected cargo bay's actual interior shape; every generated entity persists an immutable source/selection receipt that reaches Eve as `loadout.item` semantics; Terminus explicitly maps combat-role factions to catalog availability corporations | multi-zone generated-world parity and exact faction-local stream/call-order parity | | Flight intent | `Ship`, `Thruster`, `AetherDrive`, input paths | daemon intent + Ymir physics | analog movement/look/tractor | EveUnity input driver | movement intents feed Ymir-owned body integration; authored thrust allocation, torque, energy and heat remain simplified | prove original drive/equipment behavior ordering and runtime interpolation | | Collision | Unity physics and entity handlers | Ymir | collision/result facts | visual interpolation only | Ymir.Core world stepping wired for entity bodies, radial fields and contacts; missing-owner negative proof passes | publish authoritative collision events and prove Unity cannot write transforms back | | Targeting/contacts | sensors + `ActionGameManager` | daemon | contact/target semantics | target HUD and indicators | simplified owner wired/projected; daemon weapon lock now acquires and decays from facing, elapsed time, and observer-local contact confidence instead of granting an immediate perfect lock | target-switch reset, angular decay, visibility/nearest/explicit cases, and runtime lock feedback |