From c31aabe88e95119b1313383d3cdcdbfb79150a3c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 20:47:58 +0100 Subject: [PATCH] Restore instant weapon burst timing --- Aetheria.State.Daemon.Smoke/Program.cs | 14 +++-- Aetheria.State.Verify/Program.cs | 3 + .../AetheriaRuntimeBehaviorMetadata.cs | 8 ++- .../AetheriaRuntimeDaemonSimulation.cs | 61 +++++++++++++++---- docs/original-game-migration-matrix.md | 2 +- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 01d508530..ee51838da 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -625,9 +625,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(12, new AetheriaRuntimeBehaviorValue( "item-key", "", 0, false, "", "test-ammo", Array.Empty(), Array.Empty())), - 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()).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.###} " + diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 497607edd..dbc8482e8 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -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)", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs index 717845059..0d647f25a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs @@ -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); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 5c6d90f15..e71895939 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -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()) + .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; } diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 8c1b79a5a..ba011da13 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -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 |