From 9d55ab67ffc014df758b26b8753bbc63b74e40a2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 20:50:09 +0100 Subject: [PATCH] Publish authoritative weapon reloads --- Aetheria.State.Daemon.Smoke/Program.cs | 28 +++++++----- Aetheria.State.Verify/Program.cs | 2 + .../AetheriaRuntimeDaemonSimulation.cs | 43 ++++++++++++++++--- docs/original-game-migration-matrix.md | 2 +- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index ee51838da..235c90303 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -625,7 +625,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(12, new AetheriaRuntimeBehaviorValue( "item-key", "", 0, false, "", "test-ammo", Array.Empty(), Array.Empty())), - new AetheriaRuntimeBehaviorField(13, Number(6)), + new AetheriaRuntimeBehaviorField(13, Number(3)), new AetheriaRuntimeBehaviorField(14, Number(0.3)), new AetheriaRuntimeBehaviorField(16, PerformanceStat(330)), new AetheriaRuntimeBehaviorField(17, PerformanceStat(3)), @@ -643,7 +643,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Array.Empty()); var target = Entity(1, 105, "raider"); target.Kind = "station"; - target.StatGrids = [Grid("hull", 7), Grid("shield", 0), Grid("heat", 0)]; + target.StatGrids = [Grid("hull", 14), Grid("shield", 0), Grid("heat", 0)]; var run = new AetheriaRuntimeRunCheckpointCommit { RunId = "agent-attack-smoke", @@ -714,14 +714,22 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "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(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"); + RequireEqual(0, authoredWeapon.Ammo, + "two three-round authored bursts must consume both complete magazines"); + RequireNear(40, agent.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor").CapacitorCharge, + 0.000001, "two authored bursts must drain exactly their combined energy from canonical capacitor state"); + RequireEqual(1, CargoQuantity(agent, "test-ammo"), + "one empty magazine must consume exactly one reserve cargo commodity before reload"); + Require(run.GameEvents.Count(value => value.Kind == "projectile.launched" && value.ItemKey == "test-lock-cannon") >= 6, + "two authored bursts must emit all six due rounds through the projectile owner"); + Require(run.GameEvents.Count(value => value.Kind == "weapon.reload.started") == 1 && + run.GameEvents.Count(value => value.Kind == "weapon.reload.completed") == 1, + "empty magazine transition must publish one authoritative reload start and completion pair"); + var reloadStarted = run.GameEvents.Single(value => value.Kind == "weapon.reload.started").FrameId; + var reloadCompleted = run.GameEvents.Single(value => value.Kind == "weapon.reload.completed").FrameId; + Require(reloadCompleted > reloadStarted && !run.GameEvents.Any(value => + value.Kind == "projectile.launched" && value.FrameId > reloadStarted && value.FrameId < reloadCompleted), + "reload interval must be a projectile-free authoritative timeline, not a cosmetic client delay"); 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 dbc8482e8..e76deeece 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16602,6 +16602,8 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "weapon.State.BurstRemaining = weapon.BurstCount", "weapon.State.BurstTimer += deltaSeconds", "weapon.SingleAmmoBurst", + "weapon.reload.started", + "weapon.reload.completed", "AetheriaRuntimeCargoTransactions.TryRemoveQuantity(", "CommitEnergy(entity, weapon.Energy)", "SpawnProjectile(zone, attacker, target, weapon, settings)", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index e71895939..3c83b385d 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -251,6 +251,7 @@ namespace GameCult.Aetheria.State.Verse { weapon.State.Reloading = false; weapon.State.Ammo = weapon.MagazineSize; + AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.completed"); } } weapon.State.CooldownProgress = Math.Max(0, weapon.State.CooldownProgress - deltaSeconds); @@ -286,8 +287,13 @@ namespace GameCult.Aetheria.State.Verse { if (weapon.State.CooldownProgress > 0) continue; - if (weapon.SingleAmmoBurst && CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired) - continue; + if (weapon.SingleAmmoBurst) + { + var triggerResult = CommitWeaponRound(attacker, weapon); + if (triggerResult == WeaponRoundResult.ReloadStarted) + AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started"); + if (triggerResult != WeaponRoundResult.Fired) continue; + } weapon.State.BurstRemaining = weapon.BurstCount; weapon.State.BurstInterval = weapon.BurstTime / weapon.BurstCount; weapon.State.BurstTimer = 0; @@ -298,10 +304,16 @@ namespace GameCult.Aetheria.State.Verse weapon.State.BurstTimer += deltaSeconds; while (weapon.State.BurstRemaining > 0 && weapon.State.BurstTimer > 0) { - if (!weapon.SingleAmmoBurst && CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired) + if (!weapon.SingleAmmoBurst) { - weapon.State.BurstRemaining = 0; - break; + var roundResult = CommitWeaponRound(attacker, weapon); + if (roundResult == WeaponRoundResult.ReloadStarted) + AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started"); + if (roundResult != WeaponRoundResult.Fired) + { + weapon.State.BurstRemaining = 0; + break; + } } weapon.State.BurstRemaining--; weapon.State.BurstTimer -= weapon.State.BurstInterval; @@ -346,6 +358,27 @@ namespace GameCult.Aetheria.State.Verse } } + private static void AppendWeaponEvent( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit entity, + ResolvedWeapon weapon, + long frameId, + string kind) + { + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:weapon:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{kind}", + Kind = kind, + FrameId = frameId, + ZoneIndex = zone.ZoneIndex, + SourceEntityIndex = entity.EntityIndex, + SubjectKey = weapon.ItemKey, + ItemKey = weapon.ItemKey, + ScalarValue = weapon.State.Ammo + }); + } + private static void UpdateWeaponLock( AetheriaRuntimeEntitySnapshotCommit attacker, AetheriaRuntimeEntitySnapshotCommit target, diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index ba011da13..300393373 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -36,7 +36,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | 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 | | Fields/fog/gravity | zone renderer and shaders | daemon/plugin semantics | Fields plugin documents | EveUnity fields lowerer | owner-exists; basic lowering; composition parity absent | visual parity captures | | HUD/schematic | ARPG HUD and `SchematicDisplay` | Eve composition from daemon facts | semantic status/components | EveUnity UI | reference | source-fact and timing matrix | -| Hit/lock/warnings | `ActionGameManager` feedback | daemon event facts | transient feedback stream | VFX/UI/audio | bounded deduplicated game-event ledger and Eve `feedback.stream` wired for pickup outcomes plus projectile launch/impact, entity damage and alive-to-dead destruction chronology | add lock transitions, thermal and docking event families with runtime effect consumption | +| Hit/lock/warnings | `ActionGameManager` feedback | daemon event facts | transient feedback stream | VFX/UI/audio | bounded deduplicated game-event ledger and Eve `feedback.stream` wired for pickup outcomes, projectile launch/impact, entity damage, alive-to-dead destruction, and authoritative weapon reload start/completion chronology; reload interval is proven projectile-free | add lock transitions, resource refusals, thermal and docking event families with runtime effect consumption | | Assets | `Assets/Resources` | Aetheria provider | asset catalog + CultMesh CDN | runtime cache/load | wired; basic lowering; not live-proven remotely | cold/warm remote-only live load and cache proof | | Save/resume | original persistence paths | daemon/CultCache | published current state | clients rehydrate | owner-exists; not parity-proven | mid-loop restart | | Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | incomplete; no scenario proof | fly/fire/loot/dock/trade |