diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 38fb09129..4f533934c 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -28,6 +28,10 @@ internal sealed class AetheriaDaemonYmirSmokeChecks PickupIsCapacityCheckedExactlyOnceAndExpires(); PickupShieldContactCollectsOrBounces(); ThermalCellsUseFossilConductionAndRadiation(); + EnergyNetworkSettlesReactorAfterConsumers(); + RadiatorPumpsHeatBeforeReactorSettlement(); + EquipmentThermalPerformanceOwnsShutdownAndWear(); + ThermalTopologyComesFromHullAndEquipment(); MultipleActorsUseTheSameMovementLever(); AgentClaimsAndCompletesExploreTaskThroughCommands(); SchedulerAssignsHighestPriorityCompatibleTask(); @@ -58,14 +62,16 @@ internal sealed class AetheriaDaemonYmirSmokeChecks source.DirectionY = 0; source.TargetEntityIndex = 2; source.WeaponGroups = [new[] { 0 }]; - source.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit - { - Item = new AetheriaRuntimeLoadoutItemCommit - { ItemKey = "test-beam", Quality = 1, Durability = 1, Enabled = true } - }]; + source.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-beam", Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-beam-capacitor", Quality = 1, Durability = 1, Enabled = true } } + ]; source.BehaviorStates = [new AetheriaRuntimeBehaviorStateCommit { - OwnerKind = "fixture", OwnerIndex = 0, BehaviorIndex = 0, + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, OwnerIndex = 1, BehaviorIndex = 0, BehaviorKind = "Capacitor", CapacitorCharge = 10, CapacitorCapacity = 10, CapacitorEfficiency = 1 }]; source.CargoContents = [Cargo(("beam-ammo", 1, 0, 0))]; @@ -89,7 +95,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(14, Number(0.3)), new AetheriaRuntimeBehaviorField(17, Number(0.15)) ]); - var catalog = new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-beam", payload)], [], []); + var catalog = new AetheriaRuntimeCatalogSnapshot( + [CatalogItem("test-beam", payload), CatalogItem("test-beam-capacitor", CapacitorPayload(10, 1))], [], []); var intents = new AetheriaRuntimeDaemonIntentState(); intents.WeaponGroups.Add(new AetheriaRuntimeDaemonWeaponGroupIntent { @@ -382,14 +389,16 @@ internal sealed class AetheriaDaemonYmirSmokeChecks { var source = Entity(0, -50, "player"); var target = Entity(1, 0, "neutral"); - target.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit - { - Item = new AetheriaRuntimeLoadoutItemCommit - { ItemKey = "test-energy-shield", Quality = 1, Durability = 1, Enabled = true } - }]; + target.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-energy-shield", Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-shield-capacitor", Quality = 1, Durability = 1, Enabled = true } } + ]; target.BehaviorStates = [new AetheriaRuntimeBehaviorStateCommit { - OwnerKind = "fixture", OwnerIndex = 0, BehaviorIndex = 0, + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, OwnerIndex = 1, BehaviorIndex = 0, BehaviorKind = "Capacitor", CapacitorCharge = 100, CapacitorCapacity = 100, CapacitorEfficiency = 1 }]; @@ -399,7 +408,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(2, PerformanceStat(3)) ]); var catalog = new AetheriaRuntimeCatalogSnapshot( - [CatalogItem("test-energy-shield", shield)], [], []); + [CatalogItem("test-energy-shield", shield), CatalogItem("test-shield-capacitor", CapacitorPayload(100, 1))], [], []); var zone = new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [source, target] }; var run = new AetheriaRuntimeRunCheckpointCommit { @@ -959,6 +968,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Array.Empty(), Array.Empty()); + private static AetheriaRuntimeBehaviorPayload CapacitorPayload(double capacity, double efficiency) => new( + 0, "Capacitor", 0, + [ + new AetheriaRuntimeBehaviorField(1, PerformanceStat(capacity)), + new AetheriaRuntimeBehaviorField(2, PerformanceStat(efficiency)) + ]); + private static AetheriaRuntimeCatalogItem CatalogItem(string itemKey, params AetheriaRuntimeBehaviorPayload[] payloads) => new( itemKey, itemKey, "equipment", "", "", 0, 1, 1, 1, 1, 1, 1, 1, Array.Empty(), @@ -1216,13 +1232,15 @@ internal sealed class AetheriaDaemonYmirSmokeChecks { ItemKey = "test-lock-cannon", Quality = 1, Durability = 1, Enabled = true } - } + }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-attack-capacitor", Quality = 1, Durability = 1, Enabled = true } } ]; agent.BehaviorStates = [ new AetheriaRuntimeBehaviorStateCommit { - OwnerKind = "fixture", OwnerIndex = 0, BehaviorIndex = 0, + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, OwnerIndex = 1, BehaviorIndex = 0, BehaviorKind = "Capacitor", CapacitorCharge = 50, CapacitorCapacity = 50, CapacitorEfficiency = 1 } ]; @@ -1255,7 +1273,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(25, PerformanceStat(1)) ]); var catalog = new AetheriaRuntimeCatalogSnapshot( - [CatalogItem("test-lock-cannon", weaponPayload)], + [CatalogItem("test-lock-cannon", weaponPayload), CatalogItem("test-attack-capacitor", CapacitorPayload(50, 1))], Array.Empty(), Array.Empty()); var target = Entity(1, 105, "raider"); @@ -1875,6 +1893,223 @@ internal sealed class AetheriaDaemonYmirSmokeChecks return command; } + private static void EnergyNetworkSettlesReactorAfterConsumers() + { + var entity = Entity(0, 0, "player"); + entity.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "ledger-capacitor", Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "ledger-reactor", Quality = 1, Durability = 1, Enabled = true } } + ]; + entity.BehaviorStates = + [ + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 0, BehaviorIndex = 0, BehaviorKind = "Capacitor", + CapacitorCharge = 2, CapacitorCapacity = 10, CapacitorEfficiency = 0.5 + }, + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 1, BehaviorIndex = 0, BehaviorKind = "Reactor" + } + ]; + entity.StatGrids = + [ + Grid("hull", 100), Grid("shield", 0), Grid("heat", 280), + Grid("temperature", 280), Grid("thermal-mass", 1), Grid("conductivity", 1) + ]; + var capacitorItem = CatalogItem("ledger-capacitor", CapacitorPayload(10, 0.5)); + capacitorItem.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var reactorPayload = new AetheriaRuntimeBehaviorPayload(0, "Reactor", 100, + [ + new AetheriaRuntimeBehaviorField(1, PerformanceStat(4)), + new AetheriaRuntimeBehaviorField(2, PerformanceStat(2)), + new AetheriaRuntimeBehaviorField(3, PerformanceStat(0.5)), + new AetheriaRuntimeBehaviorField(4, PerformanceStat(2)) + ]); + var reactorItem = CatalogItem("ledger-reactor", reactorPayload); + reactorItem.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var catalog = new AetheriaRuntimeCatalogSnapshot([capacitorItem, reactorItem], [], []); + + AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog); + Require(AetheriaRuntimeEnergySimulation.TryConsume(entity, catalog, 5), + "online reactor must accept residual demand after capacitors drain"); + var capacitor = entity.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor"); + var reactor = entity.BehaviorStates.Single(value => value.BehaviorKind == "Reactor"); + RequireNear(0, capacitor.CapacitorCharge, 0.000001, + "consumers must drain capacitors before adding reactor draw"); + RequireNear(3, reactor.ReactorDraw, 0.000001, + "residual demand must accumulate on the last-running reactor"); + + AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, 1); + RequireNear(1, capacitor.CapacitorCharge, 0.000001, + "baseline reactor surplus must refill non-full capacitors after demand settles"); + RequireNear(0, reactor.ReactorDraw, 0.000001, + "reactor settlement must consume and reset the per-tick demand ledger"); + RequireNear(283.5, GridValue(entity, "temperature", 0), 0.000001, + "capacitor discharge/refill losses and reactor baseline heat must reach canonical item cells"); + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "energy-ledger-smoke", CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [entity] }] + }; + var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run }, + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon")); + var worldEntity = Flatten(surface.Surface.Root).Single(node => + node.Id == "aetheria.daemon.game.world.entity.0"); + RequireEqual("1", worldEntity.Props["capacitorCharge"], + "Eve must expose the settled canonical capacitor charge"); + RequireEqual("0", worldEntity.Props["reactorDraw"], + "Eve must expose the reset post-settlement reactor ledger"); + } + + private static void RadiatorPumpsHeatBeforeReactorSettlement() + { + var entity = Entity(0, 0, "player"); + entity.HeatsinksEnabled = true; + entity.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "radiator-capacitor", Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "radiator", Quality = 1, Durability = 1, Enabled = true } } + ]; + entity.BehaviorStates = + [ + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 0, BehaviorIndex = 0, BehaviorKind = "Capacitor", CapacitorCharge = 10 + }, + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 1, BehaviorIndex = 0, BehaviorKind = "Radiator", RadiatorTemperature = 280 + } + ]; + entity.StatGrids = + [ + Grid("hull", 100), Grid("shield", 0), Grid("heat", 400), + Grid("temperature", 400), Grid("thermal-mass", 1), Grid("conductivity", 1) + ]; + var capacitor = CatalogItem("radiator-capacitor", CapacitorPayload(10, 1)); + capacitor.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var radiatorPayload = new AetheriaRuntimeBehaviorPayload(0, "Radiator", 0, + [ + new AetheriaRuntimeBehaviorField(1, PerformanceStat(0)), + new AetheriaRuntimeBehaviorField(2, PerformanceStat(1)), + new AetheriaRuntimeBehaviorField(3, Number(300)), + new AetheriaRuntimeBehaviorField(4, PerformanceStat(0.5)), + new AetheriaRuntimeBehaviorField(5, PerformanceStat(1)), + new AetheriaRuntimeBehaviorField(6, PerformanceStat(10)) + ]); + var radiator = CatalogItem("radiator", radiatorPayload); + radiator.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var catalog = new AetheriaRuntimeCatalogSnapshot([capacitor, radiator], [], []); + + AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog); + AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, 1); + + RequireNear(9, entity.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor").CapacitorCharge, + 0.000001, "radiator pump must consume authored energy before reactor settlement"); + RequireNear(300.5, GridValue(entity, "temperature", 0), 0.000001, + "radiator must remove pumped heat and return authored waste heat to its item cells"); + RequireNear(290, entity.BehaviorStates.Single(value => value.BehaviorKind == "Radiator").RadiatorTemperature, + 0.000001, "pumped heat must accumulate in radiator thermal mass before radiation"); + } + + private static void EquipmentThermalPerformanceOwnsShutdownAndWear() + { + var entity = Entity(0, 0, "player"); + entity.ShutdownPerformance = 0.25; + var item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "thermal-wear-item", Quality = 0, Durability = 10, Enabled = true }; + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = item }]; + entity.StatGrids = + [ + Grid("hull", 100), Grid("shield", 0), Grid("heat", 300), + Grid("temperature", 300), Grid("thermal-mass", 1), Grid("conductivity", 1) + ]; + var wearPayload = new AetheriaRuntimeBehaviorPayload(0, "Wear", 0, + [new AetheriaRuntimeBehaviorField(1, new AetheriaRuntimeBehaviorValue( + "bool", "", 0, true, "", "", [], []))]); + var typed = CatalogItem("thermal-wear-item", wearPayload); + typed.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + typed.Durability = 10; + typed.ThermalResilience = 2; + typed.MinimumTemperature = 200; + typed.MaximumTemperature = 400; + typed.ThermalPerformanceCurveKeys = + [ + new AetheriaRuntimeCurveKey(0, 1, 0, 0), + new AetheriaRuntimeCurveKey(1, 0, 0, 0) + ]; + var catalog = new AetheriaRuntimeCatalogSnapshot([typed], [], []); + + AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, 1); + var state = entity.EquipmentStates.Single(); + RequireNear(0.5, state.ThermalPerformance, 0.000001, + "item thermal performance must come from mean occupied-cell temperature and authored curve"); + Require(state.ThermalOnline && state.Online, + "item above shutdown performance with positive durability must remain online"); + RequireNear(10 - state.Wear, item.Durability, 0.000001, + "generic Wear behavior must apply the computed thermal wear potential at its authored cadence"); + + var temperature = entity.StatGrids.Single(value => value.Name == "temperature"); + temperature.Values = [380]; + item.OverrideShutdown = false; + entity.OverrideShutdown = true; + AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, 1); + state = entity.EquipmentStates.Single(); + Require(!state.ThermalOnline && !state.Online, + "entity override alone must not bypass item thermal shutdown"); + item.OverrideShutdown = true; + AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, 1); + Require(entity.EquipmentStates.Single().ThermalOnline, + "thermal shutdown bypass must require both entity-wide and item-local override"); + } + + private static void ThermalTopologyComesFromHullAndEquipment() + { + var entity = Entity(0, 0, "player"); + entity.HullItemKey = "thermal-hull"; + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + X = 0, Y = 0, Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "thermal-gear", Quality = 1, Durability = 1, Enabled = true } + }]; + var hull = HullCatalogItem("thermal-hull", 2, 1, 0); + hull.Mass = 10; + hull.SpecificHeat = 2; + hull.Conductivity = 4; + hull.InteriorShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var gear = CatalogItem("thermal-gear"); + gear.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + gear.Mass = 4; + gear.SpecificHeat = 5; + gear.Conductivity = 3; + var catalog = new AetheriaRuntimeCatalogSnapshot([hull, gear], [], []); + + AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog); + + var mass = entity.StatGrids.Single(value => value.Name == "thermal-mass"); + var conductivity = entity.StatGrids.Single(value => value.Name == "conductivity"); + RequireNear(30, mass.Values[0], 0.000001, + "occupied cell thermal mass must include its proportional equipment mass"); + RequireNear(10, mass.Values[1], 0.000001, + "hull thermal mass must be divided across authored hull cells"); + RequireNear(3, conductivity.Values[0], 0.000001, + "occupied cell conductivity must come from installed equipment"); + RequireNear(280, entity.StatGrids.Single(value => value.Name == "temperature").Values[1], 0.000001, + "authored hull topology must initialize each occupied cell at the fossil temperature"); + } + private static void ThermalCellsUseFossilConductionAndRadiation() { var entity = Entity(7, 0, "player"); diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index e37845267..1b369c692 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -75,6 +75,7 @@ RequireMainMenuVerseHostDocumentAccess(root); RequireMainMenuContinueRunState(root); RequireUnityObserverDoesNotTickLocalSimulation(root); RequireUnityDoesNotCallSharedSimulationTicks(root); +RequireDaemonEnergyAndThermalAuthority(root); RequireUnityPhysicsIsNotGameplayAuthority(root); RequireDeadPropertiesPanelShellDeleted(root); RequireTypedBehaviorMetadataCoverage(root); @@ -16327,6 +16328,104 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) } } +static void RequireDaemonEnergyAndThermalAuthority(string root) +{ + var runtime = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime"); + var energyPath = Path.Combine(runtime, "AetheriaRuntimeEnergySimulation.cs"); + var thermalPath = Path.Combine(runtime, "AetheriaRuntimeThermalSimulation.cs"); + var simulationPath = Path.Combine(runtime, "AetheriaRuntimeDaemonSimulation.cs"); + var snapshotPath = Path.Combine(runtime, "AetheriaRuntimeSnapshotDocuments.cs"); + var surfacePath = Path.Combine(runtime, "AetheriaRuntimeDaemonGameSurfaceBuilder.cs"); + var operationsPath = Path.Combine(runtime, "AetheriaRuntimeDaemonOperations.cs"); + + var sources = new Dictionary + { + [energyPath] = File.Exists(energyPath) ? File.ReadAllText(energyPath) : "", + [thermalPath] = File.Exists(thermalPath) ? File.ReadAllText(thermalPath) : "", + [simulationPath] = File.Exists(simulationPath) ? File.ReadAllText(simulationPath) : "", + [snapshotPath] = File.Exists(snapshotPath) ? File.ReadAllText(snapshotPath) : "", + [surfacePath] = File.Exists(surfacePath) ? File.ReadAllText(surfacePath) : "" + }; + var required = new Dictionary + { + [energyPath] = new[] + { + "public static class AetheriaRuntimeEnergySimulation", + "public static bool TryConsume(", + "public static void StepRadiators(", + "public static void SettleReactors(", + "AddCapacitorCharge(" + }, + [thermalPath] = new[] + { + "public static void EnsureTopology(", + "public static void UpdateEquipmentStates(", + "public static void AddHeatToEquipment(", + "public static void ApplyWear(" + }, + [simulationPath] = new[] + { + "AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog)", + "AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, deltaSeconds)", + "AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog)", + "AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds)", + "AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds)", + "AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog)", + "AetheriaRuntimeEnergySimulation.TryConsume(entity, catalog, demand)" + }, + [snapshotPath] = new[] + { + "public IReadOnlyList EquipmentStates", + "public sealed class AetheriaRuntimeEquipmentStateCommit", + "public double ThermalPerformance", + "public double Wear", + "public bool Online" + }, + [surfacePath] = new[] + { + "[\"capacitorCharge\"]", + "[\"reactorDraw\"]", + "[\"radiatorTemperature\"]", + "[\"minimumEquipmentThermalPerformance\"]", + "[\"offlineEquipmentCount\"]" + } + }; + var missing = required.SelectMany(pair => pair.Value + .Where(symbol => !sources[pair.Key].Contains(symbol, StringComparison.Ordinal)) + .Select(symbol => $"{Path.GetRelativePath(root, pair.Key)}: missing {symbol}")) + .ToArray(); + if (missing.Length > 0) + throw new InvalidOperationException( + "Daemon energy and thermal authority must execute one catalog-derived network and publish its facts through Eve: " + + string.Join("; ", missing)); + + var operations = File.Exists(operationsPath) ? File.ReadAllText(operationsPath) : ""; + var forbiddenOperations = new[] + { + "ApplyCommitEnergy(", + "ApplySetHeat(", + "ApplySetEquipmentOnline(", + "ApplyEquipmentWear(" + }; + var surviving = forbiddenOperations.Where(symbol => operations.Contains(symbol, StringComparison.Ordinal)).ToArray(); + if (surviving.Length > 0) + throw new InvalidOperationException( + "Client commands may request semantic behavior but may not directly write daemon energy, heat, wear, or equipment online state: " + + string.Join(", ", surviving)); + + var simulation = sources[simulationPath]; + var ordered = new[] + { + "AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds)", + "AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds)", + "AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog)" + }; + if (!(simulation.IndexOf(ordered[0], StringComparison.Ordinal) < simulation.IndexOf(ordered[1], StringComparison.Ordinal) && + simulation.IndexOf(ordered[1], StringComparison.Ordinal) < simulation.IndexOf(ordered[2], StringComparison.Ordinal))) + throw new InvalidOperationException( + "Daemon tick order must pump radiators before reactor settlement, then conduct and radiate the resulting cell heat."); +} + static void RequireUnityDoesNotCallSharedSimulationTicks(string root) { var checkedRoots = new[] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 6842e79ba..4d83755fa 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -653,6 +653,23 @@ namespace GameCult.Aetheria.State.Verse var thermalVisibility = EntityStat(entity, "thermal-visibility"); var maximumHull = MaximumHull(entity, simulationSettings); var maximumShield = MaximumShield(entity, simulationSettings); + var behaviorStates = entity.BehaviorStates ?? Array.Empty(); + var capacitorCharge = behaviorStates.Where(value => value != null && value.BehaviorKind == "Capacitor") + .Sum(value => Math.Max(0, value.CapacitorCharge)); + var capacitorCapacity = behaviorStates.Where(value => value != null && value.BehaviorKind == "Capacitor") + .Sum(value => Math.Max(0, value.CapacitorCapacity)); + var reactorDraw = behaviorStates.Where(value => value != null && value.BehaviorKind == "Reactor") + .Sum(value => value.ReactorDraw); + var reactorLoad = behaviorStates.Where(value => value != null && value.BehaviorKind == "Reactor") + .Select(value => value.ReactorLoadRatio).DefaultIfEmpty(0).Max(); + var radiatorTemperature = behaviorStates.Where(value => value != null && value.BehaviorKind == "Radiator") + .Select(value => value.RadiatorTemperature).DefaultIfEmpty(0).Max(); + var equipmentStates = entity.EquipmentStates ?? Array.Empty(); + var minimumThermalPerformance = equipmentStates.Where(value => value != null) + .Select(value => value.ThermalPerformance).DefaultIfEmpty(1).Min(); + var maximumWear = equipmentStates.Where(value => value != null) + .Select(value => value.Wear).DefaultIfEmpty(0).Max(); + var offlineEquipmentCount = equipmentStates.Count(value => value != null && !value.Online); var weaponCooldown = (entity.WeaponStates ?? Array.Empty()) .Select(state => state?.CooldownProgress ?? 0) .DefaultIfEmpty(0) @@ -683,6 +700,16 @@ namespace GameCult.Aetheria.State.Verse ["shield"] = FormatNumber(shield), ["maximumShield"] = FormatNumber(maximumShield), ["shieldRatio"] = FormatRatio(shield, maximumShield), + ["capacitorCharge"] = FormatNumber(capacitorCharge), + ["capacitorCapacity"] = FormatNumber(capacitorCapacity), + ["capacitorRatio"] = FormatRatio(capacitorCharge, capacitorCapacity), + ["reactorDraw"] = FormatNumber(reactorDraw), + ["reactorLoadRatio"] = FormatNumber(reactorLoad), + ["radiatorTemperature"] = FormatNumber(radiatorTemperature), + ["radiatorVisibility"] = FormatNumber(EntityStat(entity, "radiator-visibility")), + ["minimumEquipmentThermalPerformance"] = FormatNumber(minimumThermalPerformance), + ["maximumEquipmentWear"] = FormatNumber(maximumWear), + ["offlineEquipmentCount"] = offlineEquipmentCount.ToString(CultureInfo.InvariantCulture), ["heat"] = FormatNumber(heat), ["meanTemperature"] = FormatNumber(heat), ["minimumTemperature"] = FormatNumber(minimumTemperature), diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 7557aa43a..67f990366 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -47,7 +47,12 @@ namespace GameCult.Aetheria.State.Verse EnsureStats(entities, settings, catalog); foreach (var entity in entities) + { + AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog); AetheriaRuntimeThermalSimulation.EnsureState(entity); + AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, deltaSeconds); + AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog); + } foreach (var movement in intents?.Movements ?? Enumerable.Empty()) ApplyMovementIntent(run, entities, movement, settings); StepTractorPower(entities, deltaSeconds); @@ -300,7 +305,7 @@ namespace GameCult.Aetheria.State.Verse continue; if (weapon.SingleAmmoBurst) { - var triggerResult = CommitWeaponRound(attacker, weapon); + var triggerResult = CommitWeaponRound(attacker, weapon, catalog); if (triggerResult == WeaponRoundResult.ReloadStarted) AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started"); PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, triggerResult); @@ -318,7 +323,7 @@ namespace GameCult.Aetheria.State.Verse { if (!weapon.SingleAmmoBurst) { - var roundResult = CommitWeaponRound(attacker, weapon); + var roundResult = CommitWeaponRound(attacker, weapon, catalog); if (roundResult == WeaponRoundResult.ReloadStarted) AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started"); PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, roundResult); @@ -334,7 +339,9 @@ namespace GameCult.Aetheria.State.Verse CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog, settings); AppendShotCommittedEvent(run, zone, attacker, target, weapon, shotId, frameId); weapon.State.Firing = true; - AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, + weapon.State.OwnerIndex, weapon.Heat); + ApplyWeaponWear(attacker, weapon.State, 1); } } } @@ -366,7 +373,9 @@ namespace GameCult.Aetheria.State.Verse foreach (var entity in entities) { - AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds); + AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds); + AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds); + AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog); entity.IsActive = IsAlive(entity); if (!entity.IsActive) { @@ -395,7 +404,7 @@ namespace GameCult.Aetheria.State.Verse weapon.State.CoolingDown = weapon.State.CooldownProgress > 0; if (!IsAlive(attacker) || weaponGroup < 0 || weapon.State.CooldownProgress > 0) continue; - var result = CommitWeaponRound(attacker, weapon); + var result = CommitWeaponRound(attacker, weapon, catalog); PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, result); if (result != WeaponRoundResult.Fired) continue; @@ -434,7 +443,9 @@ namespace GameCult.Aetheria.State.Verse weapon.State.Firing = true; weapon.State.CoolingDown = true; weapon.State.CooldownProgress = weapon.Cooldown; - AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, + weapon.State.OwnerIndex, weapon.Heat); + ApplyWeaponWear(attacker, weapon.State, 1); AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{payloadId}:deployed", Kind = "deployable.deployed", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex, SubjectKey = payloadId, ItemKey = weapon.ItemKey, PositionX = attacker.PositionX, PositionZ = attacker.PositionZ }); } } @@ -552,15 +563,16 @@ namespace GameCult.Aetheria.State.Verse if (!state.Charged) { var energy = weapon.ChargeEnergy * step; - if (!CanSupplyEnergy(attacker, energy)) + if (!CanSupplyEnergy(attacker, catalog, energy)) { state.Charging = false; state.Charge = 0; PublishChargedRefusal(run, zone, attacker, weapon, frameId, "insufficient-charge-energy"); continue; } - CommitEnergy(attacker, energy); - AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.ChargeHeat * step); + CommitEnergy(attacker, catalog, energy); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, + weapon.Base.State.OwnerIndex, weapon.ChargeHeat * step); } state.Charge += step; if (!state.Charged && state.Charge >= 1) @@ -576,7 +588,7 @@ namespace GameCult.Aetheria.State.Verse state.ChargeHoldSeconds = 0; state.ChargeRiskChecks = 0; state.ChargeMalfunctionRisk = 0; - TriggerChargedShot(run, zone, attacker, weapon, frameId); + TriggerChargedShot(run, zone, attacker, weapon, frameId, catalog); } else if (state.Charged) { @@ -614,7 +626,7 @@ namespace GameCult.Aetheria.State.Verse { if (!shot.SingleAmmoBurst) { - var result = CommitWeaponRound(attacker, shot); + var result = CommitWeaponRound(attacker, shot, catalog); PublishWeaponRoundResult(run, zone, attacker, shot, frameId, result); if (result == WeaponRoundResult.ReloadStarted) AppendWeaponEvent(run, zone, attacker, shot, frameId, "weapon.reload.started"); @@ -626,7 +638,9 @@ namespace GameCult.Aetheria.State.Verse CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog, settings); AppendShotCommittedEvent(run, zone, attacker, solutionTarget, shot, shotId, frameId); state.Firing = true; - AetheriaRuntimeThermalSimulation.AddHeat(attacker, shot.Heat); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, + shot.State.OwnerIndex, shot.Heat); + ApplyWeaponWear(attacker, shot.State, 1); } if (state.BurstRemaining <= 0) { @@ -638,12 +652,12 @@ namespace GameCult.Aetheria.State.Verse private static void TriggerChargedShot(AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit attacker, - ResolvedChargedWeapon weapon, long frameId) + ResolvedChargedWeapon weapon, long frameId, AetheriaRuntimeCatalogSnapshot? catalog) { var shot = weapon.CommittedShot(); if (shot.SingleAmmoBurst) { - var result = CommitWeaponRound(attacker, shot); + var result = CommitWeaponRound(attacker, shot, catalog); PublishWeaponRoundResult(run, zone, attacker, shot, frameId, result); if (result == WeaponRoundResult.ReloadStarted) AppendWeaponEvent(run, zone, attacker, shot, frameId, "weapon.reload.started"); @@ -736,13 +750,13 @@ namespace GameCult.Aetheria.State.Verse } var energy = weapon.Energy * deltaSeconds; - if (!CanSupplyEnergy(attacker, energy)) + if (!CanSupplyEnergy(attacker, catalog, energy)) { PublishConstantWeaponRefusal(run, zone, attacker, weapon, frameId, "insufficient-energy"); StopConstantWeapon(run, zone, attacker, weapon, frameId, "insufficient-energy"); continue; } - CommitEnergy(attacker, energy); + CommitEnergy(attacker, catalog, energy); if (!string.IsNullOrWhiteSpace(weapon.AmmoItemKey)) { @@ -774,7 +788,9 @@ namespace GameCult.Aetheria.State.Verse } weapon.State.LastRefusalReason = ""; - AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat * deltaSeconds); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, + weapon.State.OwnerIndex, weapon.Heat * deltaSeconds); + ApplyWeaponWear(attacker, weapon.State, deltaSeconds); var shot = weapon.ResolutionShot(deltaSeconds); var shotId = NextShotId(zone, attacker, shot); CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog, settings); @@ -1538,7 +1554,8 @@ namespace GameCult.Aetheria.State.Verse private static WeaponRoundResult CommitWeaponRound( AetheriaRuntimeEntitySnapshotCommit entity, - ResolvedWeapon weapon) + ResolvedWeapon weapon, + AetheriaRuntimeCatalogSnapshot? catalog) { if (weapon.MagazineSize > 1 && weapon.State.Ammo <= 0) { @@ -1555,46 +1572,29 @@ namespace GameCult.Aetheria.State.Verse return WeaponRoundResult.ReloadStarted; } - if (!CanSupplyEnergy(entity, weapon.Energy)) + if (!CanSupplyEnergy(entity, catalog, weapon.Energy)) return WeaponRoundResult.InsufficientEnergy; - CommitEnergy(entity, weapon.Energy); + CommitEnergy(entity, catalog, weapon.Energy); if (weapon.MagazineSize > 1) weapon.State.Ammo--; return WeaponRoundResult.Fired; } - private static bool CanSupplyEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand) - { - if (demand < 0.01) return true; - var states = entity.BehaviorStates ?? Array.Empty(); - var charge = states.Where(value => value != null && value.BehaviorKind == "Capacitor") - .Sum(value => Math.Max(0, value.CapacitorCharge)); - return charge >= demand || states.Any(value => value != null && value.BehaviorKind == "Reactor"); - } + private static bool CanSupplyEnergy(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double demand) => + AetheriaRuntimeEnergySimulation.CanSupply(entity, catalog, demand); - private static void CommitEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand) + private static void CommitEnergy(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double demand) => + AetheriaRuntimeEnergySimulation.TryConsume(entity, catalog, demand); + + private static void ApplyWeaponWear(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeWeaponStateCommit state, double multiplier) { - if (demand < 0.01) return; - var capacitors = (entity.BehaviorStates ?? Array.Empty()) - .Where(value => value != null && value.BehaviorKind == "Capacitor" && value.CapacitorCharge > 0) - .ToList(); - var remaining = demand; - while (remaining > 0.000001 && capacitors.Count > 0) - { - var share = remaining / capacitors.Count; - foreach (var capacitor in capacitors.ToArray()) - { - var drained = Math.Min(share, capacitor.CapacitorCharge); - capacitor.CapacitorCharge -= drained; - remaining -= drained; - if (capacitor.CapacitorCharge <= 0.000001) capacitors.Remove(capacitor); - } - } - var reactors = (entity.BehaviorStates ?? Array.Empty()) - .Where(value => value != null && value.BehaviorKind == "Reactor") - .ToArray(); - if (remaining > 0.000001 && reactors.Length > 0) - foreach (var reactor in reactors) reactor.ReactorDraw += remaining / reactors.Length; + if (state == null || state.OwnerKind != AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind) return; + var wear = (entity.EquipmentStates ?? Array.Empty()) + .FirstOrDefault(value => value != null && value.EquipmentIndex == state.OwnerIndex)?.Wear ?? 0; + AetheriaRuntimeThermalSimulation.ApplyWear(entity, state.OwnerIndex, wear * multiplier); } private enum WeaponRoundResult { Fired, ReloadStarted, InsufficientEnergy, NoAmmo } @@ -1815,10 +1815,10 @@ namespace GameCult.Aetheria.State.Verse var energyDemand = damage * energyUsage; shield.State.ShieldEfficiency = efficiency; shield.State.ShieldEnergyUsage = energyUsage; - if (!CanSupplyEnergy(target, energyDemand)) + if (!CanSupplyEnergy(target, catalog, energyDemand)) continue; - CommitEnergy(target, energyDemand); + CommitEnergy(target, catalog, energyDemand); var heat = damage / efficiency; AetheriaRuntimeThermalSimulation.AddHeat(target, heat); RefreshShieldProjection(target, catalog); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs new file mode 100644 index 000000000..3393d4918 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeEnergySimulation + { + public static void BeginTick(AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot? catalog) + { + if (entity == null) return; + var previousRadiatorVisibility = RadiatorVisibility(entity); + entity.Visibility = Math.Max(0, entity.Visibility - previousRadiatorVisibility); + SetRadiatorVisibility(entity, 0); + foreach (var capacitor in AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, "Capacitor")) + { + capacitor.State.CapacitorCapacity = Math.Max(0, capacitor.EvaluateStat(1)); + capacitor.State.CapacitorEfficiency = Clamp01(capacitor.EvaluateStat(2)); + capacitor.State.CapacitorCharge = Math.Max(0, + Math.Min(capacitor.State.CapacitorCapacity, capacitor.State.CapacitorCharge)); + } + foreach (var reactor in AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, "Reactor")) + reactor.State.ReactorDraw = 0; + } + + public static bool CanSupply(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double demand) + { + if (demand < 0.01) return true; + var capacitors = Online(entity, catalog, "Capacitor"); + var charge = capacitors.Sum(value => Math.Max(0, value.State.CapacitorCharge)); + return charge > demand || Online(entity, catalog, "Reactor").Count > 0; + } + + public static bool TryConsume(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double demand) + { + if (demand < 0.01) return true; + if (!CanSupply(entity, catalog, demand)) return false; + var capacitors = Online(entity, catalog, "Capacitor"); + while (demand > 0.01) + { + var charged = capacitors.Where(value => value.State.CapacitorCharge > 0.01).ToArray(); + if (charged.Length == 0) break; + var chargeToRemove = demand; + foreach (var capacitor in charged) + { + var removed = Math.Min(chargeToRemove / charged.Length, capacitor.State.CapacitorCharge); + AddCapacitorCharge(entity, catalog, capacitor, -removed); + demand -= removed; + } + } + if (demand < 0.01) return true; + var reactors = Online(entity, catalog, "Reactor"); + if (reactors.Count == 0) return false; + foreach (var reactor in reactors) reactor.State.ReactorDraw += demand / reactors.Count; + return true; + } + + public static void StepRadiators(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double deltaSeconds) + { + if (entity == null || catalog == null || deltaSeconds <= 0 || !entity.HeatsinksEnabled) return; + foreach (var radiator in Online(entity, catalog, "Radiator")) + { + var itemTemperature = AetheriaRuntimeThermalSimulation.EquipmentTemperature( + entity, catalog, radiator.EquipmentIndex); + if (radiator.State.RadiatorTemperature <= 0) + radiator.State.RadiatorTemperature = itemTemperature; + radiator.State.PumpedHeat = Math.Max(0, radiator.EvaluateStat(2)); + radiator.State.WasteHeat = Math.Max(0, radiator.EvaluateStat(4)); + radiator.State.EnergyUsage = Math.Max(0, radiator.EvaluateStat(5)); + var tempRatio = Math.Max(radiator.State.RadiatorTemperature / Math.Max(0.000001, itemTemperature), 1); + if (radiator.State.WasteHeat > 0 && + tempRatio > radiator.State.PumpedHeat / radiator.State.WasteHeat) + continue; + if (!TryConsume(entity, catalog, radiator.State.EnergyUsage * tempRatio * deltaSeconds)) + continue; + var temperatureFloor = ReadNumber(radiator.Payload, 3); + var pumped = radiator.State.PumpedHeat * Math.Max(itemTemperature - temperatureFloor, 0); + if (pumped >= 0.01) + { + var waste = radiator.State.WasteHeat * tempRatio; + AetheriaRuntimeThermalSimulation.AddHeatToEquipment( + entity, catalog, radiator.EquipmentIndex, (waste - pumped) * deltaSeconds); + var thermalMass = Math.Max(0.000001, radiator.EvaluateStat(6)); + radiator.State.RadiatorTemperature += pumped / thermalMass * deltaSeconds; + } + radiator.State.Emissivity = Math.Max(0, radiator.EvaluateStat(1)); + var radiation = Math.Pow(Math.Max(0, radiator.State.RadiatorTemperature), + AetheriaRuntimeThermalSimulation.RadiationExponent) * + AetheriaRuntimeThermalSimulation.RadiationMultiplier * radiator.State.Emissivity; + radiator.State.RadiatorTemperature = Math.Max(0, + radiator.State.RadiatorTemperature - radiation * deltaSeconds); + AddRadiatorVisibility(entity, radiation); + } + } + + public static void SettleReactors(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double deltaSeconds) + { + if (entity == null || catalog == null || deltaSeconds <= 0) return; + var capacitors = Online(entity, catalog, "Capacitor"); + foreach (var reactor in Online(entity, catalog, "Reactor")) + { + var charge = Math.Max(0, reactor.EvaluateStat(1)) * deltaSeconds; + var efficiency = Math.Max(0.000001, reactor.EvaluateStat(2)); + reactor.State.ReactorDraw -= charge; + var heat = charge / efficiency; + if (reactor.State.ReactorDraw > 0.01) + { + reactor.State.ReactorLoadRatio = (reactor.State.ReactorDraw + charge) / Math.Max(charge, 0.01); + heat += reactor.State.ReactorDraw / Math.Max(0.000001, reactor.EvaluateStat(3)); + reactor.State.ReactorDraw = 0; + } + if (reactor.State.ReactorDraw < -0.01) + { + while (reactor.State.ReactorDraw < -0.01) + { + var available = capacitors.Where(value => + value.State.CapacitorCharge < value.State.CapacitorCapacity - 0.01).ToArray(); + if (available.Length == 0) break; + var chargeToAdd = -reactor.State.ReactorDraw; + foreach (var capacitor in available) + { + var added = Math.Min(chargeToAdd / available.Length, + capacitor.State.CapacitorCapacity - capacitor.State.CapacitorCharge); + AddCapacitorCharge(entity, catalog, capacitor, added); + reactor.State.ReactorDraw += added; + } + } + } + if (reactor.State.ReactorDraw < -0.01) + { + reactor.State.ReactorLoadRatio = (reactor.State.ReactorDraw + charge) / Math.Max(charge, 0.01); + heat -= reactor.State.ReactorDraw / efficiency * + (1 - 1 / Math.Max(0.000001, reactor.EvaluateStat(4))); + reactor.State.ReactorDraw = 0; + } + else reactor.State.ReactorLoadRatio = 1; + AetheriaRuntimeThermalSimulation.AddHeatToEquipment( + entity, catalog, reactor.EquipmentIndex, heat); + } + } + + private static IReadOnlyList Online( + AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot? catalog, string kind) => + AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, kind) + .Where(value => (entity.EquipmentStates ?? Array.Empty()) + .FirstOrDefault(state => state != null && state.EquipmentIndex == value.EquipmentIndex)?.Online ?? + (value.Item.Enabled && value.Item.Durability > 0.01)) + .ToArray(); + + private static void AddCapacitorCharge(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, AetheriaRuntimeEquippedBehavior capacitor, double delta) + { + capacitor.State.CapacitorCharge = Math.Max(0, + Math.Min(capacitor.State.CapacitorCapacity, capacitor.State.CapacitorCharge + delta)); + AetheriaRuntimeThermalSimulation.AddHeatToEquipment(entity, catalog, capacitor.EquipmentIndex, + Math.Abs(delta) * (1 - capacitor.State.CapacitorEfficiency)); + } + + private static void AddRadiatorVisibility(AetheriaRuntimeEntitySnapshotCommit entity, double radiation) + { + radiation = Math.Max(0, radiation); + SetRadiatorVisibility(entity, RadiatorVisibility(entity) + radiation); + entity.Visibility += radiation; + } + + private static double RadiatorVisibility(AetheriaRuntimeEntitySnapshotCommit entity) => + (entity.StatGrids ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.Name, "radiator-visibility", StringComparison.Ordinal))? + .Values.FirstOrDefault() ?? 0; + + private static void SetRadiatorVisibility(AetheriaRuntimeEntitySnapshotCommit entity, double radiation) + { + var grids = (entity.StatGrids ?? Array.Empty()).ToList(); + var grid = grids.FirstOrDefault(value => string.Equals(value.Name, "radiator-visibility", StringComparison.Ordinal)); + if (grid == null) + { + grid = new AetheriaRuntimeEntityStatGridCommit { Name = "radiator-visibility", Width = 1, Height = 1 }; + grids.Add(grid); + } + grid.Values = new[] { Math.Max(0, radiation) }; + entity.StatGrids = grids; + } + + private static double ReadNumber(AetheriaRuntimeBehaviorPayload payload, int key) => + (payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == key)?.Value?.NumberValue ?? 0; + + private static double Clamp01(double value) => value < 0 ? 0 : value > 1 ? 1 : value; + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs.meta new file mode 100644 index 000000000..5f225bf24 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEnergySimulation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ec02276eb02546a2868da179c5f0be7c diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index cd0f95cfc..5c9495e52 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1045,6 +1045,24 @@ namespace GameCult.Aetheria.State.Verse [Key(46)] public long DestroyedFrameId { get; set; } = -1; + + [Key(47)] + public IReadOnlyList EquipmentStates { get; set; } = + Array.Empty(); + } + + [MessagePackObject] + public sealed class AetheriaRuntimeEquipmentStateCommit + { + [Key(0)] public int EquipmentIndex { get; set; } = -1; + [Key(1)] public double Temperature { get; set; } + [Key(2)] public double PreviousTemperature { get; set; } + [Key(3)] public double ThermalPerformance { get; set; } = 1; + [Key(4)] public double DurabilityPerformance { get; set; } = 1; + [Key(5)] public double Wear { get; set; } + [Key(6)] public bool ThermalOnline { get; set; } = true; + [Key(7)] public bool DurabilityOnline { get; set; } = true; + [Key(8)] public bool Online { get; set; } = true; } [MessagePackObject] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalSimulation.cs index 11c4d2077..1bd137450 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalSimulation.cs @@ -20,6 +20,9 @@ namespace GameCult.Aetheria.State.Verse public const double RadiationExponent = 3.0; public const double RadiationMultiplier = 0.00000001; public const double InitialTemperature = 280.0; + public const double ThermalWearExponent = 0.01; + public const double DeltaTemperatureWearExponent = 0.01; + public const double QualityWearExponent = 2.0; public static void EnsureState(AetheriaRuntimeEntitySnapshotCommit entity) { @@ -42,6 +45,64 @@ namespace GameCult.Aetheria.State.Verse entity.StatGrids = grids; } + public static void EnsureTopology(AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot? catalog) + { + if (entity == null || catalog == null) return; + var hull = catalog.FindItem(entity.HullItemKey ?? ""); + if (hull == null || hull.ShapeWidth <= 0 || hull.ShapeHeight <= 0 || + hull.ShapeCells == null || hull.ShapeCells.Count == 0) return; + var grids = (entity.StatGrids ?? Array.Empty()).ToList(); + var occupied = new HashSet(hull.ShapeCells.Select(cell => Index(cell.X, cell.Y, hull.ShapeWidth))); + var temperature = Find(grids, TemperatureGrid); + if (temperature == null) + { + temperature = Grid(TemperatureGrid, hull.ShapeWidth, hull.ShapeHeight, 0); + grids.Add(temperature); + } + if (temperature.Width != hull.ShapeWidth || temperature.Height != hull.ShapeHeight || + temperature.Values.Count != hull.ShapeWidth * hull.ShapeHeight) + { + temperature.Width = hull.ShapeWidth; + temperature.Height = hull.ShapeHeight; + temperature.Values = Enumerable.Range(0, hull.ShapeWidth * hull.ShapeHeight) + .Select(index => occupied.Contains(index) ? InitialTemperature : 0).ToArray(); + } + else if (temperature.Values.All(value => value <= 0)) + temperature.Values = Enumerable.Range(0, hull.ShapeWidth * hull.ShapeHeight) + .Select(index => occupied.Contains(index) ? InitialTemperature : 0).ToArray(); + + var cellCount = Math.Max(1, hull.ShapeCells.Count); + var baseMass = Math.Max(0.000001, hull.Mass * hull.SpecificHeat / cellCount); + var mass = Enumerable.Range(0, hull.ShapeWidth * hull.ShapeHeight) + .Select(index => occupied.Contains(index) ? baseMass : 0).ToArray(); + var conductivity = Enumerable.Range(0, mass.Length) + .Select(index => occupied.Contains(index) ? 1.0 : 0).ToArray(); + var equipment = entity.Equipment ?? Array.Empty(); + for (var equipmentIndex = 0; equipmentIndex < equipment.Count; equipmentIndex++) + { + var slot = equipment[equipmentIndex]; + var item = catalog.FindItem(slot?.Item?.ItemKey ?? ""); + var cells = item?.ShapeCells ?? Array.Empty(); + var itemCellCount = Math.Max(1, cells.Count); + foreach (var cell in cells) + { + var x = slot.X + cell.X; + var y = slot.Y + cell.Y; + if (x < 0 || y < 0 || x >= hull.ShapeWidth || y >= hull.ShapeHeight) continue; + var index = Index(x, y, hull.ShapeWidth); + if (!occupied.Contains(index)) continue; + mass[index] += Math.Max(0, item!.Mass * item.SpecificHeat / itemCellCount); + conductivity[index] = Math.Max(0.000001, item.Conductivity); + } + } + SetGrid(grids, ThermalMassGrid, hull.ShapeWidth, hull.ShapeHeight, mass); + SetGrid(grids, ConductivityGrid, hull.ShapeWidth, hull.ShapeHeight, conductivity); + EnsureScalar(grids, MeanTemperatureGrid, Mean(temperature.Values.Where(value => value > 0).ToArray())); + EnsureScalar(grids, MinimumTemperatureGrid, temperature.Values.Where(value => value > 0).DefaultIfEmpty(0).Min()); + EnsureScalar(grids, MaximumTemperatureGrid, temperature.Values.DefaultIfEmpty(0).Max()); + entity.StatGrids = grids; + } + public static void AddHeat(AetheriaRuntimeEntitySnapshotCommit entity, double energy) { if (entity == null || !double.IsFinite(energy) || Math.Abs(energy) < 0.0000001) @@ -65,7 +126,100 @@ namespace GameCult.Aetheria.State.Verse entity.StatGrids = grids; } - public static void Step(AetheriaRuntimeEntitySnapshotCommit entity, double deltaSeconds) + public static void AddHeatToEquipment(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, int equipmentIndex, double energy) + { + if (entity == null || !double.IsFinite(energy) || Math.Abs(energy) < 0.0000001) return; + EnsureState(entity); + var cells = EquipmentCellIndices(entity, catalog, equipmentIndex).ToArray(); + if (cells.Length == 0) { AddHeat(entity, energy); return; } + var grids = entity.StatGrids.ToList(); + var temperature = Find(grids, TemperatureGrid)!; + var thermalMass = Find(grids, ThermalMassGrid)!; + var values = temperature.Values.ToArray(); + var energyPerCell = energy / cells.Length; + foreach (var index in cells) + if (index >= 0 && index < values.Length && thermalMass.Values[index] > 0) + values[index] = Math.Max(0, values[index] + energyPerCell / thermalMass.Values[index]); + temperature.Values = values; + entity.StatGrids = grids; + } + + public static double EquipmentTemperature(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, int equipmentIndex) + { + EnsureState(entity); + var temperature = Find(entity.StatGrids, TemperatureGrid)!; + var cells = EquipmentCellIndices(entity, catalog, equipmentIndex).ToArray(); + return cells.Length == 0 ? Mean(temperature.Values) : cells.Average(index => temperature.Values[index]); + } + + public static void UpdateEquipmentStates(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, double deltaSeconds) + { + if (entity == null || catalog == null) return; + var existing = (entity.EquipmentStates ?? Array.Empty()) + .Where(value => value != null).ToDictionary(value => value.EquipmentIndex); + var states = new List(); + var equipment = entity.Equipment ?? Array.Empty(); + for (var index = 0; index < equipment.Count; index++) + { + var item = equipment[index]?.Item; + var typed = catalog.FindItem(item?.ItemKey ?? ""); + if (item == null || typed == null) continue; + if (!existing.TryGetValue(index, out var state)) + state = new AetheriaRuntimeEquipmentStateCommit + { + EquipmentIndex = index, + PreviousTemperature = EquipmentTemperature(entity, catalog, index) + }; + var temperature = EquipmentTemperature(entity, catalog, index); + var thermalPerformance = typed.MaximumTemperature <= typed.MinimumTemperature || + typed.ThermalPerformanceCurveKeys == null || typed.ThermalPerformanceCurveKeys.Count == 0 + ? 1 + : Clamp01(SampleCurve(typed.ThermalPerformanceCurveKeys, + (temperature - typed.MinimumTemperature) / + (typed.MaximumTemperature - typed.MinimumTemperature))); + var maxDurability = Math.Max(0.000001, typed.Durability); + var durabilityPerformance = Math.Max(0, item.Durability / maxDurability); + var quality = Clamp01(item.Quality); + var wear = (1 - Math.Pow(thermalPerformance, + (1 - Math.Pow(quality, QualityWearExponent)) * ThermalWearExponent) + + Math.Abs(temperature - state.PreviousTemperature) * DeltaTemperatureWearExponent) * + maxDurability / Math.Max(0.000001, typed.ThermalResilience); + state.Temperature = temperature; + state.ThermalPerformance = thermalPerformance; + state.DurabilityPerformance = durabilityPerformance; + state.Wear = Math.Max(0, wear); + state.ThermalOnline = thermalPerformance > entity.ShutdownPerformance || + entity.OverrideShutdown && item.OverrideShutdown; + state.DurabilityOnline = item.Durability > 0.01; + state.Online = item.Enabled && state.ThermalOnline && state.DurabilityOnline; + state.PreviousTemperature = temperature; + states.Add(state); + + var wearPayload = (typed.BehaviorPayloads ?? Array.Empty()) + .FirstOrDefault(value => value != null && value.Kind == "Wear"); + if (state.Online && wearPayload != null) + { + var perSecond = (wearPayload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == 1)?.Value?.BoolValue ?? true; + ApplyWear(entity, index, state.Wear * (perSecond ? deltaSeconds : 1)); + } + } + entity.EquipmentStates = states; + } + + public static void ApplyWear(AetheriaRuntimeEntitySnapshotCommit entity, int equipmentIndex, double damage) + { + var equipment = entity?.Equipment ?? Array.Empty(); + if (equipmentIndex < 0 || equipmentIndex >= equipment.Count || damage <= 0) return; + var item = equipment[equipmentIndex]?.Item; + if (item != null) item.Durability = Math.Max(0, item.Durability - damage); + } + + public static void Step(AetheriaRuntimeEntitySnapshotCommit entity, double deltaSeconds, + AetheriaRuntimeCatalogSnapshot? catalog = null) { if (entity == null || deltaSeconds <= 0) return; @@ -75,6 +229,12 @@ namespace GameCult.Aetheria.State.Verse var temperature = Find(grids, TemperatureGrid)!; var thermalMass = Find(grids, ThermalMassGrid)!; var conductivity = Find(grids, ConductivityGrid)!; + var hull = catalog?.FindItem(entity.HullItemKey ?? ""); + var hullConductivity = Math.Max(0.000001, hull?.Conductivity ?? 1); + var facetX = Find(grids, "hull_conductivity_x"); + var facetY = Find(grids, "hull_conductivity_y"); + var interior = new HashSet((hull?.InteriorShapeCells ?? Array.Empty()) + .Select(cell => Index(cell.X, cell.Y, temperature.Width))); var current = temperature.Values.ToArray(); var next = current.ToArray(); var radiation = 0.0; @@ -88,20 +248,22 @@ namespace GameCult.Aetheria.State.Verse var weightedTemperature = current[index] / ConductionMultiplier; var totalConductivity = 1.0 / ConductionMultiplier; - Accumulate(x - 1, y); - Accumulate(x + 1, y); - Accumulate(x, y - 1); - Accumulate(x, y + 1); + Accumulate(x - 1, y, facetX, x - 1, y); + Accumulate(x + 1, y, facetX, x, y); + Accumulate(x, y - 1, facetY, x, y - 1); + Accumulate(x, y + 1, facetY, x, y); next[index] = weightedTemperature / totalConductivity; - if (IsBorderCell(x, y, temperature.Width, temperature.Height, thermalMass.Values)) + if (hull != null ? !interior.Contains(index) : + IsBorderCell(x, y, temperature.Width, temperature.Height, thermalMass.Values)) { var emitted = Math.Pow(Math.Max(0, next[index]), RadiationExponent) * RadiationMultiplier; next[index] = Math.Max(0, next[index] - emitted * deltaSeconds); radiation += emitted; } - void Accumulate(int neighborX, int neighborY) + void Accumulate(int neighborX, int neighborY, + AetheriaRuntimeEntityStatGridCommit? facet, int facetXIndex, int facetYIndex) { if (neighborX < 0 || neighborY < 0 || neighborX >= temperature.Width || neighborY >= temperature.Height) return; @@ -109,6 +271,7 @@ namespace GameCult.Aetheria.State.Verse if (thermalMass.Values[neighbor] <= 0) return; var transfer = conductivity.Values[index] * conductivity.Values[neighbor] * + FacetFactor(facet, facetXIndex, facetYIndex, hullConductivity) * thermalMass.Values[neighbor] / thermalMass.Values[index]; totalConductivity += transfer; weightedTemperature += current[neighbor] * transfer; @@ -124,9 +287,35 @@ namespace GameCult.Aetheria.State.Verse entity.StatGrids = grids; } + private static double FacetFactor(AetheriaRuntimeEntityStatGridCommit? grid, + int x, int y, double hullConductivity) + { + if (grid == null || x < 0 || y < 0 || x >= grid.Width || y >= grid.Height) return 1 / hullConductivity; + var index = Index(x, y, grid.Width); + return index < grid.Values.Count && grid.Values[index] > 0.5 ? hullConductivity : 1 / hullConductivity; + } + private static double PreviousThermalVisibility(AetheriaRuntimeEntitySnapshotCommit entity) => Find(entity.StatGrids, "thermal-visibility")?.Values.FirstOrDefault() ?? 0; + private static IEnumerable EquipmentCellIndices(AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, int equipmentIndex) + { + var temperature = Find(entity.StatGrids, TemperatureGrid); + var equipment = entity.Equipment ?? Array.Empty(); + if (temperature == null || catalog == null || equipmentIndex < 0 || equipmentIndex >= equipment.Count) + yield break; + var slot = equipment[equipmentIndex]; + var item = catalog.FindItem(slot?.Item?.ItemKey ?? ""); + foreach (var cell in item?.ShapeCells ?? Array.Empty()) + { + var x = slot.X + cell.X; + var y = slot.Y + cell.Y; + if (x >= 0 && y >= 0 && x < temperature.Width && y < temperature.Height) + yield return Index(x, y, temperature.Width); + } + } + private static bool IsBorderCell(int x, int y, int width, int height, IReadOnlyList mass) => x == 0 || y == 0 || x == width - 1 || y == height - 1 || mass[Index(x - 1, y, width)] <= 0 || mass[Index(x + 1, y, width)] <= 0 || @@ -134,6 +323,29 @@ namespace GameCult.Aetheria.State.Verse private static int Index(int x, int y, int width) => y * width + x; private static double Mean(IReadOnlyList values) => values.Count == 0 ? 0 : values.Average(); + private static double Clamp01(double value) => value < 0 ? 0 : value > 1 ? 1 : value; + + private static double SampleCurve(IReadOnlyList keys, double value) + { + var ordered = keys.OrderBy(key => key.Time).ToArray(); + if (value <= ordered[0].Time) return ordered[0].Value; + for (var index = 1; index < ordered.Length; index++) + { + var next = ordered[index]; + var previous = ordered[index - 1]; + if (value > next.Time) continue; + var span = next.Time - previous.Time; + if (span <= double.Epsilon) return next.Value; + var t = Clamp01((value - previous.Time) / span); + var t2 = t * t; + var t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * previous.Value + + (t3 - 2 * t2 + t) * previous.OutTangent * span + + (-2 * t3 + 3 * t2) * next.Value + + (t3 - t2) * next.InTangent * span; + } + return ordered[ordered.Length - 1].Value; + } private static bool Valid(AetheriaRuntimeEntityStatGridCommit? grid) => grid != null && grid.Width > 0 && grid.Height > 0 && grid.Values.Count == grid.Width * grid.Height; @@ -166,6 +378,20 @@ namespace GameCult.Aetheria.State.Verse Find(grids, name)!.Values = new[] { value }; } + private static void SetGrid(List grids, + string name, int width, int height, IReadOnlyList values) + { + var grid = Find(grids, name); + if (grid == null) + { + grid = Grid(name, width, height, 0); + grids.Add(grid); + } + grid.Width = width; + grid.Height = height; + grid.Values = values.ToArray(); + } + private static AetheriaRuntimeEntityStatGridCommit Grid(string name, int width, int height, double value) => new() { Name = name, diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 5d87b207a..cfd563e5e 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1937,6 +1937,15 @@ First Aetheria surfaces to publish: - `rg "Newtonsoft|JsonObject|JsonProperty|JsonConvert|JsonKnownTypes|RethinkDb|RethinkTable|DatabaseCache"` is zero outside migration quarantine and docs. - `rg "DatabaseEntry|DatabaseLink|InspectableDatabaseLink|ServerShared\\\\CultCache" Assets Economy.*` shows no live runtime ownership path. +- `AetheriaRuntimeEnergySimulation` owns the equipped capacitor/reactor/radiator + network. `AetheriaRuntimeThermalSimulation` owns catalog-derived cell topology, + equipment-local temperature/performance, shutdown, wear, conduction, and + radiation. The daemon orders radiator pumping before reactor settlement and + thermal conduction; client operations have no direct energy, heat, wear, or + equipment-online writer. The Eve game surface publishes the resulting power, + radiator, temperature, wear, and offline-equipment facts. Cockpit medical + consequences, rotated equipment footprints, and parent/child radiation remain + migration work rather than presentation authority. - `dotnet list package --vulnerable --include-transitive` is clean for active maintained projects. - CultCache smoke proves write, flush, reopen, query, and typed reference diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 3e6193704..992f7c248 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -23,7 +23,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | 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 | | Projectile flight | weapon/projectile classes | Eve presentation for ordinary tracers; Ymir for persistent physical payloads | receipt trajectory/effect facts or persistent payload state | native projectile visual | ordinary instant, charged, and constant execution creates no projectile body; each weapon state owns a stable shot sequence and receipts carry origin, endpoint, nominal duration, and effect identity for Eve presentation; `SpawnProjectile` and its alternate writer are deleted; Ymir projectile contacts are feedback-only for explicitly persisted payload rows and cannot write damage; Ymir.Core remains embedded for world integration, radial fields, circle contacts, pickups, and genuinely persistent/interceptable payloads | split/rename the remaining physical-payload interface and remove ordinary projectile terminology from runtime contracts | | Shield/armor/equipment/hull damage | energy-funded shield, schematic armor cells, equipment and scalar hull | daemon canonical damage transaction | aggregate/exact armor grid, entity status, and layer-aware shot/damage receipts | schematic, meters, impacts, damage VFX | owner-exists; wired; exclusive; projected: daemon initializes armor/maximum-armor grids from hull plus hardpoint catalog topology; direct and deployable damage share one `shield -> armor cell -> equipment -> scalar hull` transaction; direct impacts select a cell or complete hardpoint footprint before orthogonal spread and 0.5-cell penetration, while splash payloads select the source-facing hull half; the fossil penetration infinite-loop defect is fixed; damage type is typed/pass-through without resistances because the fossil never applied them; Eve exposes aggregate and exact armor state and receipts identify reached/applied layers; fake Ymir contacts and presentation cannot decide damage | fossil parity fixtures for topology, edge impacts, orthogonal spread, multi-cell penetration, equipment interception, and generic shield/equipment effect lifecycle | -| Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment status and warnings | schematic/post effects | cell conduction/radiation wired; energy network and medical thresholds remain reference | capacitor/reactor/radiator ordering, shared energy and medical timelines | +| Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment state, power/temperature facts and warnings | schematic/post effects | owner-exists; wired; exclusive; projected: catalog hull and installed-item shapes derive cell mass/conductivity/topology; local temperature derives thermal performance, wear potential and online state; all consumers share capacitor-first/reactor-residual transactions; powered radiators pump before reactor settlement; reactor heat then enters the same cell network before conduction/radiation; Eve exposes capacitor, reactor, radiator, temperature, wear and offline-equipment facts; cockpit medical thresholds, rotated equipment topology and parent/child radiation remain incomplete | multi-consumer ordering, multi-radiator visibility, rotated topology, cockpit injury/death timelines and generic schematic/post-effect lowering | | Weapons | ServerShared behaviors + Unity effects | daemon plus Ymir geometry/kinematics | action capability, weapon state, shot/damage receipts | effects/audio/HUD | instant and constant resource/timing lifecycles are daemon-owned; charged weapons accept one semantic request, spool without requiring a solution, hold at full charge, commit automatically when a solution appears, and perform deterministic once-per-second malfunction hazard checks after the authored safe hold interval with increasing persisted risk; generic Eve `weapon.state` exposes charge hold/risk and feedback exposes readiness, commit, refusal, and malfunction chronology; committed direct damage now enters the shared daemon armor-cell transaction | guided persistent payloads, visibility/wear, remaining impact-distribution parity, and Unity effect reconciliation | | Destruction/drops | hull death, 25% equipment rolls, unconditional cargo drops, 25-unit launch, 30-second pickups | daemon | destruction identity, death/drop events, typed pickups | provider-advertised destruction effect and pickups | owner-exists; wired; exclusive; projected: direct and deployable lethal damage commit one deterministic destruction transaction, clear live references, retain only a non-rendered identity tombstone, and emit exact pickups/events; the reserved client `DestroyEntity` command is rejected; Aetheria advertises its original big explosion as `effect.feedback.entity.destroyed`, lowered generically by EveUnity | equipment-destruction-before-hull parity and multiplayer reconnect chronology | | Tractor/scoop | analog tractor and physical timed pickups | daemon + Ymir | tractor power, pickup body and receipt | beam/interaction feedback | fossil 2/s power ramp and authored forward radius/traction/distance feed pickup bodies into the shared Ymir world; shield contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; 30s daemon lifetime; instant cargo teleport deleted | prove generic runtime beam, contact, success and rejection feedback | diff --git a/docs/original-game-simulation-spec.md b/docs/original-game-simulation-spec.md index 7d152349d..7ef303865 100644 --- a/docs/original-game-simulation-spec.md +++ b/docs/original-game-simulation-spec.md @@ -42,6 +42,14 @@ performance and online state derive from local temperature. Cockpit temperature advances heatstroke/hypothermia accumulation and recovery. Threshold crossings and death are authoritative events. +The daemon derives the grid from the equipped catalog, rather than accepting a +renderer-authored heat scalar. Hull mass and specific heat are distributed over +the hull shape; installed equipment contributes thermal mass and replaces the +conductivity of its occupied cells. Equipment temperature is the mean of those +cells. Authored thermal-performance curves and durability derive each item's +current performance and online state. Generic wear and weapon-triggered wear +consume the same temperature-derived wear potential. + ## Energy Network Capacitors store charge. A demand drains charged capacitors evenly first, then @@ -49,6 +57,14 @@ divides unmet demand among online reactors. Behaviors succeed or fail according to the actual transaction. Radiators consume energy to pump heat and produce waste heat. Shields consume energy per intercepted damage. +Per daemon tick, equipment performance is derived before behavior execution and +all energy consumers transact against the same equipped network. Radiators then +pump heat and add their demand, reactors settle baseline output, overload, +capacitor refill, throttling and equipment heat, and finally the thermal grid +conducts and radiates. Thermal and radiator radiation are independently replaced +visibility contributions; they are not cumulative scalar drift. A client may +request semantic controls, but cannot set charge, heat, wear, or online state. + ## Behavior Execution Required behavior families: