Restore weapon resource transactions

This commit is contained in:
Meta Vens 2026-07-11 20:45:01 +01:00
commit 91ececd147
7 changed files with 212 additions and 40 deletions

View file

@ -604,6 +604,15 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
}
}
];
agent.BehaviorStates =
[
new AetheriaRuntimeBehaviorStateCommit
{
OwnerKind = "fixture", OwnerIndex = 0, BehaviorIndex = 0,
BehaviorKind = "Capacitor", CapacitorCharge = 50, CapacitorCapacity = 50, CapacitorEfficiency = 1
}
];
agent.CargoContents = [Cargo(("test-ammo", 2, 0, 0))];
var weaponPayload = new AetheriaRuntimeBehaviorPayload(
0,
"LockWeapon",
@ -611,7 +620,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
[
new AetheriaRuntimeBehaviorField(2, PerformanceStat(7)),
new AetheriaRuntimeBehaviorField(6, PerformanceStat(145)),
new AetheriaRuntimeBehaviorField(9, PerformanceStat(5)),
new AetheriaRuntimeBehaviorField(10, PerformanceStat(3)),
new AetheriaRuntimeBehaviorField(12, new AetheriaRuntimeBehaviorValue(
"item-key", "", 0, false, "", "test-ammo",
Array.Empty<AetheriaRuntimeBehaviorValue>(), Array.Empty<AetheriaRuntimeBehaviorMapEntry>())),
new AetheriaRuntimeBehaviorField(13, Number(2)),
new AetheriaRuntimeBehaviorField(14, Number(0.3)),
new AetheriaRuntimeBehaviorField(16, PerformanceStat(330)),
new AetheriaRuntimeBehaviorField(19, PerformanceStat(0.2)),
new AetheriaRuntimeBehaviorField(21, PerformanceStat(2)),
@ -626,7 +641,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Array.Empty<AetheriaRuntimeNameFile>());
var target = Entity(1, 105, "raider");
target.Kind = "station";
target.StatGrids = [Grid("hull", 24), Grid("shield", 0), Grid("heat", 0)];
target.StatGrids = [Grid("hull", 7), Grid("shield", 0), Grid("heat", 0)];
var run = new AetheriaRuntimeRunCheckpointCommit
{
RunId = "agent-attack-smoke",
@ -695,6 +710,14 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Require((agent.WeaponStates ?? Array.Empty<AetheriaRuntimeWeaponStateCommit>()).Any(value => value.OwnerKind == AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind &&
value.OwnerIndex == 0 && value.BehaviorKind == "LockWeapon"),
"weapon progress must belong to the equipped authored behavior instead of a synthetic entity weapon");
var authoredWeapon = agent.WeaponStates.Single(value =>
value.OwnerKind == AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind);
RequireEqual(1, authoredWeapon.Ammo,
"a committed authored weapon round must decrement exactly one magazine round");
RequireNear(45, agent.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor").CapacitorCharge,
0.000001, "a committed authored weapon round must drain its energy from canonical capacitor state");
RequireEqual(2, CargoQuantity(agent, "test-ammo"),
"cargo ammunition must pay for reload, not each magazine round");
Require(!target.IsActive,
$"attack task must end through daemon damage after Ymir projectile contacts; hull={Stat(target, "hull"):0.###} " +
$"agent={agent.PositionX:0.###},{agent.PositionZ:0.###} target={target.PositionX:0.###},{target.PositionZ:0.###} " +

View file

@ -16598,6 +16598,9 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics, catalog, frameId)",
"AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)",
"EnsureWeaponState(",
"CommitWeaponRound(attacker, weapon)",
"AetheriaRuntimeCargoTransactions.TryRemoveQuantity(",
"CommitEnergy(entity, weapon.Energy)",
"SpawnProjectile(zone, attacker, target, weapon, settings)",
"projectilePhysics.Step(zone, entities, deltaSeconds)",
"Damage(target, hit.Projectile.Damage)"

View file

@ -55,6 +55,8 @@ namespace GameCult.Aetheria.State.Verse
Stat("Energy", 9),
Stat("Heat", 10),
Stat("Visibility", 11),
Integer("MagazineSize", 13),
Number("ReloadTime", 14),
Stat("Spread", 15),
Stat("Velocity", 16)
};
@ -154,6 +156,11 @@ namespace GameCult.Aetheria.State.Verse
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Number);
}
private static AetheriaRuntimeBehaviorFieldMetadata Integer(string name, int key)
{
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Integer);
}
private static AetheriaRuntimeBehaviorFieldMetadata Temperature(string name, int key)
{
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Temperature);

View file

@ -0,0 +1,68 @@
using System;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimeCargoTransactions
{
public static bool TryRemoveQuantity(
AetheriaRuntimeEntitySnapshotCommit entity,
int cargoIndex,
string itemKey,
int x,
int y,
int quantity,
out AetheriaRuntimeLoadoutItemSlotCommit slot)
{
slot = null!;
var cargoContents = (entity?.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>()).ToArray();
if (cargoIndex < 0 || cargoIndex >= cargoContents.Length || cargoContents[cargoIndex] == null)
return false;
var items = (cargoContents[cargoIndex].Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
var itemIndex = items.FindIndex(candidate => candidate?.Item != null &&
string.Equals(candidate.Item.ItemKey, itemKey, StringComparison.Ordinal) &&
candidate.X == x && candidate.Y == y);
if (itemIndex < 0) return false;
var source = items[itemIndex];
var sourceQuantity = Math.Max(1, source.Item.Quantity);
if (quantity <= 0 || quantity > sourceQuantity) return false;
slot = Clone(source, quantity);
if (quantity == sourceQuantity) items.RemoveAt(itemIndex);
else source.Item.Quantity = sourceQuantity - quantity;
cargoContents[cargoIndex] = new AetheriaRuntimeCargoBayLoadoutCommit { Items = items.ToArray() };
entity!.CargoContents = cargoContents;
return true;
}
public static bool TryFind(
AetheriaRuntimeEntitySnapshotCommit entity,
string itemKey,
out int cargoIndex,
out int x,
out int y)
{
var cargo = entity?.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
for (var bay = 0; bay < cargo.Count; bay++)
foreach (var slot in cargo[bay]?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
{
if (slot?.Item == null || !string.Equals(slot.Item.ItemKey, itemKey, StringComparison.Ordinal)) continue;
cargoIndex = bay; x = slot.X; y = slot.Y; return true;
}
cargoIndex = x = y = -1;
return false;
}
private static AetheriaRuntimeLoadoutItemSlotCommit Clone(AetheriaRuntimeLoadoutItemSlotCommit source, int quantity) => new()
{
X = source.X, Y = source.Y,
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = source.Item.ItemKey, Quality = source.Item.Quality, Durability = source.Item.Durability,
Quantity = quantity, Enabled = source.Item.Enabled, OverrideShutdown = source.Item.OverrideShutdown,
Temperature = source.Item.Temperature
}
};
}
}

View file

@ -1858,38 +1858,8 @@ namespace GameCult.Aetheria.State.Verse
int quantity,
out AetheriaRuntimeLoadoutItemSlotCommit slot)
{
slot = null!;
var cargoContents = (entity.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>()).ToArray();
if (cargoIndex < 0 || cargoIndex >= cargoContents.Length || cargoContents[cargoIndex] == null)
return false;
var items = (cargoContents[cargoIndex].Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
var itemIndex = items.FindIndex(candidate => IsCargoSlotMatch(candidate, itemKey, x, y));
if (itemIndex < 0)
return false;
var source = items[itemIndex];
var sourceQuantity = Math.Max(1, source.Item?.Quantity ?? 1);
if (quantity <= 0 || quantity > sourceQuantity)
return false;
slot = CloneSlot(source);
slot.Item.Quantity = quantity;
if (quantity == sourceQuantity)
{
items.RemoveAt(itemIndex);
}
else
{
source.Item.Quantity = sourceQuantity - quantity;
}
cargoContents[cargoIndex] = new AetheriaRuntimeCargoBayLoadoutCommit
{
Items = items.ToArray()
};
entity.CargoContents = cargoContents;
return true;
return AetheriaRuntimeCargoTransactions.TryRemoveQuantity(
entity, cargoIndex, itemKey, x, y, quantity, out slot);
}
private static void AddCargoItem(

View file

@ -244,6 +244,15 @@ namespace GameCult.Aetheria.State.Verse
foreach (var weapon in weapons)
{
weapon.State.Firing = false;
if (weapon.State.Reloading)
{
weapon.State.ReloadProgress = Math.Max(0, weapon.State.ReloadProgress - deltaSeconds);
if (weapon.State.ReloadProgress <= 0)
{
weapon.State.Reloading = false;
weapon.State.Ammo = weapon.MagazineSize;
}
}
weapon.State.CooldownProgress = Math.Max(0, weapon.State.CooldownProgress - deltaSeconds);
weapon.State.CoolingDown = weapon.State.CooldownProgress > 0;
}
@ -270,9 +279,12 @@ namespace GameCult.Aetheria.State.Verse
UpdateWeaponLock(attacker, target, weapon, deltaSeconds, settings);
if (DistanceSq(attacker, target) > weapon.Range * weapon.Range ||
weapon.State.LockProgress <= 0.99 ||
weapon.State.CooldownProgress > 0)
weapon.State.CooldownProgress > 0 ||
weapon.State.Reloading)
continue;
if (CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired)
continue;
var projectile = SpawnProjectile(zone, attacker, target, weapon, settings);
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"projectile:{projectile.ProjectileId}:launched", Kind = "projectile.launched", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = projectile.ProjectileId, ItemKey = projectile.WeaponKind, ScalarValue = projectile.Damage, PositionX = projectile.PositionX, PositionZ = projectile.PositionZ });
weapon.State.Firing = true;
@ -448,7 +460,8 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeDaemonSimulationSettings settings)
{
var state = EnsureWeaponState(entity, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind,
behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind, settings);
behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind,
Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))), settings);
return new ResolvedWeapon(
state,
behavior.Item.ItemKey,
@ -457,6 +470,10 @@ namespace GameCult.Aetheria.State.Verse
PositiveOr(behavior.EvaluateStat(19), settings.WeaponCooldownSeconds),
PositiveOr(behavior.EvaluateStat(16), settings.ProjectileSpeed),
Math.Max(0, behavior.EvaluateStat(10)),
Math.Max(0, behavior.EvaluateStat(9)),
ReadItemKey(behavior.Payload, 12),
Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))),
PositiveOr(ReadNumber(behavior.Payload, 14), settings.WeaponCooldownSeconds),
PositiveOr(behavior.EvaluateStat(21), settings.WeaponLockSpeed),
Math.Max(0, behavior.EvaluateStat(22)),
PositiveOr(behavior.EvaluateStat(23), settings.WeaponLockAngleDegrees),
@ -468,11 +485,12 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var state = EnsureWeaponState(entity, "daemon-simulation", entity.EntityIndex, 0, "ProjectileWeapon", settings);
var state = EnsureWeaponState(entity, "daemon-simulation", entity.EntityIndex, 0, "ProjectileWeapon", -1, settings);
return new ResolvedWeapon(state,
IsPlayerOwned(entity) ? "vanguard-bolt" : "raider-bolt",
ResolveProjectileDamage(entity, settings), settings.AttackRange, settings.WeaponCooldownSeconds,
settings.ProjectileSpeed, ResolveProjectileDamage(entity, settings) * settings.ProjectileHeatScale,
0, "", -1, settings.WeaponCooldownSeconds,
settings.WeaponLockSpeed, settings.WeaponLockSensorImpact, settings.WeaponLockAngleDegrees,
settings.WeaponLockDirectionImpact, settings.WeaponLockDecayPerSecond);
}
@ -483,6 +501,7 @@ namespace GameCult.Aetheria.State.Verse
int ownerIndex,
int behaviorIndex,
string behaviorKind,
int initialAmmo,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var states = (entity.WeaponStates ?? Array.Empty<AetheriaRuntimeWeaponStateCommit>()).ToList();
@ -500,7 +519,7 @@ namespace GameCult.Aetheria.State.Verse
OwnerIndex = ownerIndex,
BehaviorIndex = behaviorIndex,
BehaviorKind = behaviorKind,
Ammo = -1,
Ammo = initialAmmo,
BurstRemaining = 0,
BurstInterval = settings.WeaponCooldownSeconds,
LockTargetEntityIndex = -1
@ -571,14 +590,92 @@ namespace GameCult.Aetheria.State.Verse
private static double PositiveOr(double value, double fallback) =>
double.IsFinite(value) && value > 0 ? value : fallback;
private static string ReadItemKey(AetheriaRuntimeBehaviorPayload payload, int key)
{
var value = (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value;
return value?.ItemKeyValue ?? value?.StringValue ?? "";
}
private static double ReadNumber(AetheriaRuntimeBehaviorPayload payload, int key)
{
return (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value?.NumberValue ?? 0;
}
private static WeaponRoundResult CommitWeaponRound(
AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedWeapon weapon)
{
if (weapon.MagazineSize > 1 && weapon.State.Ammo <= 0)
{
if (!string.IsNullOrWhiteSpace(weapon.AmmoItemKey))
{
if (!AetheriaRuntimeCargoTransactions.TryFind(entity, weapon.AmmoItemKey,
out var cargoIndex, out var x, out var y) ||
!AetheriaRuntimeCargoTransactions.TryRemoveQuantity(
entity, cargoIndex, weapon.AmmoItemKey, x, y, 1, out _))
return WeaponRoundResult.NoAmmo;
}
weapon.State.Reloading = true;
weapon.State.ReloadProgress = weapon.ReloadTime;
return WeaponRoundResult.ReloadStarted;
}
if (!CanSupplyEnergy(entity, weapon.Energy))
return WeaponRoundResult.InsufficientEnergy;
CommitEnergy(entity, weapon.Energy);
if (weapon.MagazineSize > 1)
weapon.State.Ammo--;
return WeaponRoundResult.Fired;
}
private static bool CanSupplyEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand)
{
if (demand < 0.01) return true;
var states = entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>();
var charge = states.Where(value => value != null && value.BehaviorKind == "Capacitor")
.Sum(value => Math.Max(0, value.CapacitorCharge));
return charge >= demand || states.Any(value => value != null && value.BehaviorKind == "Reactor");
}
private static void CommitEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand)
{
if (demand < 0.01) return;
var capacitors = (entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>())
.Where(value => value != null && value.BehaviorKind == "Capacitor" && value.CapacitorCharge > 0)
.ToList();
var remaining = demand;
while (remaining > 0.000001 && capacitors.Count > 0)
{
var share = remaining / capacitors.Count;
foreach (var capacitor in capacitors.ToArray())
{
var drained = Math.Min(share, capacitor.CapacitorCharge);
capacitor.CapacitorCharge -= drained;
remaining -= drained;
if (capacitor.CapacitorCharge <= 0.000001) capacitors.Remove(capacitor);
}
}
var reactors = (entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>())
.Where(value => value != null && value.BehaviorKind == "Reactor")
.ToArray();
if (remaining > 0.000001 && reactors.Length > 0)
foreach (var reactor in reactors) reactor.ReactorDraw += remaining / reactors.Length;
}
private enum WeaponRoundResult { Fired, ReloadStarted, InsufficientEnergy, NoAmmo }
private sealed class ResolvedWeapon
{
public ResolvedWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey, double damage, double range,
double cooldown, double projectileSpeed, double heat, double lockSpeed, double lockSensorImpact,
double cooldown, double projectileSpeed, double heat, double energy, string ammoItemKey,
int magazineSize, double reloadTime, double lockSpeed, double lockSensorImpact,
double lockAngleDegrees, double lockDirectionImpact, double lockDecayPerSecond)
{
State = state; ItemKey = itemKey; Damage = damage; Range = range; Cooldown = cooldown;
ProjectileSpeed = projectileSpeed; Heat = heat; LockSpeed = lockSpeed;
ProjectileSpeed = projectileSpeed; Heat = heat; Energy = energy; AmmoItemKey = ammoItemKey;
MagazineSize = magazineSize; ReloadTime = reloadTime; LockSpeed = lockSpeed;
LockSensorImpact = lockSensorImpact; LockAngleDegrees = lockAngleDegrees;
LockDirectionImpact = lockDirectionImpact; LockDecayPerSecond = lockDecayPerSecond;
}
@ -590,6 +687,10 @@ namespace GameCult.Aetheria.State.Verse
public double Cooldown { get; }
public double ProjectileSpeed { get; }
public double Heat { get; }
public double Energy { get; }
public string AmmoItemKey { get; }
public int MagazineSize { get; }
public double ReloadTime { get; }
public double LockSpeed { get; }
public double LockSensorImpact { get; }
public double LockAngleDegrees { get; }

View file

@ -24,7 +24,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
| Projectile flight | weapon/projectile classes | Ymir | projectile entity/event projection | native projectile visual | Ymir.Core adapter wired for spawn/travel/contact/despawn; native presentation and broader weapon parity incomplete | prove every authored projectile family and runtime visual lifecycle |
| Shield/hull damage | energy-funded shield, armor cells, equipment and hull | daemon | entity status + damage events | meters, impacts, damage VFX | synthetic scalar owner wired; not parity | typed absorption, penetration, item damage and death causes |
| Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment status and warnings | schematic/post effects | cell conduction/radiation wired; energy network and medical thresholds remain reference | capacitor/reactor/radiator ordering, shared energy and medical timelines |
| Weapons | ServerShared behaviors + Unity effects | daemon + Ymir where spatial | action capability, weapon state, attack events | effects/audio/HUD | selected weapon groups now resolve equipped typed-catalog behaviors for damage, range, cooldown, projectile velocity, heat and lock parameters; temporal state belongs to the equipped behavior; Ymir owns flight/contact and daemon owns damage; catalog-free fixtures retain an explicit synthetic fallback | energy/ammunition/burst/resource transactions and each remaining weapon family |
| Weapons | ServerShared behaviors + Unity effects | daemon + Ymir where spatial | action capability, weapon state, attack events | effects/audio/HUD | selected weapon groups resolve equipped typed-catalog behaviors for damage, range, cooldown, projectile velocity, heat, lock, energy, magazine and reload parameters; one committed round atomically validates ammo and energy, drains canonical capacitor/reactor state, decrements the magazine, then launches through Ymir and adds heat; empty magazines consume reserve cargo through the shared cargo transaction and enter reload without firing; temporal state belongs to the equipped behavior; catalog-free fixtures retain an explicit synthetic fallback | burst timing/single-ammo-burst semantics, reload boundary timeline, visibility/wear, and each remaining weapon family |
| Destruction/drops | entity death and pickup paths | daemon | death/drop events and world entities | effects and pickups | fragments wired; chronology and original drop transaction absent | exactly-once drops and cleanup |
| 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 |
| Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon path wired/projected; Ymir placement and parity unproven | priority, eligibility, placement and reconnect |