Publish loadout generation provenance

This commit is contained in:
Meta Vens 2026-07-11 20:37:09 +01:00
commit 2edf34916d
9 changed files with 143 additions and 7 deletions

View file

@ -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") &&

View file

@ -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<string, int> _homeZones;
private readonly IReadOnlyDictionary<int, IReadOnlyList<int>> _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<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(
@ -314,4 +346,5 @@ public sealed class AetheriaDaemonLoadoutGenerator
public sealed record AetheriaDaemonLoadout(
string HullItemKey,
AetheriaEntityItemSlot[] Equipment,
AetheriaLoadoutItemSlot[] Cargo);
AetheriaLoadoutItemSlot[] Cargo,
AetheriaLoadoutGenerationReceipt Receipt);

View file

@ -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,

View file

@ -2598,7 +2598,24 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(
Contacts = (entity.Contacts ?? Array.Empty<AetheriaEntityContactSnapshot>())
.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<AetheriaLoadoutGenerationSelection>())
.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;

View file

@ -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;

View file

@ -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]

View file

@ -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<AetheriaRuntimeSurfaceComponent>());
LoadoutGenerationItems(entity));
}
private static AetheriaRuntimeSurfaceComponent[] LoadoutGenerationItems(
AetheriaRuntimeEntitySnapshotCommit entity)
{
return (entity.LoadoutGeneration?.Selections ?? Array.Empty<AetheriaRuntimeLoadoutGenerationSelectionCommit>())
.Select((value, index) => new AetheriaRuntimeSurfaceComponent(
$"aetheria.daemon.game.world.entity.{entity.EntityIndex}.loadout.{index}",
"loadout.item",
new Dictionary<string, string>(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<AetheriaRuntimeSurfaceComponent>()))
.ToArray();
}
private static AetheriaRuntimeSurfaceComponent PlayableWorldProjectile(

View file

@ -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<AetheriaRuntimeLoadoutGenerationSelectionCommit> Selections { get; set; } = Array.Empty<AetheriaRuntimeLoadoutGenerationSelectionCommit>();
}
[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]

View file

@ -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 |