Restore instant weapon burst timing

This commit is contained in:
Meta Vens 2026-07-11 20:47:58 +01:00
commit c31aabe88e
5 changed files with 68 additions and 20 deletions

View file

@ -625,9 +625,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
new AetheriaRuntimeBehaviorField(12, new AetheriaRuntimeBehaviorValue(
"item-key", "", 0, false, "", "test-ammo",
Array.Empty<AetheriaRuntimeBehaviorValue>(), Array.Empty<AetheriaRuntimeBehaviorMapEntry>())),
new AetheriaRuntimeBehaviorField(13, Number(2)),
new AetheriaRuntimeBehaviorField(13, Number(6)),
new AetheriaRuntimeBehaviorField(14, Number(0.3)),
new AetheriaRuntimeBehaviorField(16, PerformanceStat(330)),
new AetheriaRuntimeBehaviorField(17, PerformanceStat(3)),
new AetheriaRuntimeBehaviorField(18, PerformanceStat(0.3)),
new AetheriaRuntimeBehaviorField(19, PerformanceStat(0.2)),
new AetheriaRuntimeBehaviorField(21, PerformanceStat(2)),
new AetheriaRuntimeBehaviorField(22, PerformanceStat(1)),
@ -705,19 +707,21 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Require(run.GameEvents.Any(value => value.Kind == "projectile.launched" && value.SourceEntityIndex == agent.EntityIndex),
"accepted fire control must emit authoritative projectile launch chronology");
Require(run.GameEvents.Any(value => value.Kind == "projectile.launched" &&
value.ItemKey == "test-lock-cannon" && Math.Abs(value.ScalarValue - 7) < 0.000001),
"daemon combat must launch the equipped catalog weapon with its authored damage");
value.ItemKey == "test-lock-cannon" && Math.Abs(value.ScalarValue - (7.0 / 3.0)) < 0.000001),
"daemon combat must divide authored damage across the configured burst rounds");
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");
RequireEqual(3, authoredWeapon.Ammo,
"a three-round authored burst must decrement one magazine round per committed projectile");
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(run.GameEvents.Count(value => value.Kind == "projectile.launched" && value.ItemKey == "test-lock-cannon") >= 3,
"authored burst count and interval must emit every due round through the projectile owner");
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

@ -16599,6 +16599,9 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)",
"EnsureWeaponState(",
"CommitWeaponRound(attacker, weapon)",
"weapon.State.BurstRemaining = weapon.BurstCount",
"weapon.State.BurstTimer += deltaSeconds",
"weapon.SingleAmmoBurst",
"AetheriaRuntimeCargoTransactions.TryRemoveQuantity(",
"CommitEnergy(entity, weapon.Energy)",
"SpawnProjectile(zone, attacker, target, weapon, settings)",

View file

@ -8,6 +8,7 @@ namespace GameCult.Aetheria.State.Verse
{
Number,
Integer,
Boolean,
PerformanceStat,
Temperature
}
@ -62,7 +63,7 @@ namespace GameCult.Aetheria.State.Verse
};
private static readonly AetheriaRuntimeBehaviorFieldMetadata[] InstantWeaponFields =
With(WeaponFields, Stat("BurstCount", 17), Stat("BurstTime", 18), Stat("Cooldown", 19));
With(WeaponFields, Stat("BurstCount", 17), Stat("BurstTime", 18), Stat("Cooldown", 19), Boolean("SingleAmmoBurst", 20));
private static readonly AetheriaRuntimeBehaviorFieldMetadata[] LockWeaponFields =
With(InstantWeaponFields, Stat("LockSpeed", 21), Stat("SensorImpact", 22), Stat("LockAngle", 23),
@ -161,6 +162,11 @@ namespace GameCult.Aetheria.State.Verse
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Integer);
}
private static AetheriaRuntimeBehaviorFieldMetadata Boolean(string name, int key)
{
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Boolean);
}
private static AetheriaRuntimeBehaviorFieldMetadata Temperature(string name, int key)
{
return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Temperature);

View file

@ -279,18 +279,37 @@ 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.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;
weapon.State.CoolingDown = true;
weapon.State.CooldownProgress = weapon.Cooldown;
AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat);
if (weapon.State.BurstRemaining <= 0)
{
if (weapon.State.CooldownProgress > 0)
continue;
if (weapon.SingleAmmoBurst && CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired)
continue;
weapon.State.BurstRemaining = weapon.BurstCount;
weapon.State.BurstInterval = weapon.BurstTime / weapon.BurstCount;
weapon.State.BurstTimer = 0;
weapon.State.CoolingDown = true;
weapon.State.CooldownProgress = weapon.Cooldown;
}
weapon.State.BurstTimer += deltaSeconds;
while (weapon.State.BurstRemaining > 0 && weapon.State.BurstTimer > 0)
{
if (!weapon.SingleAmmoBurst && CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired)
{
weapon.State.BurstRemaining = 0;
break;
}
weapon.State.BurstRemaining--;
weapon.State.BurstTimer -= weapon.State.BurstInterval;
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;
AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat);
}
}
}
@ -465,15 +484,19 @@ namespace GameCult.Aetheria.State.Verse
return new ResolvedWeapon(
state,
behavior.Item.ItemKey,
PositiveOr(behavior.EvaluateStat(2), ResolveProjectileDamage(entity, settings)),
PositiveOr(behavior.EvaluateStat(2), ResolveProjectileDamage(entity, settings)) /
Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
PositiveOr(behavior.EvaluateStat(6), settings.AttackRange),
PositiveOr(behavior.EvaluateStat(19), settings.WeaponCooldownSeconds),
PositiveOr(behavior.EvaluateStat(16), settings.ProjectileSpeed),
Math.Max(0, behavior.EvaluateStat(10)),
Math.Max(0, behavior.EvaluateStat(9)),
Math.Max(0, behavior.EvaluateStat(10)) / Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
Math.Max(0, behavior.EvaluateStat(9)) / Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
ReadItemKey(behavior.Payload, 12),
Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))),
PositiveOr(ReadNumber(behavior.Payload, 14), settings.WeaponCooldownSeconds),
Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
Math.Max(0, behavior.EvaluateStat(18)),
ReadBool(behavior.Payload, 20),
PositiveOr(behavior.EvaluateStat(21), settings.WeaponLockSpeed),
Math.Max(0, behavior.EvaluateStat(22)),
PositiveOr(behavior.EvaluateStat(23), settings.WeaponLockAngleDegrees),
@ -491,6 +514,7 @@ namespace GameCult.Aetheria.State.Verse
ResolveProjectileDamage(entity, settings), settings.AttackRange, settings.WeaponCooldownSeconds,
settings.ProjectileSpeed, ResolveProjectileDamage(entity, settings) * settings.ProjectileHeatScale,
0, "", -1, settings.WeaponCooldownSeconds,
1, 0, false,
settings.WeaponLockSpeed, settings.WeaponLockSensorImpact, settings.WeaponLockAngleDegrees,
settings.WeaponLockDirectionImpact, settings.WeaponLockDecayPerSecond);
}
@ -603,6 +627,12 @@ namespace GameCult.Aetheria.State.Verse
.FirstOrDefault(field => field != null && field.Key == key)?.Value?.NumberValue ?? 0;
}
private static bool ReadBool(AetheriaRuntimeBehaviorPayload payload, int key)
{
return (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value?.BoolValue ?? false;
}
private static WeaponRoundResult CommitWeaponRound(
AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedWeapon weapon)
@ -670,12 +700,14 @@ namespace GameCult.Aetheria.State.Verse
{
public ResolvedWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey, double damage, double range,
double cooldown, double projectileSpeed, double heat, double energy, string ammoItemKey,
int magazineSize, double reloadTime, double lockSpeed, double lockSensorImpact,
int magazineSize, double reloadTime, int burstCount, double burstTime, bool singleAmmoBurst,
double lockSpeed, double lockSensorImpact,
double lockAngleDegrees, double lockDirectionImpact, double lockDecayPerSecond)
{
State = state; ItemKey = itemKey; Damage = damage; Range = range; Cooldown = cooldown;
ProjectileSpeed = projectileSpeed; Heat = heat; Energy = energy; AmmoItemKey = ammoItemKey;
MagazineSize = magazineSize; ReloadTime = reloadTime; LockSpeed = lockSpeed;
BurstCount = burstCount; BurstTime = burstTime; SingleAmmoBurst = singleAmmoBurst;
LockSensorImpact = lockSensorImpact; LockAngleDegrees = lockAngleDegrees;
LockDirectionImpact = lockDirectionImpact; LockDecayPerSecond = lockDecayPerSecond;
}
@ -691,6 +723,9 @@ namespace GameCult.Aetheria.State.Verse
public string AmmoItemKey { get; }
public int MagazineSize { get; }
public double ReloadTime { get; }
public int BurstCount { get; }
public double BurstTime { get; }
public bool SingleAmmoBurst { 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 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 |
| 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, reload, burst count/time and single-ammo-burst parameters; persistent burst timer/remaining state emits every due round while cooldown overlaps from trigger time; damage/heat/energy divide across rounds and resource payment occurs once per burst or once per round as authored; each committed transaction drains canonical capacitor/reactor state and magazine before Ymir launch; empty magazines consume reserve cargo through the shared cargo transaction and enter reload without firing; catalog-free fixtures retain an explicit synthetic fallback | strict reload/cooldown 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 |