From c4ae06d9cf4750b0acd63d522daabe87386ba3b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 15:36:50 +0100 Subject: [PATCH] Restore daemon-owned consumable lifecycle --- .../Tests/DaemonRuntimeDocumentTests.cs | 126 +++++++++++- .../AetheriaRuntimeConsumableSimulation.cs | 180 ++++++++++++++++++ ...etheriaRuntimeConsumableSimulation.cs.meta | 2 + .../AetheriaRuntimeDaemonSimulation.cs | 7 + ...AetheriaRuntimeInputCapabilityDocuments.cs | 11 +- docs/aetheria-perfect-machine-map.md | 9 + docs/original-game-migration-matrix.md | 4 +- 7 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs.meta diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 0be0f6ab3..279630145 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -4920,6 +4920,121 @@ public class DaemonRuntimeDocumentTests Assert.IsTrue(result.Intents.SensorPingRequested); } + [Test] + public void ConsumableSimulationAtomicallyConsumesCargoPreservesQualityAndExpires() + { + var run = RunWithTwoEntities(); + var actor = run.Zones[0].Entities[0]; + actor.CargoContents = new[] + { + new AetheriaRuntimeCargoBayLoadoutCommit + { + Items = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit + { + X = 2, + Y = 3, + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = "repair-gel", + Quantity = 2, + Quality = 0.73 + } + } + } + } + }; + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("repair-gel", Array.Empty(), category: AetheriaRuntimeItemCategories.Consumable, stackable: false, duration: 2) }, + Array.Empty(), + Array.Empty()); + var intents = new[] { new AetheriaRuntimeDaemonConsumableIntent { ActorEntityKey = "zone.0.entity.0", ItemKey = "repair-gel" } }; + + AetheriaRuntimeConsumableSimulation.Step(run, intents, catalog, 40, 0.5); + + Assert.AreEqual(1, actor.CargoContents[0].Items[0].Item.Quantity); + Assert.AreEqual(1, actor.ActiveConsumables.Count); + Assert.AreEqual(0.73, actor.ActiveConsumables[0].Quality, 0.0001); + Assert.AreEqual(1.5, actor.ActiveConsumables[0].RemainingDuration, 0.0001); + Assert.AreEqual("consumable.activated", run.GameEvents.Single().Kind); + + AetheriaRuntimeConsumableSimulation.Step(run, Array.Empty(), catalog, 41, 1.5); + Assert.AreEqual(1, actor.ActiveConsumables.Count, "The fossil grants one final update at exactly zero duration."); + AetheriaRuntimeConsumableSimulation.Step(run, Array.Empty(), catalog, 42, 0.01); + Assert.AreEqual(0, actor.ActiveConsumables.Count); + Assert.AreEqual(1, run.GameEvents.Count(value => value.Kind == "consumable.expired")); + } + + [Test] + public void ConsumableSimulationRejectsDuplicateWithoutConsumingCargo() + { + var run = RunWithTwoEntities(); + var actor = run.Zones[0].Entities[0]; + actor.CargoContents = new[] + { + new AetheriaRuntimeCargoBayLoadoutCommit + { + Items = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "repair-gel", Quantity = 1, Quality = 1 } + } + } + } + }; + actor.ActiveConsumables = new[] + { + new AetheriaRuntimeActiveConsumableCommit { ItemKey = "repair-gel", Duration = 2, RemainingDuration = 1 } + }; + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("repair-gel", Array.Empty(), category: AetheriaRuntimeItemCategories.Consumable, stackable: false, duration: 2) }, + Array.Empty(), + Array.Empty()); + + AetheriaRuntimeConsumableSimulation.Step(run, + new[] { new AetheriaRuntimeDaemonConsumableIntent { ActorEntityKey = "zone.0.entity.0", ItemKey = "repair-gel" } }, + catalog, 50, 0.25); + + Assert.AreEqual(1, actor.CargoContents[0].Items[0].Item.Quantity); + Assert.AreEqual(1, actor.ActiveConsumables.Count); + Assert.AreEqual("already-active", run.GameEvents.Single(value => value.Kind == "consumable.activation.refused").Reason); + } + + [Test] + public void InputCapabilitiesAdvertiseOnlyCurrentlyActivatableConsumables() + { + var run = RunWithTwoEntities(); + run.CurrentEntityKey = run.EntityRecordKey(0, 0); + var actor = run.Zones[0].Entities[0]; + actor.CargoContents = new[] + { + new AetheriaRuntimeCargoBayLoadoutCommit + { + Items = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "repair-gel", Quantity = 1 } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "ore", Quantity = 4 } } + } + } + }; + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] + { + CatalogItem("repair-gel", Array.Empty(), category: AetheriaRuntimeItemCategories.Consumable, duration: 2), + CatalogItem("ore", Array.Empty(), category: AetheriaRuntimeItemCategories.SimpleCommodity) + }, + Array.Empty(), + Array.Empty()); + var frame = new AetheriaRuntimeDaemonFrameDocument { FrameId = 70, Run = run }; + + var capability = AetheriaRuntimeInputCapabilityDocument.FromFrame(frame, catalog: catalog); + + Assert.IsTrue(capability.Actions.Any(action => action.Operation.EndsWith("ActivateConsumable", StringComparison.Ordinal) && action.Payload["itemKey"] == "repair-gel")); + Assert.IsFalse(capability.Actions.Any(action => action.Operation.EndsWith("ActivateConsumable", StringComparison.Ordinal) && action.Payload["itemKey"] == "ore")); + } + [Test] public void BehaviorStateProjectorDerivesEquipmentRowsFromCatalogPayloads() { @@ -5642,12 +5757,15 @@ public class DaemonRuntimeDocumentTests string itemKey, IReadOnlyList behaviorPayloads, int price = 0, - string hullType = "") + string hullType = "", + string category = "", + bool stackable = false, + double duration = 0) { return new AetheriaRuntimeCatalogItem( itemKey, itemKey, - "", + category, "", "", price, @@ -5669,8 +5787,8 @@ public class DaemonRuntimeDocumentTests hullType, behaviorPayloads.Select(payload => payload.Kind).ToArray(), 1, - false, - 0, + stackable, + duration, 1, "", "", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs new file mode 100644 index 000000000..c21455952 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeConsumableSimulation + { + public static void Step( + AetheriaRuntimeRunCheckpointCommit run, + IEnumerable intents, + AetheriaRuntimeCatalogSnapshot? catalog, + long frameId, + double deltaSeconds) + { + if (run == null || deltaSeconds <= 0) + return; + + var intentIndex = 0; + foreach (var intent in intents ?? Enumerable.Empty()) + { + Activate(run, intent, catalog, frameId, intentIndex); + intentIndex++; + } + + foreach (var zone in run.Zones ?? Array.Empty()) + foreach (var entity in zone?.Entities ?? Array.Empty()) + { + if (entity == null) + continue; + + var active = (entity.ActiveConsumables ?? Array.Empty()) + .Where(effect => effect != null) + .ToArray(); + for (var index = 0; index < active.Length; index++) + { + active[index].RemainingDuration -= deltaSeconds; + if (active[index].RemainingDuration < 0) + { + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:consumable:{index}:expired", + Kind = "consumable.expired", + FrameId = frameId, + ZoneIndex = zone.ZoneIndex, + SourceEntityIndex = entity.EntityIndex, + ItemKey = active[index].ItemKey + }); + } + } + entity.ActiveConsumables = active.Where(effect => effect.RemainingDuration >= 0).ToArray(); + } + } + + public static bool CanActivate( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog, + string itemKey) + { + var item = catalog?.FindItem(itemKey); + return entity != null && item != null && + string.Equals(item.Category, AetheriaRuntimeItemCategories.Consumable, StringComparison.Ordinal) && + item.Duration > 0 && + (item.Stackable || !(entity.ActiveConsumables ?? Array.Empty()) + .Any(active => active != null && string.Equals(active.ItemKey, itemKey, StringComparison.Ordinal))) && + AetheriaRuntimeCargoTransactions.TryFind(entity, itemKey, out _, out _, out _); + } + + private static void Activate( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeDaemonConsumableIntent? intent, + AetheriaRuntimeCatalogSnapshot? catalog, + long frameId, + int intentIndex) + { + var itemKey = intent?.ItemKey ?? ""; + if (!TryResolveActor(run, intent?.ActorEntityKey, out var zone, out var entity)) + { + Refuse(run, frameId, intentIndex, zone, entity, itemKey, "actor-unavailable"); + return; + } + + var item = catalog?.FindItem(itemKey); + if (item == null || !string.Equals(item.Category, AetheriaRuntimeItemCategories.Consumable, StringComparison.Ordinal)) + { + Refuse(run, frameId, intentIndex, zone, entity, itemKey, "not-consumable"); + return; + } + if (item.Duration <= 0) + { + Refuse(run, frameId, intentIndex, zone, entity, itemKey, "invalid-duration"); + return; + } + if (!item.Stackable && (entity.ActiveConsumables ?? Array.Empty()) + .Any(active => active != null && string.Equals(active.ItemKey, itemKey, StringComparison.Ordinal))) + { + Refuse(run, frameId, intentIndex, zone, entity, itemKey, "already-active"); + return; + } + if (!AetheriaRuntimeCargoTransactions.TryFind(entity, itemKey, out var cargoIndex, out var x, out var y) || + !AetheriaRuntimeCargoTransactions.TryRemoveQuantity(entity, cargoIndex, itemKey, x, y, 1, out var consumed)) + { + Refuse(run, frameId, intentIndex, zone, entity, itemKey, "missing-cargo"); + return; + } + + entity.ActiveConsumables = (entity.ActiveConsumables ?? Array.Empty()) + .Where(active => active != null) + .Append(new AetheriaRuntimeActiveConsumableCommit + { + ItemKey = item.ItemKey, + Quality = consumed.Item.Quality, + Duration = item.Duration, + RemainingDuration = item.Duration + }) + .ToArray(); + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:consumable:{intentIndex}:activated", + Kind = "consumable.activated", + FrameId = frameId, + ZoneIndex = zone.ZoneIndex, + SourceEntityIndex = entity.EntityIndex, + ItemKey = item.ItemKey, + ScalarValue = consumed.Item.Quality, + AuxiliaryValue = item.Duration + }); + } + + private static bool TryResolveActor( + AetheriaRuntimeRunCheckpointCommit run, + string? actorEntityKey, + out AetheriaRuntimeZoneSnapshotCommit? zone, + out AetheriaRuntimeEntitySnapshotCommit? entity) + { + AetheriaRuntimeRunCheckpointCommit.TryParseEntityKey(actorEntityKey ?? "", out var actorZoneIndex, out var actorEntityIndex); + foreach (var candidateZone in run.Zones ?? Array.Empty()) + foreach (var candidate in candidateZone?.Entities ?? Array.Empty()) + { + var canonicalKey = candidate == null + ? "" + : run.EntityRecordKey(candidateZone.ZoneIndex, candidate.EntityIndex); + if (candidate != null && + (string.Equals(canonicalKey, actorEntityKey, StringComparison.Ordinal) || + (candidateZone.ZoneIndex == actorZoneIndex && candidate.EntityIndex == actorEntityIndex))) + { + zone = candidateZone; + entity = candidate; + return true; + } + } + zone = null; + entity = null; + return false; + } + + private static void Refuse( + AetheriaRuntimeRunCheckpointCommit run, + long frameId, + int intentIndex, + AetheriaRuntimeZoneSnapshotCommit? zone, + AetheriaRuntimeEntitySnapshotCommit? entity, + string itemKey, + string reason) + { + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"frame:{frameId}:consumable:{intentIndex}:refused", + Kind = "consumable.activation.refused", + FrameId = frameId, + ZoneIndex = zone?.ZoneIndex ?? -1, + SourceEntityIndex = entity?.EntityIndex ?? -1, + ItemKey = itemKey, + Reason = reason + }); + } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs.meta new file mode 100644 index 000000000..3da1d5608 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76ddd6e0004f0a247a16d1a393d768d1 \ No newline at end of file diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index bf9701bc7..ba4e8afed 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -32,6 +32,13 @@ namespace GameCult.Aetheria.State.Verse if (worldPhysics == null) throw new ArgumentNullException(nameof(worldPhysics)); + AetheriaRuntimeConsumableSimulation.Step( + run, + intents?.Consumables ?? Enumerable.Empty(), + catalog, + frameId, + deltaSeconds); + foreach (var zone in run.Zones ?? Array.Empty()) { if (zone == null) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs index 724b3d063..7e3eb2b01 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs @@ -85,7 +85,16 @@ namespace GameCult.Aetheria.State.Verse { actions.AddRange((entity.WeaponGroups ?? Array.Empty>()).Select((_, index) => Action($"weapon-group.{index}.fire", $"Fire Weapon Group {index + 1}", "FireWeaponGroup", "weapon-group", $"{run.CurrentEntityKey}#weapon-group/{index}", ("weaponGroup", index.ToString())))); actions.AddRange((entity.Equipment ?? Array.Empty()).Where(slot => slot?.Item != null).Select((slot, index) => Action($"equipment.{index}.activate", $"Activate {slot.Item.ItemKey}", "SetBehaviorActive", "equipment", $"{run.CurrentEntityKey}#equipment/{index}", ("equipmentIndex", index.ToString()), ("behaviorIndex", "0"), ("active", "true")))); - actions.AddRange((entity.CargoContents ?? Array.Empty()).SelectMany(bay => bay.Items).Where(slot => slot?.Item != null).Select((slot, index) => Action($"cargo.{slot.Item.ItemKey}.{index}.use", $"Use {slot.Item.ItemKey}", "ActivateConsumable", "consumable", $"{run.CurrentEntityKey}#cargo/{index}", ("itemKey", slot.Item.ItemKey)))); + actions.AddRange((entity.CargoContents ?? Array.Empty()) + .SelectMany(bay => bay.Items) + .Where(slot => slot?.Item != null && AetheriaRuntimeConsumableSimulation.CanActivate(entity, catalog, slot.Item.ItemKey)) + .GroupBy(slot => slot.Item.ItemKey, StringComparer.Ordinal) + .Select((group, index) => + { + var itemKey = group.Key; + var item = catalog?.FindItem(itemKey); + return Action($"cargo.{itemKey}.use", $"Use {item?.Name ?? itemKey}", "ActivateConsumable", "consumable", $"{run.CurrentEntityKey}#cargo/{index}", ("itemKey", itemKey)); + })); actions.AddRange(TradeActions(run, entity, catalog)); } return new AetheriaRuntimeInputCapabilityDocument { Version = frame?.FrameId ?? 0, Actions = actions.ToArray(), DefaultProfiles = BuildDefaultProfiles(actions) }; diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index b67820dea..125cd5bde 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -109,6 +109,15 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. of falling through to legacy DTO classification. Slot-binding drag/drop now routes through typed daemon operations, and rebuilt current-entity binds come from typed action-bar descriptor rows instead of stale slot-local `Entity` references. + `AetheriaRuntimeConsumableSimulation` is the sole live owner of consumable + activation and lifetime. It resolves semantic activation intents against the + typed catalog and the actor's concrete cargo, atomically removes one unit, + preserves that instance's quality, applies authored stackability, and creates + the canonical active-effect row. Daemon simulation time ages and expires those + rows and publishes lifecycle/refusal facts. Input capability projection derives + only currently activatable consumable actions from this state; Unity's restored + `ConsumableItemEffect` and action-bar fill are read models and cannot consume + cargo or advance timers. Consumable activation, active-duration fill, runtime duration, and effectiveness curves now use typed item rows with neutral defaults for missing optional facets. Gear action-bar bindings read custom icon resource paths from typed item rows, then use typed weapon and hardpoint facets for diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 0bc74c85b..af0464c0d 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -30,7 +30,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | live generic Unity witness now proves advertised dock/undock actions, daemon bay assignment, dock-parent inclusion in the SoA view, sharing of the dock parent's equipment-derived contact picture, generic camera retargeting, controlled-subject hiding, movement suppression, and restoration on undock; the starter station and local raider form a deterministic Terminus departure proof | restore fossil component-prerequisite rejection reasons, exact undock placement, wormhole priority, reconnect, and docked inventory/trade surfaces | | Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection | | Trade | trade UI/economy | daemon | stock, quote, purchase commands | generic UI | daemon derives station, stock row, catalog item, current price, quantity, credits, target capacity, and hull-vs-cargo result; Eve advertises only currently available stock actions; generic Unity live witness receives an authoritative purchase receipt while paused | sell flow, quote expiry, contested stock, and full trade surface parity | -| Action bar/input | input shell and rebinding UI | daemon advertises actions; client owns bindings | input capability and action descriptors | universal binding UI | contracts wired; dynamic original behavior source absent | dynamic equipment/cargo actions | +| Action bar/input | input shell and rebinding UI | daemon advertises actions; client owns bindings | input capability and action descriptors | universal binding UI | contracts wired; cargo actions are catalog-filtered to consumables the current actor can actually activate, including suppression of active non-stackable items | dynamic equipment behavior validity and runtime action-bar quantity/fill proof | | Pilot camera | ARPG follow/dock cameras | Eve view semantics + runtime | camera rig and semantic view id | EveUnity camera rig | basic follow lowered; dock/view parity absent | authored framing and dock transition | | Render channels | Unity layers/culling masks | provider variant + runtime | semantic view id | native layer policy | specification work; incomplete uncommitted prototype | no map glyphs in pilot view | | Minimap/tactical map | `ZoneRenderer`, map cameras | daemon world facts | explicit map projections | Unity and Electron | facts fragmented; generic lowerers absent | same state in both runtimes | @@ -44,7 +44,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; shared evaluated-stat query proven; execution families remain partial | every behavior family, ordering, resource transaction and modifier lifecycle | | Sensors/visibility | installed sensor sensitivity, decaying signature sources, and observer information thresholds | daemon | contacts and ping events | indicators/maps | passive reach now derives from enabled installed Sensor behaviors with diminishing-return array composition regardless of entity kind; contact refresh no longer overwrites emitted visibility; the old station range survives only as obsolete snapshot compatibility | author station/ship sensor loadout budgets, then prove power/thermal shutdown, threshold decay, ping, and classification timelines | | AI/tasks | corporation task scheduler plus agent state machines | daemon | commander task board, corporation survey ledger, and shared semantic controls | Eve commander/pilot clients | point exploration, attack with optimum-range control, same-zone haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home wired through shared commands; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing; capability/priority assignment and rejection behavior proven; generic Electron commander lowering proves the worker roster | cross-zone logistics execution, cancellation, reconnect, and multi-client command transfer | -| Consumables | cargo-backed timed behavior containers | daemon | dynamic actions and active effects | action bar/schematic | reference | consume, stacking, duration and cleanup | +| 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, advances timers on daemon time with the fossil's exact-zero final tick, removes expired effects, and emits activation/refusal/expiry facts; Unity remains a projection | execute authored behavior payloads in fossil order, including resource failure, effectiveness curves, and modifier cleanup | ## Ownership Invariants