Restore daemon-owned consumable lifecycle

This commit is contained in:
Meta Vens 2026-07-13 15:36:50 +01:00
commit c4ae06d9cf
7 changed files with 332 additions and 7 deletions

View file

@ -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<AetheriaRuntimeBehaviorPayload>(), category: AetheriaRuntimeItemCategories.Consumable, stackable: false, duration: 2) },
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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<AetheriaRuntimeDaemonConsumableIntent>(), 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<AetheriaRuntimeDaemonConsumableIntent>(), 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<AetheriaRuntimeBehaviorPayload>(), category: AetheriaRuntimeItemCategories.Consumable, stackable: false, duration: 2) },
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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<AetheriaRuntimeBehaviorPayload>(), category: AetheriaRuntimeItemCategories.Consumable, duration: 2),
CatalogItem("ore", Array.Empty<AetheriaRuntimeBehaviorPayload>(), category: AetheriaRuntimeItemCategories.SimpleCommodity)
},
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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<AetheriaRuntimeBehaviorPayload> 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,
"",
"",

View file

@ -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<AetheriaRuntimeDaemonConsumableIntent> intents,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId,
double deltaSeconds)
{
if (run == null || deltaSeconds <= 0)
return;
var intentIndex = 0;
foreach (var intent in intents ?? Enumerable.Empty<AetheriaRuntimeDaemonConsumableIntent>())
{
Activate(run, intent, catalog, frameId, intentIndex);
intentIndex++;
}
foreach (var zone in run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
foreach (var entity in zone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
if (entity == null)
continue;
var active = (entity.ActiveConsumables ?? Array.Empty<AetheriaRuntimeActiveConsumableCommit>())
.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<AetheriaRuntimeActiveConsumableCommit>())
.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<AetheriaRuntimeActiveConsumableCommit>())
.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<AetheriaRuntimeActiveConsumableCommit>())
.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<AetheriaRuntimeZoneSnapshotCommit>())
foreach (var candidate in candidateZone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
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
});
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 76ddd6e0004f0a247a16d1a393d768d1

View file

@ -32,6 +32,13 @@ namespace GameCult.Aetheria.State.Verse
if (worldPhysics == null)
throw new ArgumentNullException(nameof(worldPhysics));
AetheriaRuntimeConsumableSimulation.Step(
run,
intents?.Consumables ?? Enumerable.Empty<AetheriaRuntimeDaemonConsumableIntent>(),
catalog,
frameId,
deltaSeconds);
foreach (var zone in run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
{
if (zone == null)

View file

@ -85,7 +85,16 @@ namespace GameCult.Aetheria.State.Verse
{
actions.AddRange((entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>()).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<AetheriaRuntimeLoadoutItemSlotCommit>()).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<AetheriaRuntimeCargoBayLoadoutCommit>()).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<AetheriaRuntimeCargoBayLoadoutCommit>())
.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) };

View file

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

View file

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