diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 4f533934c..43be3f895 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -32,6 +32,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RadiatorPumpsHeatBeforeReactorSettlement(); EquipmentThermalPerformanceOwnsShutdownAndWear(); ThermalTopologyComesFromHullAndEquipment(); + ThermalMedicalExposureUsesCockpitTemperature(); + ThermalMedicalDeathUsesOrdinaryDestructionPath(); MultipleActorsUseTheSameMovementLever(); AgentClaimsAndCompletesExploreTaskThroughCommands(); SchedulerAssignsHighestPriorityCompatibleTask(); @@ -2110,6 +2112,106 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "authored hull topology must initialize each occupied cell at the fossil temperature"); } + private static void ThermalMedicalExposureUsesCockpitTemperature() + { + var entity = Entity(0, 0, "player"); + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "medical-cockpit", Quality = 1, Durability = 1, Enabled = true } + }]; + entity.StatGrids = + [ + Grid("hull", 100), Grid("temperature", 340), + Grid("thermal-mass", 1), Grid("conductivity", 1) + ]; + var cockpit = CatalogItem("medical-cockpit", + new AetheriaRuntimeBehaviorPayload(0, "Cockpit", 0, [])); + cockpit.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var catalog = new AetheriaRuntimeCatalogSnapshot([cockpit], [], []); + var settings = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault; + + var heat = AetheriaRuntimeThermalMedicalSimulation.Step(entity, catalog, 300, settings); + RequireNear(0.3, entity.Heatstroke, 0.000001, + "cockpit heat must accumulate nonlinear heatstroke at the fossil rate"); + Require(heat.HeatstrokeRiskCrossed, + "heatstroke must emit one upward severe-risk crossing"); + RequireNear(340, Stat(entity, "cockpit-temperature"), 0.000001, + "cockpit cell temperature must become an authoritative Eve-readable fact"); + + entity.StatGrids.Single(value => value.Name == "temperature").Values = [280]; + AetheriaRuntimeThermalMedicalSimulation.Step(entity, catalog, 1, settings); + RequireNear(0.1, entity.Heatstroke, 0.000001, + "heatstroke must recover linearly below the authored threshold"); + + entity.StatGrids.Single(value => value.Name == "temperature").Values = [263]; + var cold = AetheriaRuntimeThermalMedicalSimulation.Step(entity, catalog, 300, settings); + RequireNear(0.3, entity.Hypothermia, 0.000001, + "cockpit cold must accumulate nonlinear hypothermia at the fossil rate"); + Require(cold.HypothermiaRiskCrossed, + "hypothermia must use its own exposure for the intended severe-risk crossing"); + + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "thermal-medical-surface", 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("263", worldEntity.Props["cockpitTemperature"], + "Eve must publish canonical cockpit temperature rather than deriving it from effects"); + RequireEqual("true", worldEntity.Props["hypothermiaRisk"], + "Eve must publish the current severe cold-risk state"); + Require(worldEntity.Props.ContainsKey("heatstrokePostWeight") && + worldEntity.Props.ContainsKey("severeHeatstrokeWeight"), + "Eve must publish the exact source weights used by the original heatstroke presentation"); + } + + private static void ThermalMedicalDeathUsesOrdinaryDestructionPath() + { + var entity = Entity(0, 0, "player"); + entity.EntityId = "thermal-pilot"; + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "lethal-cockpit", Quality = 1, Durability = 1, Enabled = true } + }]; + entity.StatGrids = + [ + Grid("hull", 100), Grid("temperature", 700), + Grid("thermal-mass", 1), Grid("conductivity", 1) + ]; + var cockpit = CatalogItem("lethal-cockpit", + new AetheriaRuntimeBehaviorPayload(0, "Cockpit", 0, [])); + cockpit.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)]; + var catalog = new AetheriaRuntimeCatalogSnapshot([cockpit], [], []); + var zone = new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [entity] }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "thermal-death", CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", Zones = [zone] + }; + + AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 1, + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, + new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, frameId: 17); + + RequireEqual("heatstroke", entity.CauseOfDeath, + "lethal cockpit heat must persist the typed cause of death"); + Require(!entity.IsActive && !string.IsNullOrWhiteSpace(entity.DestructionId), + "thermal death must enter the ordinary exactly-once destruction path"); + var death = run.GameEvents.Single(value => value.Kind == "entity.destroyed"); + RequireEqual("heatstroke", death.Reason, + "Eve feedback must carry the thermal cause needed for the original death transition"); + Require(run.GameEvents.Any(value => value.Kind == "pilot.heatstroke.risk"), + "a lethal step crossing the severe threshold must publish risk before death"); + } + 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 1b369c692..5e885fbf5 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16333,6 +16333,8 @@ 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 medicalPath = Path.Combine(runtime, "AetheriaRuntimeThermalMedicalSimulation.cs"); + var settingsPath = Path.Combine(runtime, "AetheriaRuntimeDaemonSimulationSettings.cs"); var simulationPath = Path.Combine(runtime, "AetheriaRuntimeDaemonSimulation.cs"); var snapshotPath = Path.Combine(runtime, "AetheriaRuntimeSnapshotDocuments.cs"); var surfacePath = Path.Combine(runtime, "AetheriaRuntimeDaemonGameSurfaceBuilder.cs"); @@ -16342,6 +16344,8 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) { [energyPath] = File.Exists(energyPath) ? File.ReadAllText(energyPath) : "", [thermalPath] = File.Exists(thermalPath) ? File.ReadAllText(thermalPath) : "", + [medicalPath] = File.Exists(medicalPath) ? File.ReadAllText(medicalPath) : "", + [settingsPath] = File.Exists(settingsPath) ? File.ReadAllText(settingsPath) : "", [simulationPath] = File.Exists(simulationPath) ? File.ReadAllText(simulationPath) : "", [snapshotPath] = File.Exists(snapshotPath) ? File.ReadAllText(snapshotPath) : "", [surfacePath] = File.Exists(surfacePath) ? File.ReadAllText(surfacePath) : "" @@ -16363,6 +16367,24 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) "public static void AddHeatToEquipment(", "public static void ApplyWear(" }, + [medicalPath] = new[] + { + "public static class AetheriaRuntimeThermalMedicalSimulation", + "temperature > settings.HeatstrokeTemperature", + "settings.HeatstrokeRecoveryPerSecond * deltaSeconds", + "temperature < settings.HypothermiaTemperature", + "settings.HypothermiaRecoveryPerSecond * deltaSeconds", + "entity.Heatstroke > 0.99", + "entity.Hypothermia > 0.99" + }, + [settingsPath] = new[] + { + "public double SevereThermalRiskThreshold", + "public double HeatstrokeTemperature", + "public double HeatstrokeMultiplier", + "public double HypothermiaTemperature", + "public double HypothermiaMultiplier" + }, [simulationPath] = new[] { "AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog)", @@ -16371,6 +16393,10 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) "AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds)", "AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds)", "AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog)", + "AetheriaRuntimeThermalMedicalSimulation.Step(", + "PublishThermalMedicalEvents(", + "CommitDestruction(run, zone, entity, -1,", + "Reason = target.CauseOfDeath", "AetheriaRuntimeEnergySimulation.TryConsume(entity, catalog, demand)" }, [snapshotPath] = new[] @@ -16379,7 +16405,8 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) "public sealed class AetheriaRuntimeEquipmentStateCommit", "public double ThermalPerformance", "public double Wear", - "public bool Online" + "public bool Online", + "public string CauseOfDeath" }, [surfacePath] = new[] { @@ -16387,7 +16414,11 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) "[\"reactorDraw\"]", "[\"radiatorTemperature\"]", "[\"minimumEquipmentThermalPerformance\"]", - "[\"offlineEquipmentCount\"]" + "[\"offlineEquipmentCount\"]", + "[\"cockpitTemperature\"]", + "[\"heatstrokeRisk\"]", + "[\"hypothermiaRisk\"]", + "[\"causeOfDeath\"]" } }; var missing = required.SelectMany(pair => pair.Value @@ -16405,7 +16436,10 @@ static void RequireDaemonEnergyAndThermalAuthority(string root) "ApplyCommitEnergy(", "ApplySetHeat(", "ApplySetEquipmentOnline(", - "ApplyEquipmentWear(" + "ApplyEquipmentWear(", + "ApplySetHeatstroke(", + "ApplySetHypothermia(", + "ApplySetCauseOfDeath(" }; var surviving = forbiddenOperations.Where(symbol => operations.Contains(symbol, StringComparison.Ordinal)).ToArray(); if (surviving.Length > 0) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 4d83755fa..55a5fdb92 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -670,6 +670,13 @@ namespace GameCult.Aetheria.State.Verse var maximumWear = equipmentStates.Where(value => value != null) .Select(value => value.Wear).DefaultIfEmpty(0).Max(); var offlineEquipmentCount = equipmentStates.Count(value => value != null && !value.Online); + var cockpitTemperature = EntityStat(entity, "cockpit-temperature"); + var thermalRiskThreshold = simulationSettings.SevereThermalRiskThreshold; + var heatstrokePostWeight = thermalRiskThreshold <= 0 ? 1 : + Math.Max(0, Math.Min(1, entity.Heatstroke / thermalRiskThreshold)); + var severeHeatstrokeWeight = thermalRiskThreshold >= 1 ? 0 : + Math.Max(0, Math.Min(1, + (entity.Heatstroke - thermalRiskThreshold) / (1 - thermalRiskThreshold))); var weaponCooldown = (entity.WeaponStates ?? Array.Empty()) .Select(state => state?.CooldownProgress ?? 0) .DefaultIfEmpty(0) @@ -715,8 +722,14 @@ namespace GameCult.Aetheria.State.Verse ["minimumTemperature"] = FormatNumber(minimumTemperature), ["maximumTemperature"] = FormatNumber(maximumTemperature), ["thermalVisibility"] = FormatNumber(thermalVisibility), + ["cockpitTemperature"] = FormatNumber(cockpitTemperature), ["heatstroke"] = FormatRatio(entity.Heatstroke, 1), ["hypothermia"] = FormatRatio(entity.Hypothermia, 1), + ["heatstrokeRisk"] = entity.Heatstroke > thermalRiskThreshold ? "true" : "false", + ["hypothermiaRisk"] = entity.Hypothermia > thermalRiskThreshold ? "true" : "false", + ["heatstrokePostWeight"] = FormatNumber(heatstrokePostWeight), + ["severeHeatstrokeWeight"] = FormatNumber(severeHeatstrokeWeight), + ["causeOfDeath"] = entity.CauseOfDeath ?? "", ["visibility"] = FormatNumber(entity.Visibility), ["targetEntityId"] = targetEntityId, ["targetedByPlayer"] = player != null && player.TargetEntityIndex == entity.EntityIndex ? "true" : "false", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 67f990366..5552f9f9b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -376,6 +376,12 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds); AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds); AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog); + var medical = AetheriaRuntimeThermalMedicalSimulation.Step( + entity, catalog, deltaSeconds, settings); + PublishThermalMedicalEvents(run, zone, entity, medical, frameId); + if (medical.Died) + CommitDestruction(run, zone, entity, -1, + $"thermal:{medical.DeathCause}", "", frameId, settings, medical.DeathCause); entity.IsActive = IsAlive(entity); if (!entity.IsActive) { @@ -450,6 +456,29 @@ namespace GameCult.Aetheria.State.Verse } } + private static void PublishThermalMedicalEvents( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeThermalMedicalResult result, + long frameId) + { + void Append(string kind, double exposure) + { + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:{kind}", + Kind = kind, FrameId = frameId, ZoneIndex = zone.ZoneIndex, + TargetEntityIndex = entity.EntityIndex, SubjectKey = entity.EntityId, + ScalarValue = exposure, AuxiliaryValue = result.CockpitTemperature, + PositionX = entity.PositionX, PositionZ = entity.PositionZ + }); + } + + if (result.HeatstrokeRiskCrossed) Append("pilot.heatstroke.risk", entity.Heatstroke); + if (result.HypothermiaRiskCrossed) Append("pilot.hypothermia.risk", entity.Hypothermia); + } + private static void ResolveDeployableDetonations( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, @@ -1236,13 +1265,15 @@ namespace GameCult.Aetheria.State.Verse string subjectKey, string weaponItemKey, long frameId, - AetheriaRuntimeDaemonSimulationSettings settings) + AetheriaRuntimeDaemonSimulationSettings settings, + string causeOfDeath = "hull-destroyed") { if (!string.IsNullOrWhiteSpace(target.DestructionId)) return; var destructionId = $"destruction:{run.RunId}:{zone.ZoneIndex}:{target.EntityIndex}:{frameId}"; target.DestructionId = destructionId; target.DestroyedFrameId = frameId; + target.CauseOfDeath = causeOfDeath ?? ""; target.IsActive = false; target.TargetEntityIndex = -1; target.TractorPower = 0; @@ -1332,7 +1363,8 @@ namespace GameCult.Aetheria.State.Verse EventId = destructionId, Kind = "entity.destroyed", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = subjectKey, - ItemKey = weaponItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ + ItemKey = weaponItemKey, Reason = target.CauseOfDeath, + PositionX = target.PositionX, PositionZ = target.PositionZ }); } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs index 9497c13bb..599c2d61d 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs @@ -37,7 +37,16 @@ namespace GameCult.Aetheria.State.Verse weaponLockDecayPerSecond: 1.0, lootDropProbability: 0.25, lootDropVelocity: 25.0, - pickupLifetimeSeconds: 30.0); + pickupLifetimeSeconds: 30.0, + severeThermalRiskThreshold: 0.25, + heatstrokeTemperature: 330.0, + heatstrokeMultiplier: 0.00001, + heatstrokeExponent: 2.0, + heatstrokeRecoveryPerSecond: 0.2, + hypothermiaTemperature: 273.0, + hypothermiaMultiplier: 0.00001, + hypothermiaExponent: 2.0, + hypothermiaRecoveryPerSecond: 0.2); public AetheriaRuntimeDaemonSimulationSettings( double pawnSpeed, @@ -68,7 +77,16 @@ namespace GameCult.Aetheria.State.Verse double weaponLockDecayPerSecond = AetheriaDefaultRaw.WeaponLockDecayPerSecond, double lootDropProbability = AetheriaDefaultRaw.LootDropProbability, double lootDropVelocity = AetheriaDefaultRaw.LootDropVelocity, - double pickupLifetimeSeconds = AetheriaDefaultRaw.PickupLifetimeSeconds) + double pickupLifetimeSeconds = AetheriaDefaultRaw.PickupLifetimeSeconds, + double severeThermalRiskThreshold = AetheriaDefaultRaw.SevereThermalRiskThreshold, + double heatstrokeTemperature = AetheriaDefaultRaw.HeatstrokeTemperature, + double heatstrokeMultiplier = AetheriaDefaultRaw.HeatstrokeMultiplier, + double heatstrokeExponent = AetheriaDefaultRaw.HeatstrokeExponent, + double heatstrokeRecoveryPerSecond = AetheriaDefaultRaw.HeatstrokeRecoveryPerSecond, + double hypothermiaTemperature = AetheriaDefaultRaw.HypothermiaTemperature, + double hypothermiaMultiplier = AetheriaDefaultRaw.HypothermiaMultiplier, + double hypothermiaExponent = AetheriaDefaultRaw.HypothermiaExponent, + double hypothermiaRecoveryPerSecond = AetheriaDefaultRaw.HypothermiaRecoveryPerSecond) { PawnSpeed = PositiveOr(pawnSpeed, AetheriaDefaultRaw.PawnSpeed); RaiderSpeed = PositiveOr(raiderSpeed, AetheriaDefaultRaw.RaiderSpeed); @@ -99,6 +117,15 @@ namespace GameCult.Aetheria.State.Verse LootDropProbability = Clamp01Or(lootDropProbability, AetheriaDefaultRaw.LootDropProbability); LootDropVelocity = NonNegativeOr(lootDropVelocity, AetheriaDefaultRaw.LootDropVelocity); PickupLifetimeSeconds = PositiveOr(pickupLifetimeSeconds, AetheriaDefaultRaw.PickupLifetimeSeconds); + SevereThermalRiskThreshold = Clamp01Or(severeThermalRiskThreshold, AetheriaDefaultRaw.SevereThermalRiskThreshold); + HeatstrokeTemperature = PositiveOr(heatstrokeTemperature, AetheriaDefaultRaw.HeatstrokeTemperature); + HeatstrokeMultiplier = NonNegativeOr(heatstrokeMultiplier, AetheriaDefaultRaw.HeatstrokeMultiplier); + HeatstrokeExponent = PositiveOr(heatstrokeExponent, AetheriaDefaultRaw.HeatstrokeExponent); + HeatstrokeRecoveryPerSecond = NonNegativeOr(heatstrokeRecoveryPerSecond, AetheriaDefaultRaw.HeatstrokeRecoveryPerSecond); + HypothermiaTemperature = PositiveOr(hypothermiaTemperature, AetheriaDefaultRaw.HypothermiaTemperature); + HypothermiaMultiplier = NonNegativeOr(hypothermiaMultiplier, AetheriaDefaultRaw.HypothermiaMultiplier); + HypothermiaExponent = PositiveOr(hypothermiaExponent, AetheriaDefaultRaw.HypothermiaExponent); + HypothermiaRecoveryPerSecond = NonNegativeOr(hypothermiaRecoveryPerSecond, AetheriaDefaultRaw.HypothermiaRecoveryPerSecond); } public double PawnSpeed { get; } @@ -130,6 +157,15 @@ namespace GameCult.Aetheria.State.Verse public double LootDropProbability { get; } public double LootDropVelocity { get; } public double PickupLifetimeSeconds { get; } + public double SevereThermalRiskThreshold { get; } + public double HeatstrokeTemperature { get; } + public double HeatstrokeMultiplier { get; } + public double HeatstrokeExponent { get; } + public double HeatstrokeRecoveryPerSecond { get; } + public double HypothermiaTemperature { get; } + public double HypothermiaMultiplier { get; } + public double HypothermiaExponent { get; } + public double HypothermiaRecoveryPerSecond { get; } private static double PositiveOr(double value, double fallback) { @@ -177,6 +213,15 @@ namespace GameCult.Aetheria.State.Verse public const double LootDropProbability = 0.25; public const double LootDropVelocity = 25.0; public const double PickupLifetimeSeconds = 30.0; + public const double SevereThermalRiskThreshold = 0.25; + public const double HeatstrokeTemperature = 330.0; + public const double HeatstrokeMultiplier = 0.00001; + public const double HeatstrokeExponent = 2.0; + public const double HeatstrokeRecoveryPerSecond = 0.2; + public const double HypothermiaTemperature = 273.0; + public const double HypothermiaMultiplier = 0.00001; + public const double HypothermiaExponent = 2.0; + public const double HypothermiaRecoveryPerSecond = 0.2; } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index 5c9495e52..8848ff4e0 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1049,6 +1049,9 @@ namespace GameCult.Aetheria.State.Verse [Key(47)] public IReadOnlyList EquipmentStates { get; set; } = Array.Empty(); + + [Key(48)] + public string CauseOfDeath { get; set; } = ""; } [MessagePackObject] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs new file mode 100644 index 000000000..498eb4a73 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs @@ -0,0 +1,90 @@ +using System; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeThermalMedicalSimulation + { + public static AetheriaRuntimeThermalMedicalResult Step( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, + double deltaSeconds, + AetheriaRuntimeDaemonSimulationSettings settings) + { + if (entity == null || catalog == null || deltaSeconds <= 0 || !entity.IsActive) + return default; + + var cockpit = AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, "Cockpit") + .OrderBy(value => value.EquipmentIndex) + .FirstOrDefault(); + if (cockpit == null) + return default; + + var temperature = AetheriaRuntimeThermalSimulation.EquipmentTemperature( + entity, catalog, cockpit.EquipmentIndex); + SetScalar(entity, "cockpit-temperature", temperature); + var previousHeatstroke = Clamp01(entity.Heatstroke); + var previousHypothermia = Clamp01(entity.Hypothermia); + + entity.Heatstroke = temperature > settings.HeatstrokeTemperature + ? Clamp01(previousHeatstroke + Math.Pow( + temperature - settings.HeatstrokeTemperature, + settings.HeatstrokeExponent) * settings.HeatstrokeMultiplier * deltaSeconds) + : Clamp01(previousHeatstroke - settings.HeatstrokeRecoveryPerSecond * deltaSeconds); + entity.Hypothermia = temperature < settings.HypothermiaTemperature + ? Clamp01(previousHypothermia + Math.Pow( + settings.HypothermiaTemperature - temperature, + settings.HypothermiaExponent) * settings.HypothermiaMultiplier * deltaSeconds) + : Clamp01(previousHypothermia - settings.HypothermiaRecoveryPerSecond * deltaSeconds); + + var heatstrokeRisk = Crossed(previousHeatstroke, entity.Heatstroke, + settings.SevereThermalRiskThreshold); + var hypothermiaRisk = Crossed(previousHypothermia, entity.Hypothermia, + settings.SevereThermalRiskThreshold); + var deathCause = entity.Heatstroke > 0.99 ? "heatstroke" : + entity.Hypothermia > 0.99 ? "hypothermia" : ""; + return new AetheriaRuntimeThermalMedicalResult( + cockpit.EquipmentIndex, temperature, heatstrokeRisk, hypothermiaRisk, deathCause); + } + + private static bool Crossed(double previous, double current, double threshold) => + previous < threshold && current > threshold; + + private static void SetScalar(AetheriaRuntimeEntitySnapshotCommit entity, string name, double value) + { + var grids = (entity.StatGrids ?? Array.Empty()).ToList(); + var grid = grids.FirstOrDefault(candidate => string.Equals(candidate.Name, name, StringComparison.Ordinal)); + if (grid == null) + { + grid = new AetheriaRuntimeEntityStatGridCommit { Name = name, Width = 1, Height = 1 }; + grids.Add(grid); + } + grid.Values = new[] { value }; + entity.StatGrids = grids; + } + + private static double Clamp01(double value) => value < 0 ? 0 : value > 1 ? 1 : value; + } + + public readonly struct AetheriaRuntimeThermalMedicalResult + { + public AetheriaRuntimeThermalMedicalResult(int cockpitEquipmentIndex, double cockpitTemperature, + bool heatstrokeRiskCrossed, bool hypothermiaRiskCrossed, string deathCause) + { + CockpitEquipmentIndex = cockpitEquipmentIndex; + CockpitTemperature = cockpitTemperature; + HeatstrokeRiskCrossed = heatstrokeRiskCrossed; + HypothermiaRiskCrossed = hypothermiaRiskCrossed; + DeathCause = deathCause ?? ""; + } + + public int CockpitEquipmentIndex { get; } + public double CockpitTemperature { get; } + public bool HeatstrokeRiskCrossed { get; } + public bool HypothermiaRiskCrossed { get; } + public string DeathCause { get; } + public bool Died => !string.IsNullOrWhiteSpace(DeathCause); + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs.meta new file mode 100644 index 000000000..22c79cf82 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeThermalMedicalSimulation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 741e91657d8b412ebf96032d567ecfc7 diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index cfd563e5e..17b8f1bf6 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1942,9 +1942,13 @@ First Aetheria surfaces to publish: 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 + equipment-online writer. `AetheriaRuntimeThermalMedicalSimulation` derives + cockpit exposure, recovery, severe-risk crossings, and thermal death from the + cockpit's canonical cells; all death causes use the same destruction commit. + The Eve game surface publishes the resulting power, radiator, temperature, + wear, offline-equipment, cockpit, risk, render-weight, and typed death facts. + Rotated equipment footprints, parent/child radiation, schematic composition, + native thermal post effects, and the cause-specific death fade remain migration work rather than presentation authority. - `dotnet list package --vulnerable --include-transitive` is clean for active maintained projects. diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 992f7c248..c38fd0dd0 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 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 | +| Thermal/energy | cell conduction, capacitor/reactor/radiator network, and cockpit thermal exposure | daemon | ship/equipment power, temperature, medical state, risk and death feedback | 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; cockpit-cell temperature drives the fossil nonlinear heatstroke/hypothermia accumulation, linear recovery, severe-risk crossings, and ordinary exactly-once destruction with a typed cause; Eve exposes power, temperature, wear, offline-equipment, cockpit, risk, heatstroke render-weight, and death-cause facts; rotated equipment topology, parent/child radiation, schematic composition and native post/death lowering remain incomplete | multi-consumer ordering, multi-radiator visibility, rotated topology, reconnect across risk/death, generic schematic meters, heatstroke phasing and cause-specific death fade | | 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-presentation-spec.md b/docs/original-game-presentation-spec.md index 04eee2baf..ee456ceb5 100644 --- a/docs/original-game-presentation-spec.md +++ b/docs/original-game-presentation-spec.md @@ -97,6 +97,22 @@ Death unbinds gameplay input, presents cause, and transitions to appropriate menu/restart flow. The client cannot clear or save authoritative world state as a side effect of playing the animation. +The fossil's live heatstroke presentation uses two volume weights. Base weight +is `saturate(heatstroke / severeRiskThreshold)`. Severe weight is the normalized +amount above that threshold, with an authored sinusoidal pulse applied by the +lowerer. Heatstroke and hypothermia each have schematic exposure meters, while +cockpit temperature drives the shared thermal-limit meter. The scene contains +hypothermia volumes, but the inspected live update never drove them before +death; do not invent continuous hypothermia post processing as historical +parity. Cause-specific death fades cross the applicable thermal volumes into +the death volume over one authored second. + +The current fossil settings still declare heatstroke and hypothermia control +limits, but the implemented heatstroke cockpit lockout was deliberately removed +and no hypothermia lockout was connected. Thermal exposure therefore does not +silently attenuate or reject semantic controls. The daemon owns medical state +and death; the runtime owns only meter, volume, and transition lowering. + ## Effects And Audio Weapon and impact effects consume semantic event facts and provider asset refs. diff --git a/docs/original-game-simulation-spec.md b/docs/original-game-simulation-spec.md index 7ef303865..200c6df20 100644 --- a/docs/original-game-simulation-spec.md +++ b/docs/original-game-simulation-spec.md @@ -42,6 +42,15 @@ performance and online state derive from local temperature. Cockpit temperature advances heatstroke/hypothermia accumulation and recovery. Threshold crossings and death are authoritative events. +Above 330 K, heatstroke advances by `(temperature - 330)^2 * 0.00001 * dt`; +below the threshold it recovers by `0.2 * dt`. Below 273 K, hypothermia advances +by `(273 - temperature)^2 * 0.00001 * dt`; above the threshold it also recovers +by `0.2 * dt`. Values saturate to `[0,1]`, severe risk crosses at `0.25`, and a +value above `0.99` kills through the ordinary entity death path. The fossil's +hypothermia risk comparison accidentally read heatstroke; the daemon implements +the intended symmetric threshold crossing while retaining the exact exposure +and death formulas. + 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