From afb26b206d2f16ede663072cacddf09843c77109 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 03:08:56 +0100 Subject: [PATCH] Run thermostat behavior gates in daemon --- Aetheria.State.Daemon.Smoke/Program.cs | 64 +++++++++++++++++++ .../Tests/DaemonRuntimeDocumentTests.cs | 32 +++++++++- .../AetheriaRuntimeBehaviorSimulation.cs | 14 ++++ .../AetheriaRuntimeBehaviorStateProjector.cs | 6 +- .../AetheriaRuntimeDaemonOperations.cs | 24 +++++-- docs/aetheria-current-codebase-model.md | 3 + docs/aetheria-perfect-machine-map.md | 8 +++ docs/original-game-migration-matrix.md | 2 +- 8 files changed, 146 insertions(+), 7 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index b3540f072..6a034d80f 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -176,6 +176,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FossilVelocityBehaviorsRunInDaemonFlightStep, BehaviorChainsGateAuthoritativeStatModifiers, ResourceBehaviorsStopEquipmentChainsInAuthoredOrder, + ThermotoggleSeedsAndGatesFromDaemonOwnedTarget, ReflectorUsesSharedStellarLightFieldWithoutAccumulating, SensorPingIsActorScopedEnergyGatedAndReconnectable, TurretControllerAcquiresAimsAndTriggersExactWeapons, @@ -392,6 +393,69 @@ internal sealed class AetheriaDaemonYmirSmokeChecks }; } + private static void ThermotoggleSeedsAndGatesFromDaemonOwnedTarget() + { + var thermostat = CatalogItem("thermostat", + new AetheriaRuntimeBehaviorPayload(0, "Thermotoggle", 0, + [ + new AetheriaRuntimeBehaviorField(1, Number(300)), + new AetheriaRuntimeBehaviorField(2, BoolValue(false)), + new AetheriaRuntimeBehaviorField(3, BoolValue(true)) + ]), + new AetheriaRuntimeBehaviorPayload(1, "Heat", 0, + [ + new AetheriaRuntimeBehaviorField(1, PerformanceStat(10)), + new AetheriaRuntimeBehaviorField(2, BoolValue(false)) + ])); + var catalog = new AetheriaRuntimeCatalogSnapshot([thermostat], [], []); + var entity = Entity(0, 0, "player"); + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = thermostat.ItemKey, Enabled = true, Durability = 1, Quality = 1 } + }]; + AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog); + AetheriaRuntimeThermalSimulation.EnsureState(entity); + AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, 0.1); + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(entity, catalog); + var thermostatState = entity.BehaviorStates.Single(value => value.BehaviorKind == "Thermotoggle"); + RequireNear(300, thermostatState.ThermotoggleTargetTemperature, 0.000001, + "new thermotoggle state must seed from the authored target instead of zero"); + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "thermotoggle-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [entity] }] + }; + var context = new AetheriaRuntimeDaemonOperationContext { Catalog = catalog }; + + SetTarget(200, 1); + var before = AetheriaRuntimeThermalSimulation.EquipmentTemperature(entity, catalog, 0); + AetheriaRuntimeBehaviorSimulation.Step(0, [entity], [], catalog, 0.1); + RequireNear(before, AetheriaRuntimeThermalSimulation.EquipmentTemperature(entity, catalog, 0), 0.000001, + "a low-pass thermotoggle above its target must stop downstream heat"); + + SetTarget(300, 2); + AetheriaRuntimeBehaviorSimulation.Step(0, [entity], [], catalog, 0.1); + Require(AetheriaRuntimeThermalSimulation.EquipmentTemperature(entity, catalog, 0) > before, + "a low-pass thermotoggle below its target must admit downstream heat"); + + void SetTarget(double target, long sequence) + { + var command = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.SetThermotoggleTargetTemperature, + "smoke", "thermostat-session", sequence, "zone.0.entity.0"); + command.TargetEntityKey = "zone.0.entity.0"; + command.EquipmentIndex = 0; + command.BehaviorIndex = 0; + command.ScalarValue = target; + var result = AetheriaRuntimeDaemonOperations.Execute(run, [command], context); + RequireEqual(1, result.AppliedCommandIds.Count, + "an installed adjustable thermotoggle must accept its typed daemon command"); + } + } + private static void DockedRefitSurfacePublishesGenericCommands() { var hull = HullCatalogItem("surface-hull", 9, 7, 100); diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 85d58a16d..b81bced21 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -4101,10 +4101,40 @@ public class DaemonRuntimeDocumentTests command.BehaviorIndex = 0; command.ScalarValue = 450.0; - var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); + var thermotoggle = BehaviorPayload( + "Thermotoggle", + new AetheriaRuntimeBehaviorField(1, NumberValue(300)), + BoolField(2, false), + BoolField(3, true)); + var context = new AetheriaRuntimeDaemonOperationContext + { + Catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("reactor", new[] { thermotoggle }) }, + Array.Empty(), + Array.Empty()) + }; + + var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }, context); Assert.AreEqual(1, result.AppliedCommandIds.Count); Assert.AreEqual(450.0, run.Zones[0].Entities[0].BehaviorStates[0].ThermotoggleTargetTemperature, 0.0001); + + thermotoggle.Fields = new[] + { + new AetheriaRuntimeBehaviorField(1, NumberValue(300)), + BoolField(2, false), + BoolField(3, false) + }; + var rejected = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.SetThermotoggleTargetTemperature, + "codex", "session-thermotoggle", 24, "zone.0.entity.0"); + rejected.TargetEntityKey = "zone.0.entity.0"; + rejected.EquipmentIndex = 0; + rejected.BehaviorIndex = 0; + rejected.ScalarValue = 500; + result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { rejected }, context); + Assert.AreEqual(1, result.RejectedCommandIds.Count); + Assert.AreEqual(450.0, run.Zones[0].Entities[0].BehaviorStates[0].ThermotoggleTargetTemperature, 0.0001); } [Test] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs index 3f873c764..e17754cd6 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs @@ -144,6 +144,20 @@ namespace GameCult.Aetheria.State.Verse break; state.TriggerPulled = false; } + if (string.Equals(entry.Payload.Kind, "Thermotoggle", StringComparison.Ordinal)) + { + var temperature = AetheriaRuntimeThermalSimulation.EquipmentTemperature( + entity, catalog, equipmentIndex); + var highPass = ReadBool(entry.Payload, 2); + if ((temperature < state.ThermotoggleTargetTemperature) ^ highPass) + { + // The fossil returns true for this branch. + } + else + { + break; + } + } if (string.Equals(entry.Payload.Kind, "Cooldown", StringComparison.Ordinal)) { if (state.CooldownProgress >= 0) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs index a5775c082..9175de931 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs @@ -69,13 +69,17 @@ namespace GameCult.Aetheria.State.Verse if (payload == null || string.IsNullOrWhiteSpace(payload.Kind)) continue; - yield return new AetheriaRuntimeBehaviorStateCommit + var state = new AetheriaRuntimeBehaviorStateCommit { OwnerKind = EquipmentOwnerKind, OwnerIndex = equipmentIndex, BehaviorIndex = behaviorIndex, BehaviorKind = payload.Kind }; + if (string.Equals(payload.Kind, "Thermotoggle", StringComparison.Ordinal)) + state.ThermotoggleTargetTemperature = (payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == 1)?.Value?.NumberValue ?? 0; + yield return state; } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 8752308fb..7da3cb653 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -189,7 +189,7 @@ namespace GameCult.Aetheria.State.Verse case AetheriaRuntimeDaemonCommandKinds.SetWeaponGroupMembership: return ApplySetWeaponGroupMembership(run, command); case AetheriaRuntimeDaemonCommandKinds.SetThermotoggleTargetTemperature: - return ApplySetThermotoggleTargetTemperature(run, command); + return ApplySetThermotoggleTargetTemperature(run, command, context); case AetheriaRuntimeDaemonCommandKinds.SetEntityName: return ApplyTargetEntity(run, command.TargetEntityKey, entity => entity.Name = command.TextValue ?? ""); @@ -753,9 +753,11 @@ namespace GameCult.Aetheria.State.Verse private static bool ApplySetThermotoggleTargetTemperature( AetheriaRuntimeRunCheckpointCommit run, - AetheriaRuntimeDaemonCommandDocument command) + AetheriaRuntimeDaemonCommandDocument command, + AetheriaRuntimeDaemonOperationContext context) { - if (!TryResolveEntity(run, command.TargetEntityKey, out _, out _, out var entity)) + if (!double.IsFinite(command.ScalarValue) || + !TryResolveEntity(run, command.TargetEntityKey, out _, out _, out var entity)) return false; var behavior = (entity.BehaviorStates ?? Array.Empty()) @@ -764,7 +766,21 @@ namespace GameCult.Aetheria.State.Verse string.Equals(candidate.OwnerKind, "equipment", StringComparison.Ordinal) && candidate.OwnerIndex == command.EquipmentIndex && candidate.BehaviorIndex == command.BehaviorIndex); - if (behavior == null) + var equipment = entity.Equipment ?? Array.Empty(); + var item = command.EquipmentIndex >= 0 && command.EquipmentIndex < equipment.Count + ? equipment[command.EquipmentIndex]?.Item + : null; + var payloads = context.Catalog?.FindItem(item?.ItemKey ?? "")?.BehaviorPayloads ?? + Array.Empty(); + var payload = command.BehaviorIndex >= 0 && command.BehaviorIndex < payloads.Count + ? payloads[command.BehaviorIndex] + : null; + var adjustable = (payload?.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == 3)?.Value?.BoolValue ?? false; + if (behavior == null || + !string.Equals(behavior.BehaviorKind, "Thermotoggle", StringComparison.Ordinal) || + !string.Equals(payload?.Kind, "Thermotoggle", StringComparison.Ordinal) || + !adjustable) return false; behavior.ThermotoggleTargetTemperature = command.ScalarValue; diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index b4e5c8a39..843590572 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -283,6 +283,9 @@ idempotent and keeps the Unity fossil out of the gameplay authority path. The same daemon organ executes common resource gates in group order. Energy, thermal cells, cargo, and durability remain owned by their narrow transaction subsystems; behavior composition decides only whether and when to call them. +Thermotoggle target temperature is also daemon-owned behavior state. Authored +data seeds it, adjustable commands mutate it, and its high/low-pass predicate +gates the same ordered chain. Migration target: diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 96b2bf12d..190459567 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -315,6 +315,14 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. downstream heat, wear, or modifiers. Thermal simulation computes wear but is no longer allowed to apply it merely because an item contains a `Wear` payload. + `Thermotoggle` is another chain gate, not a renderer hint. New state seeds its + target from authored field 1, evaluates the fossil + `(temperature < target) XOR highPass` predicate against source-equipment + temperature, and stops its group when false. The target-temperature command + mutates only an exact installed `Thermotoggle` whose authored field 3 grants + adjustment; non-finite values, wrong behavior indices, missing catalogs, and + fixed thermostats fail closed. Eve receives the live target as ordinary + behavior state and remains presentation-only. - Sensor contact information is daemon-owned. Each operational installed `Sensor` behavior executes the fossil information equation against target visibility, distance, observer-relative angle, its authored sensitivity diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index f10c59d4d..a14775baf 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -43,7 +43,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Save/resume | original persistence paths | daemon/CultCache | published current state | clients rehydrate | owner-exists; not parity-proven | mid-loop restart | | Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | live minimum-loop proof: generic Unity discovers the provider, lowers the pilot world/assets, single-steps dock and undock while paused, buys daemon-priced station stock, single-steps Ymir salvage collection, resumes, moves, targets, fires, and presents authoritative receipts/shot outcome; the released full-session profile starts with no pickup, destroys a deterministic target, observes its canonical cargo drop, tractors it into a Ymir Begin contact, and proves exactly one `ymir-fact:` collection receipt plus cargo `0` to `1` | richer trade content, docking transitions, and sustained combat encounters | | Starbridge | original/shared architecture intent | one daemon session | pilot + commander surfaces | Unity + Electron | commander task board and autonomous explore execution wired; Electron Eve lowering and split-target proof missing | commander orders plus four pilots and autonomous workers | -| Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; `Switch`, one-shot `Trigger`, normalized `Cooldown`, `EnergyDraw`, equipment `Heat`, atomic `ItemUsage`, and thermal `Wear` execute in authored group order and stop downstream effects on refusal; cooldown projects generic progress. Equipped and timed-consumable `StatModifier` sources persist daemon-owned lifecycle state and every runtime stat reader applies the fossil `base × multiplier product + constant sum` rule through one query; legacy `Data` kind references and required-behavior filters are honored | remaining specialized behavior return-value composition and canonical consumable-content proof | +| Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; `Switch`, one-shot `Trigger`, normalized `Cooldown`, adjustable high/low-pass `Thermotoggle`, `EnergyDraw`, equipment `Heat`, atomic `ItemUsage`, and thermal `Wear` execute in authored group order and stop downstream effects on refusal; cooldown projects generic progress and thermostat target is daemon-commanded state seeded from authored data. Equipped and timed-consumable `StatModifier` sources persist daemon-owned lifecycle state and every runtime stat reader applies the fossil `base × multiplier product + constant sum` rule through one query; legacy `Data` kind references and required-behavior filters are honored | remaining specialized behavior return-value composition and canonical consumable-content proof | | Sensors/visibility | installed sensor sensitivity and angular curve, `Visibility`, `Reflector`, decaying signature sources, stellar light, ping state, and observer information thresholds | daemon | accumulated contacts, named visibility sources, and ping events | indicators/maps and provider environment lowering | every operational installed Sensor executes the fossil visibility/sensitivity/angle/distance information equation and decay; visibility is thresholded by the provider setting. Actor-scoped ping owns energy, cooldown, expansion radius, reconnect-stable crossed-target state, one-time information boost, and transient signature. Operational `Visibility` equipment publishes evaluated constant signature; committed weapons publish authored signature including charged-shot multiplication; transient weapon and ping sources decay by the fossil exponential rule. Reflectors multiply evaluated cross-section by the exponent-8 stellar `PowerPulse` after thermal settlement. Gameplay light and environment projection share resolved authored orbit/body centers and provider-owned sun radius inputs. Contact refresh no longer invents perfect information or overwrites emitted visibility; docked craft consume one daemon-derived union of craft and dock-parent contacts through both Eve rows and SoA bodies, with sensor-source provenance | active consumable visibility/reflector effects, complete source cardinality, classification timelines, and canonical catalog station-array data | | AI/tasks | corporation task scheduler plus agent state machines and installed orbital `TurretController` | daemon | commander task board, corporation survey ledger, controller aim/state, and shared semantic controls | Eve commander/pilot clients | point exploration, loadout-driven attack, haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home are wired through shared commands. Ship combat samples the fossil DPS/range policy, selects live weapon groups, leads projectiles, strafes relative to target attitude, and gates fire on catalog placement rotation. Operational orbital controllers independently acquire the first visible hostile ship, publish predictive look direction, and trigger only exact aligned in-range weapon behaviors through the shared combat lifecycle; non-player identity no longer grants implicit group-zero fire. Cross-zone task and home routes survive the full daemon-owned wormhole timeline; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing | cancellation, reconnect, multi-client command transfer, and model-local barrel-direction metadata | | Consumables | cargo-backed timed behavior containers | daemon | dynamic actions, active effects, and lifecycle facts | action bar/schematic | daemon atomically resolves typed metadata and concrete cargo, preserves consumed-instance quality, enforces authored stacking, evaluates effectiveness/quality, and executes ordered `EnergyDraw`, fossil-compatible no-op `Heat`, `ItemUsage`, and stateful `Cooldown` payloads after energy tick initialization; every effect and payload state has stable authored identity; cooldowns update before the ordered chain, require state strictly below zero, reset to one on success, and continue aging behind earlier failures; false and unsupported payloads stop later execution with a typed fact; timers retain the fossil's exact-zero final tick; Unity reconciles presentation by effect identity and cannot restore active behavior state by list index | catalog revision pinning, stat modifiers, remaining behavior families, and reconnect presentation proof |