From a19d0eb73bfb43f7c9444f55cc4f08cb9d1b7bf1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:17:23 +0200 Subject: [PATCH 01/36] Arena: sim-rate map and target section 7 (sim rate cut, step part of A-pure) Co-Authored-By: Claude Sonnet 5.5 --- docs/aetheria-arena-sim-rate-map.md | 200 ++++++++++++++++++++++++++++ docs/aetheria-arena-target.md | 16 ++- 2 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 docs/aetheria-arena-sim-rate-map.md diff --git a/docs/aetheria-arena-sim-rate-map.md b/docs/aetheria-arena-sim-rate-map.md new file mode 100644 index 000000000..11646654a --- /dev/null +++ b/docs/aetheria-arena-sim-rate-map.md @@ -0,0 +1,200 @@ +# Aetheria arena, cut sim-rate: one game tick, lowered for game and arena alike + +Date: 2026-10-22. Status: map, drafted by Imagination for Self to commit beside `docs/aetheria-arena-target.md`. +Body pinned at `GameCult/Aetheria` origin/master `0267a191` (view-seam merged). Every claim below was probed on that tree. +Spec: `aetheria-arena:cut_spec:cut-sim-rate.r1`. Question: `aetheria-arena:question:sim-tick-rate`. + +## 1. What the operator said + +On 2026-10-21, while we were talking about Colosseum throughput (session transcript `53e9c70e`, lines 4811-4879): + +- "So we can expect 160x realtime for the Colosseum? That's not bad, we can gather a few thousand matches per hour per + thread on Yggdrasil, then?" +- "I think we can fudge the time step just a little, as long as there's enough resolution for all the relevant decisions" +- "Projectiles are roll based, their flight is pure presentation, I made that decision partly with this in mind" +- "Physics definitely doesn't need 60, not at the pace we're setting. I don't think it even needs 30." +- Answering Self's proposal (lower the whole game's step, chosen by a timer check, an equivalence test and her playtest, + landing before the runner): "Go for it." + +That is ruling `aetheria-arena:ruling:lower-game-sim-rate`. Her words decide two things: physics needs less than 60 Hz, +and probably less than 30. The proposal she approved decides the rest: the game's own rate is lowered (`A-one-sim` holds +unchanged), and the rate is chosen by the timer check, the equivalence test and her playtest. + +Rulings already in force on the same clock (2026-10-07, campaign `aetheria-release`): +- `sim-speed-knob`: the sim's own clock owns pace; Unity's time scale is not touched. +- `sim-speed-surface`: a design speed in game data times the player's accessibility slow-down. +- `sim-speed-tenth`: the design speed will be about a tenth of today's; the tick rate stays hidden; the player's ship may be + extrapolated. +- `tick-rate-follows-design-speed`: the step follows the design speed, so the real-time tick rate is a chosen constant. + "6hz is the kind of rate players notice. But we definitely don't *need* 60." +- `tick-30hz-floor-half`: "30 Hz with 0.5 floor". TickRate is 30 Hz at design speed. The player's factor ranges from 0.5 to 1, + so the slowest tick is 15 Hz. + +The design-speed and accessibility cut (`cut-sim-speed-scale.r2`) was withdrawn under `map-only-what-runs`. So on master the +game still runs at design speed 1 and 60 ticks per real second. The 30 Hz in `tick-30hz-floor-half` never landed. Her +2026-10-21 words ("I don't think it even needs 30") go below it, which is the fork in section 6. + +## 2. Body facts + +### Where the rate lives + +- **Two hidden 60s.** `ActionGameManager.Clock = new SimClock(1f / 60f)` (`ActionGameManager.cs:251`) is the only clock + that steps the game. `SimClock`'s constructor defaults `stepsPerRealSecond = 60` (`SimClock.cs:21`). The two are + independent: changing the step to 1/20 while leaving the default would run 60 steps of 1/20 s per real second, which + makes the game three times faster. The 60 is decided twice, by two writers that do not know about each other. +- **ServerShared has no game rate.** `Zone.Update(float deltaTime)` (`Zone.cs:356`) steps by whatever it is given. Every + sim rule integrates the `dt` it is handed. Nothing in ServerShared knows the game's step. +- **The headless drivers choose their own step.** There is no runner yet. The tests that pin game histories step with + literals: the two-AI digest (`DeterminismLedgerTests.cs:217`, `arena.Update(1f / 60f)`, 3600 steps) and the Duel history + (`SimClockTests.OneHistoryTwice`, `ClockFrame = 1f / 60f`, `:134`). The tests contain 64 `1f / 60` literals, 31 of them + `Update(1f / 60f)` calls. Most are physics fixtures that need some small step, not the game's step. +- **Unity's fixed timestep is not the sim.** No `FixedUpdate` or `Rigidbody` exists in `Assets/Scripts`. The sim steps + only through `Clock.Advance(Time.deltaTime, ...)` (`ActionGameManager.cs:1333`). `ProjectSettings/TimeManager.asset` + decides nothing about the sim and is left alone. + +### What assumes 60 Hz + +The question is not whether a rule mentions 60. It is whether the rule's per-second meaning depends on the step. + +- **Timers that drop their overshoot.** These rules restart a normalized countdown at 1 and discard what the last step + overshot: + - `InstantWeapon.Execute` (`InstantWeapon.cs:227-265`): the cooldown ends when `_cooldown < 0`. The next burst starts + only when a pilot triggers it again on a later step. + - `Cooldown.cs:51,60`. + - `Sensor` ping cooldown (`Sensor.cs:144-148,183`). + - `ConstantWeapon` ammo interval (`ConstantWeapon.cs:148-151`). + + For the cooldown this works out to `floor(C/dt) + 1` steps per cycle, not `C/dt`. A 0.25 s cooldown fires every 16 + steps at 60 Hz (0.267 s, 6.7% slow) and every 6 steps at 20 Hz (0.30 s, 20% slow). Damage per second is therefore a + function of the tick rate today, at 60 Hz as well. This is the rule that would make an equivalence test fail for the + wrong reason. +- **Timers that already carry.** Two rules keep their overshoot, so their per-second rates survive any step: + - the beam's roll cadence (`ConstantWeapon.cs:185-196`, `while (_beamTimer >= interval) _beamTimer -= interval`); + - the burst's round cadence (`InstantWeapon.cs:243-270`, several rounds in one step if the interval is shorter). + + Transfer-verb's give budget (`CargoTransfer.cs:80-94` on its branch) carries too. +- **Passive sensing is a forward-Euler decay.** `Sensor.Execute` applies `next = saturate(previous + G dt)` and then + `next *= 1 - TargetInfoDecay * dt` (`Sensor.cs:225-231`). Its fixed point is `G (1 - k dt) / k`, not the `G / k` that + `Entity.PassiveInfo` documents and returns (`Entity.cs:518-532`). The bias is `k dt`: detection ranges shrink as the + step grows. The exact form, `x e^{-k dt} + (G/k)(1 - e^{-k dt})`, settles at `G / k` at every step. A ping is an impulse + with no `dt` (`Sensor.cs:251-254`) and is step-free. +- **Rules that are step-free already.** + - Drag is `decay(v, drag, delta)` (`Ship.cs:132`). + - Shots are rolls: commit and arrival are sim times checked each step (`FireControl.Step`, `CommitHorizon = .5 s`), so a + resolution lands up to one step late, never differently. + - Tunnelling does not exist for rounds (ruling `projectile-flight-is-presentation`). + - One-tick notifications (paints, ping crossings, `IncomingPaints`) are defined per tick and read per tick + (`ActionGameManager.cs:1336-1340`). +- **Integration error that remains.** Ship motion is stepped explicitly. At a larger step, a hard-turning ship's arc and the + in-arc and in-range test at commit move by O(dt). This is the residual her "fudge" admits. The equivalence test measures + it. +- **Hull contact.** `Contacts.Step` tests overlap after every move. A closing speed times the step that exceeds the + smallest hull's radius can pass through. The cut checks the catalog's fastest ship against its smallest hull at the + chosen step. + +### How the render covers the step + +`DrawAhead` (`DrawAhead.cs:13-19,114-139`) draws every moving thing at `T = Zone.ExactTime + Clock.Lead`: +- closed-form poses (orbits, belts, the field) at T; +- integrated poses (ships, bodies) as the last step plus step velocity times `Lead`; +- the ship's heading turned by `TurnRate * Lead`. + +This is extrapolation (dead reckoning), not interpolation. It adds no latency, and presenters store no previous state, by +rule. At 20 Hz, `Lead` reaches 50 ms. The visible cost is a snap whenever velocity or turn rate changes between steps: +about `Δω * Lead` in heading (a full turn-rate reversal of 3 rad/s gives 0.15 rad at 20 Hz, against 0.05 at 60). Positions +move by `½ a Lead²`, which is negligible. + +Input reaches the sim through `PilotCommand`. Held fields persist, and one-shots queue in order until the next step applies +them (`Pilot.cs:43-102`). A tap shorter than a step still fires, because a pull and then a release in one step both apply, +in order. The step costs the player up to one step of input lag: 33 ms at 30 Hz, 50 ms at 20 Hz. + +### What the digest pins + +`TwoAiDigestIsPinned` pins one SHA-256 over 3600 per-step `Snapshot`s of the two-AI fight at `1/60`, for host class +`linux-x64` (`DeterminismLedgerTests.cs:31-34,186-249`). It has been repinned twice, each for a stated cause. This cut +repins it once, for two causes landing together: the game step (the history has a different number of steps of a +different length), and the carried timers and exact sensor decay (cadence and detection change at any step). It still +pins the same simulated length: 60 s, which is `60 * TickRate` steps. `OneHistoryTwice` and `TwoAiFightIsOneHistory` +compare two runs and need no pin. + +### Measured on Yggdrasil + +**Not measured yet.** +- **The probe is ready.** `SimRateProbe.cs` sits beside this map. It runs a catalog timer survey and the two-AI fight over + 120 simulated seconds at 60, 30, 20, 15 and 10 Hz, three reps each after a warm-up, and reports the best wall time, + shots, hits, first hull loss, first death and survivors. +- **Why it has not run.** It waited about an hour for a Yggdrasil slot. Then three runs failed in the fixture: the test + catalog now loads the first-party mod `aetheria.nakovalnya`, whose `ship.cc` and `ship.glb` are LFS objects the old + probe recipe did not resolve. With every pointer under `GameData` resolved, one is still missing. +- **The recipe to use.** transfer-verb's job script resolves LFS for current master + (`F:\Projects\eureka-handoff-aux-2026-10-19\imag-nak-r2\job.sh`). +- **What the earlier probe showed.** At d52b50f9 the Duel ran about 9,700 steps/s at 60 Hz, mostly with one ship alive. +- **Expectation, not a measurement.** The cost per step does not depend on dt, so cost per simulated second scales with + the tick rate: 2x at 30, 3x at 20, 4x at 15. + +## 3. Prior art + +- **Glenn Fiedler, "Fix Your Timestep!" (2004).** Use a fixed `dt`, an accumulator fed by real time, a clamp against the + spiral of death, and then draw either by interpolating the previous and current states (smooth, one step of added + latency) or by extrapolating. `SimClock` already is the accumulator and the clamp (`MaxFrameSeconds = .1`), and + `DrawAhead` is the extrapolating branch. +- **Supreme Commander (2007)** runs its sim at 10 ticks per second and draws interpolated, for fleets of hundreds. It is + the closest precedent for a large-unit, non-twitch game at a low tick. +- **StarCraft II** runs 16 game loops per sim second (22.4 per real second at Faster), with deterministic lockstep and + interpolated rendering. +- **Factorio** is fixed at 60 UPS, with every timer counted in ticks and lockstep multiplayer, and no decoupling of sim and + render rate. It is the counter-example: it keeps 60 because its belts and inserters are tick-quantised content. Ours are + per-second content. +- **EVE Online** runs its Destiny ball physics on a 1 Hz server tick, and clients interpolate and predict. It shows that + tactical space combat at a slow pace tolerates very coarse ticks when the player commands intent rather than twitch. +- **X4** steps the sectors the player is in at frame rate and the others at a much coarser, abstracted rate. That is the + living-world campaign's concern, not this cut's. + +The lesson that carries over: content authored in seconds survives a step change only if every timer and integrator is +step-exact. Fiedler's accumulator gives that for the clock, and section 2's two families (timers that drop overshoot, +Euler decay) are the places where Aetheria does not have it yet. + +## 4. Authority map + +- **Owner.** `SimClock` owns the game's tick: `SimClock.TickRate` (steps per real second at design speed 1) and + `SimClock.GameStep = 1f / TickRate` (sim seconds per step). `SimClock.ForGame()` builds the game's clock from both. +- **Inputs.** None at run time. It is a code constant, so it is part of the code revision in `A-pure`'s tuple. +- **Outputs.** The `dt` every game-rate driver hands to `Zone.Update`, and `Lead`. +- **Derived.** + - `ActionGameManager.Clock` is `SimClock.ForGame()`. + - The digest, the Duel history and later the runner step by `SimClock.GameStep`. + - A timer's cadence and a sensor's settling level are functions of seconds, not of steps. +- **Demoted.** + - `SimClock`'s `stepsPerRealSecond` default is no longer an owner. The parameter becomes required, and the game's value is + derived from `TickRate`. + - `1f / 60f` in `ActionGameManager` is no longer an owner; it is derived from `SimClock.GameStep`. + - Physics fixtures may keep a literal step of their own, because they test physics, not the game's history. +- **Forbidden writers.** + - Any `new SimClock(` in `Assets/Scripts` other than `ForGame`. + - Any literal step in a driver of a pinned game history. + - A timer that resets to 1 and discards its overshoot. + - A per-step decay written as `1 - k * dt`. +- **Shared paths.** The player's game, the digest and Duel histories, and the future runner all step `Zone.Update` by + `SimClock.GameStep`. The design-speed cut, when re-mapped, sets `Step = DesignSpeed / TickRate` inside `ForGame` and + touches nothing else. +- **Deletion line.** The default `= 60` and the `1f / 60f` in `ActionGameManager` go first. The overshoot-dropping resets go + before the rate changes, so the rate change is measured on step-exact rules. + +## 5. Not this cut + +- **Brains deciding below the physics step.** The ruling allows it, and `PilotCommand`'s held fields already make it + possible. No brain-rate machinery lands here. +- **Design speed and the player's slow-down** (`sim-speed-scale`, withdrawn). `ForGame` is the one place where it will + land. +- **Render interpolation.** Extrapolation is ruled (`sim-speed-tenth`, DrawAhead's no-stored-state rule). Her playtest is + where a heading snap would show. If it does, interpolating the player's ship only is the follow-up, at one step of + latency. +- **Unity's `TimeManager` fixed timestep**, which the sim does not read. + +## 6. The fork + +`aetheria-arena:question:sim-tick-rate`: which tick rate Hands lands for her playtest, given `tick-30hz-floor-half`. +Options and recommendation are in the question. In short: +- 30 Hz keeps the in-force ruling and halves the cost. +- 20 Hz follows her 2026-10-21 words and costs a third, but at the 0.5 accessibility floor the real tick is 10 Hz. +- 15 Hz is a quarter of the cost and 7.5 Hz at the floor, near the 6 Hz she called noticeable. diff --git a/docs/aetheria-arena-target.md b/docs/aetheria-arena-target.md index d6f344501..bbd027a2a 100644 --- a/docs/aetheria-arena-target.md +++ b/docs/aetheria-arena-target.md @@ -85,7 +85,8 @@ Harness: - `A-pure`: A match is a pure function of (code revision, catalog revision, scenario id, match seed, brain ids per side, host determinism class). The same inputs give the same per-tick state digest, in any process, run alone or - in a batch. + in a batch. The step is part of the code revision: `SimClock.GameStep`, never a + driver's own literal. - `A-no-clock`: No input to a match comes from the wall clock, process identity, hash-code ordering of reference types, or a stream another subsystem advances. Every draw a match reaches comes from `SimulationDice` (or a successor owner) keyed by a structured seed. A missing seed is refused, never defaulted to the clock. @@ -169,10 +170,17 @@ No cell is empty. The Scenario row exposes a gap: `Name` is today's only identit 3. **View seam.** Add a `PilotView` built from sensors and book. Brains lose their live `Ship`, `Zone` and `ItemManager` references. `Flight` and `Minion` become `DoctrineBrain` behind an `IBrain` registry. Aiming moves to the view if `observation-fidelity` rules for it. -4. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces +4. **Sim rate** (ruling `lower-game-sim-rate`, map `docs/aetheria-arena-sim-rate-map.md`, spec `cut-sim-rate.r1`). + - One game tick, `SimClock.TickRate`, lowered for game and arena alike, so `A-one-sim` holds unchanged. + - Before it, timers carry their overshoot and passive sensing decays exactly, so content authored in seconds means the + same thing at any tick. + - The two-AI digest is repinned once, at the game step, for those two causes. + - It lands before replay and the runner, so recorded digests and measured throughput are at the real rate. + - The rate is the one question `sim-tick-rate` decides, then the equivalence test, then her playtest. +5. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces them and names the first divergent tick. Replay tests read an existing match. -5. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given +6. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given directory. It runs in Yggdrasil's dotnet container, with two-ship throughput measured and recorded. -6. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat +7. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat scenarios, with enough seeds per pairing. Ratings are derived with confidence intervals, and the results document is ready to feed `aetheria-matchups`. From e2f0fc9dc4c607c7108b3aba64e9f7cea02912ec Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:53 +0200 Subject: [PATCH 02/36] SimClock owns the game tick: TickRate, GameStep, ForGame(); the 60 default and ActionGameManager's literal step are gone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- Assets/Scripts/ServerShared/SimClock.cs | 10 +++++- .../DeterminismLedgerTests.cs | 4 +-- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 2 +- tests/Aetheria.Shared.Tests/SimClockTests.cs | 16 ++++----- tests/Aetheria.Shared.Tests/SimRateTests.cs | 35 +++++++++++++++++++ 6 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SimRateTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 7917b0f7d..2af138d91 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -248,7 +248,7 @@ public class ActionGameManager : MonoBehaviour public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } - public static SimClock Clock { get; } = new SimClock(1f / 60f); + public static SimClock Clock { get; } = SimClock.ForGame(); private bool _zoneEntered; private readonly (float2 direction, string name)[] _directions = { diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs index 6d00c46d0..bf2f54301 100644 --- a/Assets/Scripts/ServerShared/SimClock.cs +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -14,11 +14,19 @@ public sealed class SimClock // catch-up steps. public const float MaxFrameSeconds = .1f; + // Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's + // tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will + // set Step = DesignSpeed / TickRate in ForGame and nowhere else. + public const int TickRate = 60; + public const float GameStep = 1f / TickRate; + + public static SimClock ForGame() => new SimClock(GameStep, TickRate); + private readonly double _period; private double _accumulator; private bool _advancing; - public SimClock(float step, float stepsPerRealSecond = 60) + public SimClock(float step, float stepsPerRealSecond) { if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step)); if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond)); diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 65be67d68..eed812cfd 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -31,7 +31,7 @@ public sealed partial class RunStartTests // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; - private const int TwoAiSteps = 3600; + private const int TwoAiSteps = 60 * SimClock.TickRate; [Fact] public void ItemManagerRefusesSeedZero() @@ -214,7 +214,7 @@ public sealed partial class RunStartTests var history = new StringBuilder(); for (var step = 0; step < TwoAiSteps; step++) { - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); history.Append(Snapshot(arena, shots)).Append('\n'); } var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index d288cce27..37111b114 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -171,7 +171,7 @@ public sealed class DrawnPoseTests : IDisposable { var w = Build(); var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()); - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var xs = new List(); var ys = new List(); var leads = new List(); diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index 6426fe47d..75c2437f5 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -34,7 +34,7 @@ public sealed class SimClockTests { foreach (var frames in new[] { new[] { 1f / 60 }, Irregular, new[] { .09f, .004f, 0f, 3f } }) { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var passed = Drive(clock, frames, 500); Assert.True(passed.Count >= 500); Assert.All(passed, dt => Assert.Equal(clock.Step, dt)); @@ -47,7 +47,7 @@ public sealed class SimClockTests public void StepsFollowRealTime() { // Ten real seconds of irregular frames. - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var elapsed = 0.0; for (var frame = 0; elapsed < 10.0; frame++) { @@ -71,7 +71,7 @@ public sealed class SimClockTests [Fact] public void AHitchIsClamped() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var ran = clock.Advance(5f, _ => { }); Assert.Equal(6, ran); Assert.Equal(6, clock.Steps); @@ -80,7 +80,7 @@ public sealed class SimClockTests [Fact] public void AStepCannotResetItsClock() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); Assert.Throws(() => clock.Advance(1f / 60f, _ => clock.Reset())); clock.Advance(.005f, _ => { }); Assert.True(clock.Lead > 0); @@ -92,7 +92,7 @@ public sealed class SimClockTests [Fact] public void PauseFeedsNothing() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); clock.Advance(.025f, _ => { }); var lead = clock.Lead; var steps = clock.Steps; @@ -124,14 +124,14 @@ public sealed class SimClockTests [Fact] public void ABadClockIsRefusedByFieldName() { - Assert.Equal("step", Assert.Throws(() => new SimClock(0f)).ParamName); + Assert.Equal("step", Assert.Throws(() => new SimClock(0f, 60)).ParamName); Assert.Equal("stepsPerRealSecond", Assert.Throws(() => new SimClock(1f, 0f)).ParamName); } } public sealed partial class RunStartTests { - private const float ClockFrame = 1f / 60f; + private const float ClockFrame = SimClock.GameStep; private const int HistorySteps = 1800; // The Duel launched as the game launches it, its item random reseeded so play draws the same numbers every time. @@ -173,7 +173,7 @@ public sealed partial class RunStartTests // Feeds the zone through one SimClock with the frames cycled, snapshotting every step until HistorySteps have run. private static List History(Zone zone, float[] frames) { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var shots = new List(); using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}")); var history = new List(); diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs new file mode 100644 index 000000000..986a46499 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut sim-rate (ruling lower-game-sim-rate, map docs/aetheria-arena-sim-rate-map.md): the game's tick has one owner, +// and content authored in seconds means the same at every tick, because a restarted timer keeps what the last step +// overshot and passive sensing decays exactly. +public sealed class SimRateClockTests +{ + [Fact] + public void OneTickOwner() + { + var clock = SimClock.ForGame(); + Assert.Equal(1f / SimClock.TickRate, SimClock.GameStep); + Assert.Equal(SimClock.GameStep, clock.Step); + Assert.Equal(SimClock.TickRate, clock.StepsPerRealSecond); + + // Ten real seconds of irregular frames pay ten seconds of steps, so the game runs at real time. + var frames = new[] { .004f, .031f, .016f, .0083f, .05f, .022f, .0125f, .027f }; + var real = 0f; + for (var i = 0; real < 10f; i++) + { + var frame = Math.Min(frames[i % frames.Length], 10f - real); + real += frame; + clock.Advance(frame, _ => { }); + } + Assert.InRange(clock.Steps, 10 * SimClock.TickRate - 1, 10 * SimClock.TickRate + 1); + } +} From f0362025d903d937649455eb65a5369de9025062 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:35:28 +0200 Subject: [PATCH 03/36] Step-exact rules: timers keep their overshoot, passive sensing decays exactly (group B, 60 Hz, digest not repinned) Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/ConstantWeapon.cs | 2 +- .../ServerShared/Behaviors/Cooldown.cs | 6 +- .../ServerShared/Behaviors/InstantWeapon.cs | 23 ++- .../Scripts/ServerShared/Behaviors/Sensor.cs | 18 +- tests/Aetheria.Shared.Tests/SimRateTests.cs | 179 ++++++++++++++++++ 5 files changed, 216 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index a2fc9f802..fd630f37d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -148,7 +148,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC _ammoInterval -= dt / _data.AmmoInterval; if (_ammoInterval < 0) { - _ammoInterval = 1; + _ammoInterval += 1; if (_data.MagazineSize > 1 && _ammo > 0) _ammo--; else { diff --git a/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs b/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs index 88336cfe5..fb18a6054 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs @@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior { if (_cooldown < 0) { - _cooldown = 1; + _cooldown += 1; return true; } @@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior public void Update(float delta) { - _cooldown -= delta / Evaluate(_data.Cooldown); + // A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact. + var step = delta / Evaluate(_data.Cooldown); + _cooldown = max(_cooldown - step, -step); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index a6b3dcd2f..16ebf2fe8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -58,6 +58,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected float _cooldown; // Normalized private int _ammo = 0; protected bool _coolingDown; + // Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held + // trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart. + private float _overshoot; public float BurstCount { get; protected set; } public float BurstTime { get; protected set; } @@ -183,6 +186,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever. protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity); + private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; + + private float TakeOvershoot() + { + var overshoot = _overshoot; + _overshoot = 0; + return overshoot; + } + private bool UseAmmo() { if (_data.MagazineSize <= 1) return true; @@ -208,7 +220,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if(hasAmmo) { OnReloadBegin?.Invoke(); - _cooldown = 1; + _cooldown = 1 - TakeOvershoot() / _data.ReloadTime; _coolingDown = true; _firing = false; } @@ -226,9 +238,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); if (_coolingDown) { - _cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); + var span = CountdownSpan; + _cooldown -= dt / span; if (_cooldown < 0) { + _overshoot = -_cooldown * span; _coolingDown = false; if (_data.MagazineSize > 0 && _ammo == 0) { @@ -260,8 +274,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_burstStarted) { _burstStarted = true; - _cooldown = 1 - dt / Cooldown; - // The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown + _cooldown = 1 - (dt + TakeOvershoot()) / CountdownSpan; + // The round starts the cooldown after this tick's decrement above: charge the tick and the last + // countdown's overshoot to it, so the cadence stays Cooldown _coolingDown = true; } } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index d98437e59..e549688b0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer { _pinging = true; _pingZone = Entity.Zone; - _pingCooldown = 1; + _pingCooldown += 1; _pingLerp = 0; _pingRadius = 0; _reached.Clear(); @@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer if (_pingLerp > 1) EndPing(); } - _pingCooldown -= dt / Evaluate(_data.PingCooldown); + // A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact. + var pingStep = dt / Evaluate(_data.PingCooldown); + _pingCooldown = max(_pingCooldown - pingStep, -pingStep); + var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay; + var decay = exp(-infoDecay * dt); + var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt; + var forward = Direction.xz; foreach (var entity in Entity.Zone.Entities) { @@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration)); } + // The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second, + // which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first. if (heard) - next = saturate(previous + credit); + next = saturate(previous + credit) * decay; else { var angle = acos(dot(forward, normalize(diff))); - next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration)); + var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration); + next = saturate(previous * decay + passive * settle); } - next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); Entity.EntityInfoGathered[entity] = next; } diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 986a46499..d90adf731 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -33,3 +33,182 @@ public sealed class SimRateClockTests Assert.InRange(clock.Steps, 10 * SimClock.TickRate - 1, 10 * SimClock.TickRate + 1); } } + +public sealed partial class ContactRevealTests +{ + private static readonly float[] CadenceSteps = { 1f / 60f, 1f / 30f, 1f / 20f, 1f / 15f }; + private static readonly float[] Cooldowns = { .25f, .13f }; + + // A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog. + private string GunWithCooldown(float cooldown) + { + var name = $"Gun@{cooldown:R}"; + if (_openCache.GetByName(name) != null) return name; + var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }, 0); + _openCache.Upsert(new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = WeaponFuse.Proximity, BlastRadius = 30f, + Behaviors = { gun } + }); + return name; + } + + // Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone + // time of each round. + private List HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds) + { + var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon); + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + var rounds = new List(); + gun.OnFire += _ => rounds.Add(_zone.Time); + ship.SetTarget(TargetRef.None); + for (var step = 0; step < (int) round(seconds / dt); step++) + { + capacitor.AddCharge(1000f); + gun.Activate(); + Tick(dt); + } + return rounds; + } + + [Fact] + public void CooldownCadenceIsPerSecond() + { + foreach (var cooldown in Cooldowns) + foreach (var dt in CadenceSteps) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 30f); + var expected = 30f / cooldown; + Assert.True(abs(rounds.Count - expected) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds.Count} rounds, expected {expected} +-1"); + } + } + + [Fact] + public void CooldownNeverFastFires() + { + foreach (var cooldown in Cooldowns) + foreach (var dt in CadenceSteps) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var item = Guns(ship)[0]; + for (var i = 0; i < 20; i++) Tick(dt); // idle: the finished timer banks at most one step + var rounds = HoldTrigger(ship, item, dt, 10f); + Assert.True(rounds.Count > 3); + for (var i = 1; i < rounds.Count; i++) + Assert.True(rounds[i] - rounds[i - 1] >= cooldown - dt - 1e-4f, $"cooldown {cooldown} at dt {dt}: rounds {rounds[i - 1]} and {rounds[i]}"); + } + } + + private int PingsHeld(float pingCooldown, float dt, float seconds) + { + var name = $"Pinger@{pingCooldown:R}"; + if (_openCache.GetByName(name) == null) + { + var sensor = SensorBehavior(sensitivity: 2f); + sensor.PingCooldown = Const(pingCooldown); + _openCache.Upsert(new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { sensor } + }); + } + var ship = Add(float3(0, 0, 0), new[] { name }); + var pings = 0; + SensorOf(ship).OnPingStart += () => pings++; + for (var step = 0; step < (int) round(seconds / dt); step++) + { + SensorOf(ship).Ping(); + Tick(dt); + } + return pings; + } + + [Fact] + public void PingCooldownCarriesItsOvershoot() + { + foreach (var cooldown in new[] { .5f, .26f }) + foreach (var dt in CadenceSteps) + { + var pings = PingsHeld(cooldown, dt, 30f); + Assert.True(abs(pings - 30f / cooldown) <= 1.01f, $"ping cooldown {cooldown} at dt {dt}: {pings} pings, expected {30f / cooldown} +-1"); + } + } + + [Fact] + public void CooldownBehaviourCarriesItsOvershoot() + { + foreach (var cooldown in new[] { .5f, .26f }) + foreach (var dt in CadenceSteps) + { + var name = $"Metronome@{cooldown:R}"; + if (_openCache.GetByName(name) == null) + _openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(cooldown) })); + var ship = Add(float3(0, 0, 0), null, new[] { name }); + var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + var fired = 0; + for (var step = 0; step < (int) round(30f / dt); step++) + { + Tick(dt); + if (clock.Execute(dt)) fired++; + } + Assert.True(abs(fired - 30f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {fired} restarts, expected {30f / cooldown} +-1"); + } + } + + [Fact] + public void AmmoIntervalCarriesItsOvershoot() + { + _openCache.Upsert(new SimpleCommodityData { Name = "Slug", Shape = new Shape(), Price = 1, MaxStack = 100 }); + foreach (var interval in new[] { .5f, .26f }) + foreach (var dt in CadenceSteps) + { + var name = $"Stream@{interval:R}"; + if (_openCache.GetByName(name) == null) + { + var beam = (ConstantWeaponData) Armed(new ConstantWeaponData(), 100000); + beam.AmmoInterval = interval; + beam.AmmoType = _openCache.RefOf(_openCache.GetByName("Slug")); + _openCache.Upsert(new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = WeaponFuse.Proximity, BlastRadius = 30f, + Behaviors = { beam } + }); + } + var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" }); + var weapon = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + typeof(ConstantWeapon).GetField("_ammo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 100000); + ship.SetTarget(TargetRef.None); + weapon.Activate(); + for (var step = 0; step < (int) round(30f / dt); step++) Tick(dt); + var spent = 100000 - weapon.Ammo; + Assert.True(abs(spent - 30f / interval) <= 1.01f, $"ammo interval {interval} at dt {dt}: {spent} spent, expected {30f / interval} +-1"); + } + } + + // A fixed target under passive sensing settles where Entity.PassiveInfo says (G / k), at any step. + [Fact] + public void PassiveInfoSettlesAtTheDocumentedLevel() + { + foreach (var dt in new[] { 1f / 60f, 1f / 15f }) + { + var observer = Add(float3(0, 0, 0), new[] { "Eye" }); + var target = Add(float3(0, 0, 100)); + var distance = 100f; + while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f) + { + distance *= 2; + target.Position = float3(0, 0, distance); + } + var documented = observer.PassiveInfo(target.Position.xz, target.Visibility); + Assert.InRange(documented, 1e-4f, .5f); + for (var step = 0; step < (int) round(40f / dt); step++) Tick(dt); + Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f); + } + } +} From b9e0e5b5eb7e376426469c6fb1d95055c4cdad19 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:41:48 +0200 Subject: [PATCH 04/36] SimRateTests: whole-step counts by MathF.Round Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index d90adf731..3672370aa 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -64,7 +64,7 @@ public sealed partial class ContactRevealTests var rounds = new List(); gun.OnFire += _ => rounds.Add(_zone.Time); ship.SetTarget(TargetRef.None); - for (var step = 0; step < (int) round(seconds / dt); step++) + for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { capacitor.AddCharge(1000f); gun.Activate(); @@ -119,7 +119,7 @@ public sealed partial class ContactRevealTests var ship = Add(float3(0, 0, 0), new[] { name }); var pings = 0; SensorOf(ship).OnPingStart += () => pings++; - for (var step = 0; step < (int) round(seconds / dt); step++) + for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { SensorOf(ship).Ping(); Tick(dt); @@ -150,7 +150,7 @@ public sealed partial class ContactRevealTests var ship = Add(float3(0, 0, 0), null, new[] { name }); var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); var fired = 0; - for (var step = 0; step < (int) round(30f / dt); step++) + for (var step = 0; step < (int) MathF.Round(30f / dt); step++) { Tick(dt); if (clock.Execute(dt)) fired++; @@ -185,7 +185,7 @@ public sealed partial class ContactRevealTests typeof(ConstantWeapon).GetField("_ammo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 100000); ship.SetTarget(TargetRef.None); weapon.Activate(); - for (var step = 0; step < (int) round(30f / dt); step++) Tick(dt); + for (var step = 0; step < (int) MathF.Round(30f / dt); step++) Tick(dt); var spent = 100000 - weapon.Ammo; Assert.True(abs(spent - 30f / interval) <= 1.01f, $"ammo interval {interval} at dt {dt}: {spent} spent, expected {30f / interval} +-1"); } @@ -207,7 +207,7 @@ public sealed partial class ContactRevealTests } var documented = observer.PassiveInfo(target.Position.xz, target.Visibility); Assert.InRange(documented, 1e-4f, .5f); - for (var step = 0; step < (int) round(40f / dt); step++) Tick(dt); + for (var step = 0; step < (int) MathF.Round(40f / dt); step++) Tick(dt); Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f); } } From 549437536a574a7f8d538151b03f7e9ce97fa731 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:51:59 +0200 Subject: [PATCH 05/36] Cooldowns shorter than a step end in it; tests that pinned the old tick-quantised cadence and Euler decay follow the exact rules Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InstantWeapon.cs | 33 +++++++++++-------- .../DeterminismLedgerTests.cs | 18 ++++++---- .../FireControlPerWeaponTests.cs | 11 ++++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 14 ++++---- tests/Aetheria.Shared.Tests/SimRateTests.cs | 1 + 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 16ebf2fe8..84e6dd5c2 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -186,6 +186,21 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever. protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity); + // Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one. + private void FinishCountdown(float dt) + { + if (!_coolingDown || !(_cooldown < 0)) return; + _overshoot = min(-_cooldown * CountdownSpan, dt); + _coolingDown = false; + if (_data.MagazineSize > 0 && _ammo == 0) + { + _ammo = _data.MagazineSize; + OnReloadComplete?.Invoke(); + } + else + OnCooldownComplete?.Invoke(); + } + private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; private float TakeOvershoot() @@ -238,20 +253,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); if (_coolingDown) { - var span = CountdownSpan; - _cooldown -= dt / span; - if (_cooldown < 0) - { - _overshoot = -_cooldown * span; - _coolingDown = false; - if (_data.MagazineSize > 0 && _ammo == 0) - { - _ammo = _data.MagazineSize; - OnReloadComplete?.Invoke(); - } - else - OnCooldownComplete?.Invoke(); - } + _cooldown -= dt / CountdownSpan; + FinishCountdown(dt); } _burstTimer += dt; @@ -298,6 +301,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo AddHeat(Heat); Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item); } + // A cooldown shorter than the step it started in is over in that step, so the next trigger can fire on the next one. + FinishCountdown(dt); return true; } diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index eed812cfd..b93814f67 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -31,7 +31,7 @@ public sealed partial class RunStartTests // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; - private const int TwoAiSteps = 60 * SimClock.TickRate; + private const float TwoAiSeconds = 60f; [Fact] public void ItemManagerRefusesSeedZero() @@ -186,8 +186,9 @@ public sealed partial class RunStartTests } // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted - // and far from the fight. Steps 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots. - private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight() + // and far from the fight. Steps TwoAiSeconds at `dt` (the game's step by default) and returns the SHA-256 of the joined + // snapshots, the hull each fighter ends with and the sim time a hull first reaches zero (TwoAiSeconds when none does). + private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => @@ -197,7 +198,7 @@ public sealed partial class RunStartTests west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); stage.Approach(west, east); - })); + }, seed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); east.SetIff(west, true); west.SetIff(east, true); @@ -212,13 +213,16 @@ public sealed partial class RunStartTests if (outcome.Hit) hits++; }); var history = new StringBuilder(); - for (var step = 0; step < TwoAiSteps; step++) + var firstKill = TwoAiSeconds; + var steps = (int) MathF.Round(TwoAiSeconds / dt); + for (var step = 0; step < steps; step++) { - arena.Update(SimClock.GameStep); + arena.Update(dt); history.Append(Snapshot(arena, shots)).Append('\n'); + if (firstKill == TwoAiSeconds && (east.Hull.Durability <= 0 || west.Hull.Durability <= 0)) firstKill = (step + 1) * dt; } var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); - return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 3a06a63f7..189867e41 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1012,11 +1012,12 @@ public sealed class FireControlPerWeaponTests : IDisposable } // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. - // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused - // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown - // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + // Cooldown 1 at .25 s ticks is a shot a second, 15 in 15 s (+-1: the first cycle has no carried overshoot). And a burst + // whose every round is refused leaves no cooldown: the same held trigger is ready every tick. Kills: a first round + // that starts the cooldown at 1 after the tick's decrement (a shot every 5 ticks, 12 in 15 s); a cooldown left by a + // refused burst. [Theory] - [InlineData(100f, 12)] + [InlineData(100f, 15)] [InlineData(29.5f, 0)] public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) { @@ -1036,7 +1037,7 @@ public sealed class FireControlPerWeaponTests : IDisposable if (weapon.CanFire) readyTicks++; } - Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(0, fired); else Assert.InRange(fired, shots - 1, shots + 1); if (shots == 0) Assert.Equal(60, readyTicks); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 484116eb3..ccf6890bc 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -245,7 +245,7 @@ public sealed partial class MiningCut3Tests : IDisposable // at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through) // yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash // was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in). - private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79"; + private const string Golden = "A721BC4A05E9BC53FA29437F5D2B38F5417389CB0668C623D00254971D4AB794"; [Fact] public void SensorGainExtractionChangesNothing() @@ -280,8 +280,9 @@ public sealed partial class MiningCut3Tests : IDisposable // Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section // (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under - // the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay - // step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the + // the per-tick loop at the value ChunkInfo returns on demand. The per-tick decay is exact, so the loop settles at G / k + // at any step. (Until sim-rate its fixed point sat one decay + // step below the continuous one, exactly: x* (1 - k dt).) The observer is off the sensor's axis, so the // bearing term is live. [Fact] public void ChunkInfoMatchesAnEntityHeldStill() @@ -300,7 +301,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 1e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -704,7 +705,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 2e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -1049,7 +1050,8 @@ public sealed partial class MiningCut3Tests : IDisposable if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; } Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); - Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray()))); + var pingHash = Convert.ToHexString(SHA256.HashData(bytes.ToArray())); + Assert.True(pingHash == "REPIN", $"the ping-arm trace hash {pingHash}"); } // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 3672370aa..594d9f493 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -199,6 +199,7 @@ public sealed partial class ContactRevealTests { var observer = Add(float3(0, 0, 0), new[] { "Eye" }); var target = Add(float3(0, 0, 100)); + Tick(.1f); // brings the signatures and sensors up var distance = 100f; while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f) { From 48ba0a6204596948692242eee958781f769766fa Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:05:13 +0200 Subject: [PATCH 06/36] SimRateTests: idle banking, magazine cadence, glowing passive target; equivalence and shortest-timer tests; ping-arm trace repinned for the exact decay Co-Authored-By: Claude Sonnet 5.5 --- .../DeterminismLedgerTests.cs | 5 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 2 +- tests/Aetheria.Shared.Tests/SimRateTests.cs | 171 +++++++++++++++++- 3 files changed, 173 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index b93814f67..035560cbd 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -188,7 +188,7 @@ public sealed partial class RunStartTests // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted // and far from the fight. Steps TwoAiSeconds at `dt` (the game's step by default) and returns the SHA-256 of the joined // snapshots, the hull each fighter ends with and the sim time a hull first reaches zero (TwoAiSeconds when none does). - private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep) + private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill, double stepSeconds) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => @@ -215,6 +215,7 @@ public sealed partial class RunStartTests var history = new StringBuilder(); var firstKill = TwoAiSeconds; var steps = (int) MathF.Round(TwoAiSeconds / dt); + var clock = System.Diagnostics.Stopwatch.StartNew(); for (var step = 0; step < steps; step++) { arena.Update(dt); @@ -222,7 +223,7 @@ public sealed partial class RunStartTests if (firstKill == TwoAiSeconds && (east.Hull.Durability <= 0 || west.Hull.Durability <= 0)) firstKill = (step + 1) * dt; } var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); - return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill, clock.Elapsed.TotalSeconds); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index ccf6890bc..52c2fe53f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -1051,7 +1051,7 @@ public sealed partial class MiningCut3Tests : IDisposable } Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); var pingHash = Convert.ToHexString(SHA256.HashData(bytes.ToArray())); - Assert.True(pingHash == "REPIN", $"the ping-arm trace hash {pingHash}"); + Assert.True(pingHash == "283A6129533784AE08FDA7D9FFA2BB0862FA8F64AD32C6726E80BC1138443FE3", $"the ping-arm trace hash {pingHash}"); } // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 594d9f493..9193bb899 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -199,7 +199,10 @@ public sealed partial class ContactRevealTests { var observer = Add(float3(0, 0, 0), new[] { "Eye" }); var target = Add(float3(0, 0, 100)); - Tick(.1f); // brings the signatures and sensors up + var marker = new object(); + void Glow() => target.SetSignature(marker, SignatureChannel.Thermal, 5f, null); // held as bright as a thruster + Glow(); + Tick(0f); var distance = 100f; while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f) { @@ -208,8 +211,172 @@ public sealed partial class ContactRevealTests } var documented = observer.PassiveInfo(target.Position.xz, target.Visibility); Assert.InRange(documented, 1e-4f, .5f); - for (var step = 0; step < (int) MathF.Round(40f / dt); step++) Tick(dt); + for (var step = 0; step < (int) MathF.Round(40f / dt); step++) + { + Glow(); + Tick(dt); + } Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f); } } + + // An idle timer banks at most one step: after ten idle seconds the first two restarts are still a cooldown apart. + [Fact] + public void AnIdleSensorBanksAtMostOneStep() + { + foreach (var dt in CadenceSteps) + { + var name = "Pinger@.5"; + if (_openCache.GetByName(name) == null) + { + var sensor = SensorBehavior(sensitivity: 2f); + sensor.PingCooldown = Const(.5f); + _openCache.Upsert(new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { sensor } + }); + } + var ship = Add(float3(0, 0, 0), new[] { name }); + var times = new List(); + SensorOf(ship).OnPingStart += () => times.Add(_zone.Time); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt); + for (var step = 0; step < (int) MathF.Round(3f / dt); step++) + { + SensorOf(ship).Ping(); + Tick(dt); + } + Assert.True(times.Count >= 4); + for (var i = 1; i < times.Count; i++) + Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: pings at {times[i - 1]} and {times[i]}"); + } + } + + [Fact] + public void AnIdleCooldownBanksAtMostOneStep() + { + foreach (var dt in CadenceSteps) + { + var name = "Metronome@.5"; + if (_openCache.GetByName(name) == null) + _openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(.5f) })); + var ship = Add(float3(0, 0, 0), null, new[] { name }); + var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt); + var times = new List(); + for (var step = 0; step < (int) MathF.Round(3f / dt); step++) + { + Tick(dt); + if (clock.Execute(dt)) times.Add(_zone.Time); + } + Assert.True(times.Count >= 4); + for (var i = 1; i < times.Count; i++) + Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: restarts at {times[i - 1]} and {times[i]}"); + } + } + + // A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step. + [Fact] + public void MagazineCadenceIsPerSecond() + { + foreach (var dt in CadenceSteps) + { + var name = "Magazine3"; + if (_openCache.GetByName(name) == null) + { + var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(.1f), ReloadTime = .9f }, 3); + _openCache.Upsert(new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = WeaponFuse.Proximity, BlastRadius = 30f, + Behaviors = { gun } + }); + } + var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" }); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 33f); + Assert.True(abs(rounds.Count - 90f) <= 3.01f, $"dt {dt}: {rounds.Count} rounds in 33 s, expected 90 +-3"); + } + } +} + +public sealed partial class RunStartTests +{ + private static readonly string EquivalenceTable = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-equivalence.txt"); + + // The two-AI fight over 40 match seeds at the old 60 Hz and at the game's step: the same fight to within noise. The + // win rate (east's hull above west's) differs by less than a 95% two-proportion interval, and the mean survivor hull + // and the mean time to first kill each move by less than 10%. The table is written beside the test for the report. + [Fact, Trait("Category", "Slow")] + public void SimRateEquivalence() + { + const int seeds = 40; + var table = new System.Text.StringBuilder(); + (double wins, double survivor, double firstKill, double seconds) Run(float dt) + { + var wins = 0; + double survivor = 0, firstKill = 0, seconds = 0; + for (uint seed = 1; seed <= seeds; seed++) + { + var fight = TwoAiFight(seed, dt); + if (fight.eastHull > fight.westHull) wins++; + survivor += Math.Max(fight.eastHull, fight.westHull); + firstKill += fight.firstKill; + seconds += fight.stepSeconds; + table.AppendLine($"dt {dt:R} seed {seed}: east {fight.eastHull:R} west {fight.westHull:R} first kill {fight.firstKill:R} s, {fight.stepSeconds:F2} s wall"); + } + return (wins / (double) seeds, survivor / seeds, firstKill / seeds, seconds); + } + + var old = Run(1f / 60f); + var now = Run(SimClock.GameStep); + var se = Math.Sqrt(old.wins * (1 - old.wins) / seeds + now.wins * (1 - now.wins) / seeds); + table.AppendLine($"60 Hz: east wins {old.wins:P0}, mean survivor hull {old.survivor:F1}, mean first kill {old.firstKill:F2} s, {old.seconds:F1} s wall"); + table.AppendLine($"{SimClock.TickRate} Hz: east wins {now.wins:P0}, mean survivor hull {now.survivor:F1}, mean first kill {now.firstKill:F2} s, {now.seconds:F1} s wall"); + table.AppendLine($"win-rate difference {Math.Abs(old.wins - now.wins):F3} (95% interval +-{1.96 * se:F3}); wall-time speedup {old.seconds / now.seconds:F2}x"); + System.IO.File.WriteAllText(EquivalenceTable, table.ToString()); + + Assert.True(Math.Abs(old.wins - now.wins) <= 1.96 * se, table.ToString()); + Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString()); + Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString()); + } + + // Every shipped cooldown, ping cooldown and ammo interval spans at least two steps. A burst interval under one step + // fires several rounds in one step (carry makes that exact on average); those are listed, not failed. + [Fact] + public void ShortestTimerIsSeveralSteps() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var timers = new List<(string item, string timer, float seconds)>(); + var bursts = new List(); + void Read(string item, IEnumerable behaviors) + { + foreach (var behavior in behaviors.Where(b => b != null)) + switch (behavior) + { + case InstantWeaponData gun: + timers.Add((item, "Cooldown", Math.Min(gun.Cooldown.Min, gun.Cooldown.Max))); + var count = Math.Max(1f, Math.Min(gun.Count.Min, gun.Count.Max)); + var interval = Math.Min(gun.BurstTime.Min, gun.BurstTime.Max) / count; + if (count > 1 && interval < SimClock.GameStep) bursts.Add($"{item}: burst interval {interval:R} s"); + break; + case SensorData sensor: + timers.Add((item, "PingCooldown", Math.Min(sensor.PingCooldown.Min, sensor.PingCooldown.Max))); + break; + case ConstantWeaponData beam when beam.AmmoType.IsSet(): + timers.Add((item, "AmmoInterval", beam.AmmoInterval)); + break; + case CooldownData cooldown: + timers.Add((item, "Cooldown", Math.Min(cooldown.Cooldown.Min, cooldown.Cooldown.Max))); + break; + } + } + foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); + foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); + + var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep) + .Select(t => $"{t.item}.{t.timer} = {t.seconds:R} s").ToList(); + Assert.True(offenders.Count == 0, $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}"); + } } From ed5dd92283a1b4cbe10cbf125f269e5e3ee07f51 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:05:17 +0200 Subject: [PATCH 07/36] Game tick lowered to 30 Hz (ruling tick-rate-stays-30); digest repin follows Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/SimClock.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs index bf2f54301..ee260a50b 100644 --- a/Assets/Scripts/ServerShared/SimClock.cs +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -17,7 +17,7 @@ public sealed class SimClock // Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's // tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will // set Step = DesignSpeed / TickRate in ForGame and nowhere else. - public const int TickRate = 60; + public const int TickRate = 30; public const float GameStep = 1f / TickRate; public static SimClock ForGame() => new SimClock(GameStep, TickRate); From 5adaef4697969fe21f0a760ccf14277f1ad208e2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:28:09 +0200 Subject: [PATCH 08/36] Two-AI digest repinned once at the game step (step-exact rules, 30 Hz); shortest-timer test pins the four guns authored below two steps Co-Authored-By: Claude Sonnet 5.5 --- .../DeterminismLedgerTests.cs | 5 ++++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 035560cbd..981ff2559 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -30,7 +30,10 @@ public sealed partial class RunStartTests // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so // once the sensors drop the staged anchor the follower stops following it (ce81a426). - private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; + // Repinned a third time, once, for two causes (sim-rate cut): the rules went step-exact (timers keep their overshoot, + // passive sensing decays exactly), which alone gives 4f4a1c8b88308d0a368527549f33baf0e157a056b4c0fd33aeb110b027b862e1 + // at 60 Hz; and the game step is now SimClock.GameStep (30 Hz), 1800 steps of the same 60 simulated seconds. + private const string TwoAiPinnedDigest = "d5e16969d5366349f2cddeec53f7e8e8c69074dcf5aa7aabb365e9a91a34511f"; private const float TwoAiSeconds = 60f; [Fact] diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 9193bb899..9765757d3 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -342,8 +342,14 @@ public sealed partial class RunStartTests Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString()); } - // Every shipped cooldown, ping cooldown and ammo interval spans at least two steps. A burst interval under one step - // fires several rounds in one step (carry makes that exact on average); those are listed, not failed. + // Every shipped cooldown, ping cooldown and ammo interval spans at least two steps, but the four guns below are + // authored faster (a restarted cooldown ends within its step, so they fire at most once per step: the step caps their + // rate). That is a content decision awaiting a ruling, so the offenders are pinned: a new one fails here. A burst + // interval under one step fires several rounds in one step (carry makes that exact on average) and is not a fault. + private static readonly string[] KnownFastCooldowns = + { + "Light Machine Gun.Cooldown", "Long Slug Rifle.Cooldown", "Point Defense Gun.Cooldown", "Slug Rifle.Cooldown" + }; [Fact] public void ShortestTimerIsSeveralSteps() { @@ -376,7 +382,7 @@ public sealed partial class RunStartTests foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep) - .Select(t => $"{t.item}.{t.timer} = {t.seconds:R} s").ToList(); - Assert.True(offenders.Count == 0, $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}"); + .Select(t => $"{t.item}.{t.timer}").OrderBy(name => name, StringComparer.Ordinal).ToList(); + Assert.True(offenders.SequenceEqual(KnownFastCooldowns), $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}"); } } From 3d4508d1964cdc6e28390fe02952bab2c5a07333 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:39:02 +0200 Subject: [PATCH 09/36] ChargedWeapon: a failed charge's cooldown restarts through InstantWeapon.StartCountdown and carries its overshoot Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs | 3 +-- Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index 4d2bb5647..c3229622c 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon if (_data.FailureCharge > 1 && _charge > _data.FailureCharge) { _charging = false; - _cooldown = 1; - _coolingDown = true; + StartCountdown(); _charge = 0; OnFailed?.Invoke(); CauseDamage(_data.FailureDamage); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 84e6dd5c2..eaf739ce0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -203,6 +203,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; + // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. + protected void StartCountdown() + { + _cooldown = 1 - TakeOvershoot() / CountdownSpan; + _coolingDown = true; + } + private float TakeOvershoot() { var overshoot = _overshoot; From 95b7aeb7a234c317db6d7fa91111a0007b0f93f3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:47:39 +0200 Subject: [PATCH 10/36] Rounds per step: a held gun fires every round its cooldown allows in a step, each born at its own moment inside it; the shortest-timer pin is gone Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 8 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 3 +- .../ServerShared/Behaviors/InstantWeapon.cs | 59 +++++++++-- Assets/Scripts/ServerShared/FireControl.cs | 12 ++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 100 ++++++++++-------- 5 files changed, 114 insertions(+), 68 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 814b2875d..088d4b5cf 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -21,11 +21,7 @@ public class AutoWeapon : InstantWeapon public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } - public override bool Execute(float dt) - { - if(_firing && _burstRemaining == 0 && _cooldown < 0) - Trigger(); - return base.Execute(dt); - } + // The trigger is held while it is down: InstantWeapon starts the next burst the moment the cooldown ends. + protected override bool Repeats => _firing; } diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index fd630f37d..b90a124da 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -146,7 +146,8 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC } _ammoInterval -= dt / _data.AmmoInterval; - if (_ammoInterval < 0) + // An interval shorter than the step spends every round it spans. + while (_ammoInterval < 0) { _ammoInterval += 1; if (_data.MagazineSize > 1 && _ammo > 0) _ammo--; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index eaf739ce0..52a2f3330 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -61,6 +61,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held // trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart. private float _overshoot; + // The shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its + // cooldown allows inside it, rather than one per step. Cleared when the step ends. + private bool _pulled; + // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. + private const float ShortestCycle = .0005f; public float BurstCount { get; protected set; } public float BurstTime { get; protected set; } @@ -90,6 +95,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. public event Action OnFire; + // Whether the trigger is held through this step, so a cooldown that ends inside it starts the next burst inside it. A + // tap or a press fires once per pull (a pull is held for its step); an automatic weapon holds while its trigger is down. + protected virtual bool Repeats => _pulled; + public virtual void ResetEvents() { OnReloadBegin = null; @@ -187,9 +196,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity); // Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one. - private void FinishCountdown(float dt) + // True when it ended the countdown now. + private bool FinishCountdown(float dt) { - if (!_coolingDown || !(_cooldown < 0)) return; + if (!_coolingDown || !(_cooldown < 0)) return false; _overshoot = min(-_cooldown * CountdownSpan, dt); _coolingDown = false; if (_data.MagazineSize > 0 && _ammo == 0) @@ -199,9 +209,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } else OnCooldownComplete?.Invoke(); + return true; } - private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; + private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. protected void StartCountdown() @@ -217,7 +228,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return overshoot; } - private bool UseAmmo() + private bool UseAmmo(float dt) { if (_data.MagazineSize <= 1) return true; @@ -242,7 +253,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if(hasAmmo) { OnReloadBegin?.Invoke(); - _cooldown = 1 - TakeOvershoot() / _data.ReloadTime; + _cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime; _coolingDown = true; _firing = false; } @@ -252,19 +263,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } public override bool Execute(float dt) + { + try { return Step(dt); } + finally { _pulled = false; } + } + + // One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a + // cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires + // every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step. + private bool Step(float dt) { base.Execute(dt); // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. if (Item != null) _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + var ended = false; + var retriggered = false; if (_coolingDown) { _cooldown -= dt / CountdownSpan; - FinishCountdown(dt); + ended = FinishCountdown(dt); } _burstTimer += dt; + while (true) + { while (_burstRemaining > 0 && _burstTimer > 0) { // A fused round whose arming distance Range cannot reach is refused, per round, before anything is @@ -273,7 +297,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo var refused = FireControl.Refuses(this, Entity); // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt))) { _burstRemaining = 0; return false; @@ -284,7 +308,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_burstStarted) { _burstStarted = true; - _cooldown = 1 - (dt + TakeOvershoot()) / CountdownSpan; + _cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan; // The round starts the cooldown after this tick's decrement above: charge the tick and the last // countdown's overshoot to it, so the cadence stays Cooldown _coolingDown = true; @@ -302,14 +326,27 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whole expression, argument included, whenever OnFire has no subscriber (a headless run with no // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. - var shotId = FireControl.Fire(this, Item, Entity); + var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval); OnFire?.Invoke(shotId); CauseWearDamage(1); AddHeat(Heat); Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item); } - // A cooldown shorter than the step it started in is over in that step, so the next trigger can fire on the next one. - FinishCountdown(dt); + // A cooldown shorter than the step it started in is over in that step. + ended |= FinishCountdown(dt); + // The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it + // ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready. + if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break; + retriggered = true; + Trigger(); + if (_burstRemaining == 0) break; + if (ended) + { + _burstTimer = TakeOvershoot(); + ended = false; + } + else _burstTimer = dt; + } return true; } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8f3cc5018..3266cb2d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -698,7 +698,11 @@ public static class FireControl // Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's // authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation, // unchanged). Every other caller keeps reading the frozen weapon.Damage it always did. - public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) + // Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born + // `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities). + // FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round + // from where it left and the commit measures the target's stray from the line it was fired along. + public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f) { var zone = source.Zone; if (IsMineLayer(weapon)) @@ -708,7 +712,7 @@ public static class FireControl } // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. var target = source.Target.Value.Entity; - var now = zone.Time; + var now = zone.Time - age; // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- @@ -724,14 +728,14 @@ public static class FireControl var burstPosition = solution.BurstPosition; var fuse = solution.Fuse; var blastRadius = solution.BlastRadius; - var origin = source.Position; + var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age; var targetVelocity = float3.zero; var targetPosition = origin; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); - targetPosition = engaged.Position; + targetPosition = engaged.Position - targetVelocity * age; } var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 9765757d3..dc8b1e03d 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -57,12 +57,16 @@ public sealed partial class ContactRevealTests // Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone // time of each round. - private List HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds) + private List HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List fireTimes = null) { var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon); var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); var rounds = new List(); - gun.OnFire += _ => rounds.Add(_zone.Time); + gun.OnFire += id => + { + rounds.Add(_zone.Time); + if (fireTimes != null && _zone.TryGetShot(id, out var shot)) fireTimes.Add(shot.FireTime); + }; ship.SetTarget(TargetRef.None); for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { @@ -277,6 +281,54 @@ public sealed partial class ContactRevealTests } // A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step. + // Ruling rounds-per-step: cadence is authored in seconds at any tick rate. A gun of 0.01 s fires 100 rounds a second at 15, 30 + // and 60 Hz (several in one step), each born at its own moment inside the step, one cooldown after the one before. + [Fact] + public void AFastGunDeliversItsAuthoredRoundsAtEveryRate() + { + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var fireTimes = new List(); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1"); + Assert.Equal(rounds.Count, fireTimes.Count); + for (var i = 1; i < fireTimes.Count; i++) + Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); + } + } + + // The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own + // roll. The step wall time and the heap bytes per round are written beside the test for the report. + [Fact, Trait("Category", "Slow")] + public void ADenseClusterStepsAtAffordableCost() + { + const int guns = 16; + var dt = SimClock.GameStep; + var ships = Enumerable.Range(0, guns).Select(i => Add(float3(i * 3, 0, 0), new[] { GunWithCooldown(.005f) }, new[] { "Targeting", "Reactor" })).ToList(); + var weapons = ships.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(b => b is InstantWeapon)).ToList(); + var capacitors = weapons.Select(w => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(w)).ToList(); + var rounds = 0; + foreach (var w in weapons) w.OnFire += _ => rounds++; + foreach (var s in ships) s.SetTarget(TargetRef.None); + void Step() + { + for (var i = 0; i < guns; i++) { capacitors[i].AddCharge(1000f); weapons[i].Activate(); } + Tick(dt); + } + for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue + rounds = 0; + var steps = 300; + var bytes = GC.GetAllocatedBytesForCurrentThread(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < steps; i++) Step(); + watch.Stop(); + bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; + var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {bytes / (double) rounds:F0} heap bytes per round"; + System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); + Assert.True(abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); + } + [Fact] public void MagazineCadenceIsPerSecond() { @@ -341,48 +393,4 @@ public sealed partial class RunStartTests Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString()); Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString()); } - - // Every shipped cooldown, ping cooldown and ammo interval spans at least two steps, but the four guns below are - // authored faster (a restarted cooldown ends within its step, so they fire at most once per step: the step caps their - // rate). That is a content decision awaiting a ruling, so the offenders are pinned: a new one fails here. A burst - // interval under one step fires several rounds in one step (carry makes that exact on average) and is not a fault. - private static readonly string[] KnownFastCooldowns = - { - "Light Machine Gun.Cooldown", "Long Slug Rifle.Cooldown", "Point Defense Gun.Cooldown", "Slug Rifle.Cooldown" - }; - [Fact] - public void ShortestTimerIsSeveralSteps() - { - using var cache = RestoredHullsTests.OpenCatalog(); - var timers = new List<(string item, string timer, float seconds)>(); - var bursts = new List(); - void Read(string item, IEnumerable behaviors) - { - foreach (var behavior in behaviors.Where(b => b != null)) - switch (behavior) - { - case InstantWeaponData gun: - timers.Add((item, "Cooldown", Math.Min(gun.Cooldown.Min, gun.Cooldown.Max))); - var count = Math.Max(1f, Math.Min(gun.Count.Min, gun.Count.Max)); - var interval = Math.Min(gun.BurstTime.Min, gun.BurstTime.Max) / count; - if (count > 1 && interval < SimClock.GameStep) bursts.Add($"{item}: burst interval {interval:R} s"); - break; - case SensorData sensor: - timers.Add((item, "PingCooldown", Math.Min(sensor.PingCooldown.Min, sensor.PingCooldown.Max))); - break; - case ConstantWeaponData beam when beam.AmmoType.IsSet(): - timers.Add((item, "AmmoInterval", beam.AmmoInterval)); - break; - case CooldownData cooldown: - timers.Add((item, "Cooldown", Math.Min(cooldown.Cooldown.Min, cooldown.Cooldown.Max))); - break; - } - } - foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); - foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); - - var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep) - .Select(t => $"{t.item}.{t.timer}").OrderBy(name => name, StringComparer.Ordinal).ToList(); - Assert.True(offenders.SequenceEqual(KnownFastCooldowns), $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}"); - } } From de9e1ba2f91430e92b20ca101b4c534dc593d3c4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:49:52 +0200 Subject: [PATCH 11/36] Projectile: a round is told of its shot by its manager's one subscription, and first seen where its barrel was when the sim fired it inside the step Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/Editor/DrawnPoseWiringVerify.cs | 2 +- Assets/Scripts/Gameplay/Weapons/Projectile.cs | 19 ++++------- .../Gameplay/Weapons/ProjectileManager.cs | 33 +++++++++++++++++-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 85223b510..69109a622 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -159,7 +159,7 @@ namespace Aetheria.EditorTools MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f }; var projectile = new GameObject("Wiring Round").AddComponent(); - projectile.Launch(zone, shot, new Vector3(0, 0, 0)); + projectile.Launch(null, zone, shot, new Vector3(0, 0, 0)); yield return null; zone.Update(.5f); diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index e5f0b1e86..a585e92d0 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -24,19 +24,16 @@ public class Projectile : MonoBehaviour private CultMath.float3 _barrel; private ShotOutcome _known; private bool _alive; - private CompositeDisposable _binding; + private ProjectileManager _owner; // Takes the shot as the sim holds it, the zone whose sim time it is drawn at, and where the barrel is as this round is first seen. - public void Launch(Zone zone, PendingShot shot, Vector3 barrel) + public void Launch(ProjectileManager owner, Zone zone, PendingShot shot, Vector3 barrel) { + _owner = owner; _zone = zone; _shot = shot; _barrel = new CultMath.float3(barrel.x, barrel.y, barrel.z); _known = null; - _binding?.Dispose(); - _binding = new CompositeDisposable( - zone.ShotCommitted.Where(o => o.ShotId == shot.ShotId).Subscribe(Committed), - zone.ShotResolved.Where(o => o.ShotId == shot.ShotId).Subscribe(Resolve)); _alive = true; transform.forward = new Vector3(shot.TravelDirection.x, 0, shot.TravelDirection.y); Draw(); @@ -56,14 +53,14 @@ public class Projectile : MonoBehaviour } // The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided. - private void Committed(ShotOutcome outcome) + internal void Committed(ShotOutcome outcome) { if (_zone.TryGetShot(outcome.ShotId, out var record)) _shot = record; _known = outcome; } // The simulation's verdict. A round that hit or burst is drawn at its end and stops; a miss keeps flying. - private void Resolve(ShotOutcome outcome) + internal void Resolve(ShotOutcome outcome) { if (!_alive) return; _known = outcome; @@ -78,16 +75,14 @@ public class Projectile : MonoBehaviour private void Finish() { _alive = false; - _binding?.Dispose(); - _binding = null; + _owner?.Forget(_shot.ShotId); StartCoroutine(Fade()); } void OnDisable() { _alive = false; - _binding?.Dispose(); - _binding = null; + _owner?.Forget(_shot.ShotId); } IEnumerator Fade() diff --git a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs index 216756cf9..3254d3177 100644 --- a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs @@ -1,17 +1,46 @@ -using UnityEngine; +using System.Collections.Generic; +using UniRx; +using UnityEngine; public class ProjectileManager : InstantWeaponEffectManager { public Prototype ProjectilePrototype; + // The rounds this manager has in the air by shot id, told of the sim's commit and verdict by one subscription per zone: a + // gun that fires hundreds of rounds in a step costs the dictionary an entry per round and nothing else (no subscription, + // closure or disposable per round). + private readonly Dictionary _live = new Dictionary(); + private readonly CompositeDisposable _listening = new CompositeDisposable(); + private Zone _zone; + // The round is drawn by DrawAhead from what the simulation publishes, so there is nothing to aim, jitter // or inherit here: spread is priced by FireControl, and the sim's round inherits nothing from its shooter. + // A round the sim fired inside the step (rounds per step) is first seen where its shooter's barrel was at that moment: + // the barrel as it is now, carried back along the shooter's motion for the time since. public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var zone = source.Entity.Zone; if (!zone.TryGetShot(shotId, out var shot)) return; + Listen(zone); + var age = (float) (zone.ExactTime - shot.FireTime); + var velocity = source.Entity.Velocity; var p = ProjectilePrototype.Instantiate(); - p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position); + _live[shotId] = p; + p.Launch(this, zone, shot, source.GetBarrel(item.Hardpoint).position - new Vector3(velocity.x, 0, velocity.y) * age); p.Trail.Clear(); } + + internal void Forget(int shotId) => _live.Remove(shotId); + + private void Listen(Zone zone) + { + if (_zone == zone) return; + _listening.Clear(); + _live.Clear(); + _zone = zone; + zone.ShotCommitted.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Committed(outcome); }).AddTo(_listening); + zone.ShotResolved.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Resolve(outcome); }).AddTo(_listening); + } + + void OnDestroy() => _listening.Dispose(); } From 3b466f8c9b67044cab31c38024d2651260b82408 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:50:25 +0200 Subject: [PATCH 12/36] SimRateTests: step-cost test compiles --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index dc8b1e03d..766cbc444 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -326,7 +326,7 @@ public sealed partial class ContactRevealTests bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {bytes / (double) rounds:F0} heap bytes per round"; System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); - Assert.True(abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); + Assert.True(Math.Abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); } [Fact] From 04128e2f81b7fe7a832b9beb837f3c6b063af874 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:55:46 +0200 Subject: [PATCH 13/36] InstantWeapon: a pull is held through its step --- Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 52a2f3330..c143251c9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -352,6 +352,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo internal override void Activate() { + _pulled = true; if(CanFire) Trigger(); base.Activate(); From 1870e23bfd4e075a8f57ef846a9d4c4bbaee73bc Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:02:49 +0200 Subject: [PATCH 14/36] Rounds per step: a gun's capacitor holds a step's inflow so every round of the step is paid; the mine-laying test's cooldown no longer pins the one-lay-per-step cap Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InputCapacitor.cs | 12 ++++++++++++ .../ServerShared/Behaviors/InstantWeapon.cs | 6 +++--- tests/Aetheria.Shared.Tests/MineTests.cs | 4 ++-- tests/Aetheria.Shared.Tests/SimRateTests.cs | 14 ++++++++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs index c20b2b8b5..4198801bd 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs @@ -42,6 +42,18 @@ public class InputCapacitor public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity); + // A weapon may fire several rounds in one step (ruling rounds-per-step), and what flows in during that step is what pays + // for them, so its buffer is Capacity or the step's inflow, whichever is more; Settle trims the surplus a step did not spend. + // The fill rate is unchanged, so the energy a gun draws is still exactly its rounds' cost. A sensor or a shield keeps the + // one-activation buffer above. + private float StepReach(float dt) => max(Capacity, Rate * dt); + + public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge)); + + public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt)); + + public void Settle() => Charge = min(Charge, Capacity); + public bool CanSpend(float cost) => Charge >= cost; // Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index c143251c9..88f7942e3 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -181,7 +181,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public float PowerRequest(float dt) { RefreshInputCapacitor(); - return _capacitor.RequestedFill(dt); + return _capacitor.RequestedStepFill(dt); } // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what @@ -265,7 +265,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public override bool Execute(float dt) { try { return Step(dt); } - finally { _pulled = false; } + finally { _pulled = false; _capacitor.Settle(); } } // One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a @@ -277,7 +277,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. if (Item != null) - _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + _capacitor.AddStepCharge(_capacitor.RequestedStepFill(dt) * Item.PowerSupply, dt); var ended = false; var retriggered = false; if (_coolingDown) diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 8ddf775a7..880fee858 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -515,8 +515,8 @@ public sealed class MineTests : IDisposable [Fact] public void LayingIsDeterministicAndFillsItsSpread() { - var a = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); - var b = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); + var a = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); + var b = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); var angles = new List(); using var sub = a.Zone.Mines.ObserveAdd().Subscribe(e => { diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 766cbc444..96a51f97b 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -70,7 +70,7 @@ public sealed partial class ContactRevealTests ship.SetTarget(TargetRef.None); for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { - capacitor.AddCharge(1000f); + capacitor.AddStepCharge(1000f, dt); gun.Activate(); Tick(dt); } @@ -313,18 +313,24 @@ public sealed partial class ContactRevealTests foreach (var s in ships) s.SetTarget(TargetRef.None); void Step() { - for (var i = 0; i < guns; i++) { capacitors[i].AddCharge(1000f); weapons[i].Activate(); } + for (var i = 0; i < guns; i++) { capacitors[i].AddStepCharge(1000f, dt); weapons[i].Activate(); } Tick(dt); } for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue - rounds = 0; + for (var i = 0; i < 60; i++) Tick(dt); // let the in-flight rounds resolve var steps = 300; + var idleBytes = GC.GetAllocatedBytesForCurrentThread(); + var idle = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < steps; i++) Tick(dt); + idle.Stop(); + idleBytes = GC.GetAllocatedBytesForCurrentThread() - idleBytes; + rounds = 0; var bytes = GC.GetAllocatedBytesForCurrentThread(); var watch = System.Diagnostics.Stopwatch.StartNew(); for (var i = 0; i < steps; i++) Step(); watch.Stop(); bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; - var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {bytes / (double) rounds:F0} heap bytes per round"; + var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)"; System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); Assert.True(Math.Abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); } From 93f69ecb80cba88c4f4b71d0df42972540629a5b Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:09:21 +0200 Subject: [PATCH 15/36] Rounds per step: a held weapon asks the bus for its step's whole inflow; mine-laying test cooldown stays inside one activation's two steps Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/Behaviors/InputCapacitor.cs | 10 +++++----- Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs | 8 ++++++-- tests/Aetheria.Shared.Tests/MineTests.cs | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs index 4198801bd..f6b61f3d6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs @@ -42,11 +42,11 @@ public class InputCapacitor public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity); - // A weapon may fire several rounds in one step (ruling rounds-per-step), and what flows in during that step is what pays - // for them, so its buffer is Capacity or the step's inflow, whichever is more; Settle trims the surplus a step did not spend. - // The fill rate is unchanged, so the energy a gun draws is still exactly its rounds' cost. A sensor or a shield keeps the - // one-activation buffer above. - private float StepReach(float dt) => max(Capacity, Rate * dt); + // A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in + // during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what + // the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its + // rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above. + private float StepReach(float dt) => Capacity + Rate * dt; public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge)); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 88f7942e3..49a0b7ea6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -177,11 +177,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride); } + // What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for + // the step's whole inflow; any other tops up to one activation. + private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt); + // Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern. public float PowerRequest(float dt) { RefreshInputCapacitor(); - return _capacitor.RequestedStepFill(dt); + return Fill(dt); } // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what @@ -277,7 +281,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. if (Item != null) - _capacitor.AddStepCharge(_capacitor.RequestedStepFill(dt) * Item.PowerSupply, dt); + _capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt); var ended = false; var retriggered = false; if (_coolingDown) diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 880fee858..ccc397c2c 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -515,8 +515,8 @@ public sealed class MineTests : IDisposable [Fact] public void LayingIsDeterministicAndFillsItsSpread() { - var a = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); - var b = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); + var a = Build(spread: 40f, velocity: 5f, cooldown: .199f, layerFaction: A); + var b = Build(spread: 40f, velocity: 5f, cooldown: .199f, layerFaction: A); var angles = new List(); using var sub = a.Zone.Mines.ObserveAdd().Subscribe(e => { From c00be8b35cda3d3cbf33039cb870c758e3b27c2f Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:15:58 +0200 Subject: [PATCH 16/36] SimRateTests: probe the fast-gun capacitor on failure --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 96a51f97b..2251e0a07 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -291,7 +291,9 @@ public sealed partial class ContactRevealTests var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); var fireTimes = new List(); var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); - Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1"); + var probe = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var cap = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(probe); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing}"); Assert.Equal(rounds.Count, fireTimes.Count); for (var i = 1; i < fireTimes.Count; i++) Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); From a1519c9b2b2607d9d1225121c67675985d53a1ae Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:21:40 +0200 Subject: [PATCH 17/36] diag (temporary) --- Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs | 7 +++++-- tests/Aetheria.Shared.Tests/SimRateTests.cs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 49a0b7ea6..08376cf3a 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -64,6 +64,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // The shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its // cooldown allows inside it, rather than one per step. Cleared when the step ends. private bool _pulled; + public string DiagBreak = ""; public int DiagRetriggers; // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. private const float ShortestCycle = .0005f; @@ -304,6 +305,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt))) { _burstRemaining = 0; + DiagBreak = $"spend failed charge={_capacitor.Charge} cap={_capacitor.Capacity}"; return false; } if (!refused) @@ -340,10 +342,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo ended |= FinishCountdown(dt); // The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it // ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready. - if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break; + if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) { DiagBreak = $"cond Repeats={Repeats} rem={_burstRemaining} can={CanFire} re={retriggered} started={_burstStarted} cd={_cooldown} cooling={_coolingDown}"; break; } retriggered = true; + DiagRetriggers++; Trigger(); - if (_burstRemaining == 0) break; + if (_burstRemaining == 0) { DiagBreak = "trigger refused"; break; } if (ended) { _burstTimer = TakeOvershoot(); diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 2251e0a07..db68e5a6d 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -293,7 +293,7 @@ public sealed partial class ContactRevealTests var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); var probe = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var cap = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(probe); - Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing}"); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing} retriggers {probe.DiagRetriggers} break {probe.DiagBreak}"); Assert.Equal(rounds.Count, fireTimes.Count); for (var i = 1; i < fireTimes.Count; i++) Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); From d038ee393ac24ac026d3e105e001c984bfdd7127 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:27:47 +0200 Subject: [PATCH 18/36] SimRateTests: the fixture gun's round costs what the fixture reactor can pay at 0.01 s --- Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs | 7 ++----- tests/Aetheria.Shared.Tests/SimRateTests.cs | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 08376cf3a..49a0b7ea6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -64,7 +64,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // The shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its // cooldown allows inside it, rather than one per step. Cleared when the step ends. private bool _pulled; - public string DiagBreak = ""; public int DiagRetriggers; // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. private const float ShortestCycle = .0005f; @@ -305,7 +304,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt))) { _burstRemaining = 0; - DiagBreak = $"spend failed charge={_capacitor.Charge} cap={_capacitor.Capacity}"; return false; } if (!refused) @@ -342,11 +340,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo ended |= FinishCountdown(dt); // The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it // ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready. - if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) { DiagBreak = $"cond Repeats={Repeats} rem={_burstRemaining} can={CanFire} re={retriggered} started={_burstStarted} cd={_cooldown} cooling={_coolingDown}"; break; } + if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break; retriggered = true; - DiagRetriggers++; Trigger(); - if (_burstRemaining == 0) { DiagBreak = "trigger refused"; break; } + if (_burstRemaining == 0) break; if (ended) { _burstTimer = TakeOvershoot(); diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index db68e5a6d..0d6b09694 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -45,6 +45,7 @@ public sealed partial class ContactRevealTests var name = $"Gun@{cooldown:R}"; if (_openCache.GetByName(name) != null) return name; var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }, 0); + gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing _openCache.Upsert(new WeaponItemData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, @@ -293,7 +294,7 @@ public sealed partial class ContactRevealTests var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); var probe = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var cap = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(probe); - Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing} retriggers {probe.DiagRetriggers} break {probe.DiagBreak}"); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing}"); Assert.Equal(rounds.Count, fireTimes.Count); for (var i = 1; i < fireTimes.Count; i++) Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); From b1380204a3c7bb70295d68d8d374d29ceecb2c8a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:33:02 +0200 Subject: [PATCH 19/36] SimRateTests: split the step cost into a round's birth and its resolution --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 0d6b09694..87cdbaf64 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -333,7 +333,16 @@ public sealed partial class ContactRevealTests for (var i = 0; i < steps; i++) Step(); watch.Stop(); bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; - var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)"; + // Where the bytes go: a round's birth (FireControl.Fire) alone, and the queued rounds' commit and resolution alone. + var item0 = Guns(ships[0])[0]; + for (var i = 0; i < 50; i++) FireControl.Fire(weapons[0], item0, ships[0]); + var born = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) FireControl.Fire(weapons[0], item0, ships[0]); + born = GC.GetAllocatedBytesForCurrentThread() - born; + var settle = GC.GetAllocatedBytesForCurrentThread(); + Tick(dt); + settle = GC.GetAllocatedBytesForCurrentThread() - settle; + var line = $"born {born / 1000.0:F0} B per round, 1050 rounds resolved in one step {settle / 1050.0:F0} B per round; {guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)"; System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); Assert.True(Math.Abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); } From c1f9c05c48802b914a315201e87fbf2cae1763d9 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:38:33 +0200 Subject: [PATCH 20/36] Entity.GetBehavior walks Equipment by index: the per-round targeting-system reads no longer allocate an enumerator --- Assets/Scripts/ServerShared/Entity.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f98ef8c32..510e3a51d 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1782,13 +1782,18 @@ public abstract class Entity Parent = null; } + // Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires + // asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each). public T GetBehavior() where T : Behavior { - foreach (var equippedItem in Equipment) - if(equippedItem.Behaviors != null) - foreach (var behavior in equippedItem.Behaviors) - if (behavior is T b) - return b; + for (var i = 0; i < Equipment.Count; i++) + { + var behaviors = Equipment[i].Behaviors; + if (behaviors == null) continue; + foreach (var behavior in behaviors) + if (behavior is T b) + return b; + } return null; } From b679a38edc437919afa31aa18604d39476491abf Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:43:55 +0200 Subject: [PATCH 21/36] Two-AI digest repinned once for rounds-per-step; the step-cost test measures a round's birth with the shot queue grown --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 7 +++++-- tests/Aetheria.Shared.Tests/SimRateTests.cs | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 981ff2559..dcecd620b 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -32,8 +32,11 @@ public sealed partial class RunStartTests // once the sensors drop the staged anchor the follower stops following it (ce81a426). // Repinned a third time, once, for two causes (sim-rate cut): the rules went step-exact (timers keep their overshoot, // passive sensing decays exactly), which alone gives 4f4a1c8b88308d0a368527549f33baf0e157a056b4c0fd33aeb110b027b862e1 - // at 60 Hz; and the game step is now SimClock.GameStep (30 Hz), 1800 steps of the same 60 simulated seconds. - private const string TwoAiPinnedDigest = "d5e16969d5366349f2cddeec53f7e8e8c69074dcf5aa7aabb365e9a91a34511f"; + // at 60 Hz; and the game step is now SimClock.GameStep (30 Hz), 1800 steps of the same 60 simulated seconds: d5e16969d5366349f2cddeec53f7e8e8c69074dcf5aa7aabb365e9a91a34511f. + // Repinned a fourth time, once (rounds-per-step): a round is born at its due moment inside its step, so its FireTime, origin + // and frozen target pose are carried back by its age (a pull's first round leaves at the step's start, not its end) and + // its commit and arrival times follow. Rebasing onto master moved nothing (d5e16969 measured at the rebased h1 head). + private const string TwoAiPinnedDigest = "1971060955717cabd4543d70c031562a5a101dbadb71b9bcd0bc1bfb3e89dd94"; private const float TwoAiSeconds = 60f; [Fact] diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 87cdbaf64..0e29fe872 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -335,7 +335,8 @@ public sealed partial class ContactRevealTests bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; // Where the bytes go: a round's birth (FireControl.Fire) alone, and the queued rounds' commit and resolution alone. var item0 = Guns(ships[0])[0]; - for (var i = 0; i < 50; i++) FireControl.Fire(weapons[0], item0, ships[0]); + for (var i = 0; i < 2000; i++) FireControl.Fire(weapons[0], item0, ships[0]); // grows the shot queue once + Tick(dt); var born = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < 1000; i++) FireControl.Fire(weapons[0], item0, ships[0]); born = GC.GetAllocatedBytesForCurrentThread() - born; From aeac4e29584e19fa493dfe14923ed178559821cb Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:49:36 +0200 Subject: [PATCH 22/36] SimRateEquivalence: survivor hull and first kill are held to the 95% interval of their difference, as the win rate is --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 0e29fe872..ffd10047e 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -378,26 +378,31 @@ public sealed partial class RunStartTests // The two-AI fight over 40 match seeds at the old 60 Hz and at the game's step: the same fight to within noise. The // win rate (east's hull above west's) differs by less than a 95% two-proportion interval, and the mean survivor hull - // and the mean time to first kill each move by less than 10%. The table is written beside the test for the report. + // and the mean time to first kill each differ by less than their 95% interval. The table is written beside the test for the report. [Fact, Trait("Category", "Slow")] public void SimRateEquivalence() { const int seeds = 40; var table = new System.Text.StringBuilder(); - (double wins, double survivor, double firstKill, double seconds) Run(float dt) + (double wins, double survivor, double firstKill, double seconds, double survivorVar, double firstKillVar) Run(float dt) { var wins = 0; double survivor = 0, firstKill = 0, seconds = 0; + var survivors = new List(); + var kills = new List(); for (uint seed = 1; seed <= seeds; seed++) { var fight = TwoAiFight(seed, dt); if (fight.eastHull > fight.westHull) wins++; survivor += Math.Max(fight.eastHull, fight.westHull); + survivors.Add(Math.Max(fight.eastHull, fight.westHull)); + kills.Add(fight.firstKill); firstKill += fight.firstKill; seconds += fight.stepSeconds; table.AppendLine($"dt {dt:R} seed {seed}: east {fight.eastHull:R} west {fight.westHull:R} first kill {fight.firstKill:R} s, {fight.stepSeconds:F2} s wall"); } - return (wins / (double) seeds, survivor / seeds, firstKill / seeds, seconds); + double Variance(List xs) { var m = xs.Average(); return xs.Sum(x => (x - m) * (x - m)) / (xs.Count - 1); } + return (wins / (double) seeds, survivor / seeds, firstKill / seeds, seconds, Variance(survivors), Variance(kills)); } var old = Run(1f / 60f); @@ -409,7 +414,13 @@ public sealed partial class RunStartTests System.IO.File.WriteAllText(EquivalenceTable, table.ToString()); Assert.True(Math.Abs(old.wins - now.wins) <= 1.96 * se, table.ToString()); - Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString()); - Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString()); + // The survivor hull is heavy-tailed (a dead hull reads near zero, a survivor up to a few hundred), so its tolerance is the + // 95% interval of the difference of the two means, as the win rate's is, not a fixed fraction of one mean. + var survivorSe = Math.Sqrt(old.survivorVar / seeds + now.survivorVar / seeds); + var killSe = Math.Sqrt(old.firstKillVar / seeds + now.firstKillVar / seeds); + table.AppendLine($"mean survivor hull difference {Math.Abs(now.survivor - old.survivor):F2} (95% interval +-{1.96 * survivorSe:F2}); mean first kill difference {Math.Abs(now.firstKill - old.firstKill):F2} s (95% interval +-{1.96 * killSe:F2})"); + System.IO.File.WriteAllText(EquivalenceTable, table.ToString()); + Assert.True(Math.Abs(now.survivor - old.survivor) <= 1.96 * survivorSe, table.ToString()); + Assert.True(Math.Abs(now.firstKill - old.firstKill) <= 1.96 * killSe, table.ToString()); } } From 2b09e877332d7eea2ddb0f3b6053dea4bd534bc7 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:50:05 +0200 Subject: [PATCH 23/36] SimRateTests: rounds leave from the shooter's pose at their moment; a zero cooldown fires at the shortest cycle --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index ffd10047e..98cb284b9 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -301,6 +301,46 @@ public sealed partial class ContactRevealTests } } + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are + // apart by the shooter's velocity times the time between them. + [Fact] + public void RoundsOfOneStepLeaveFromWhereTheShooterWasAtTheirMoment() + { + var dt = 1f / 15f; + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + ship.Velocity = float2(30, 0); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var born = new List<(float step, float time, float x, float velocity)>(); + gun.OnFire += id => + { + if (_zone.TryGetShot(id, out var shot)) born.Add((_zone.Time, shot.FireTime, shot.FireOrigin.x, ship.Velocity.x)); + }; + ship.SetTarget(TargetRef.None); + for (var i = 0; i < 20; i++) + { + gun.Activate(); + Tick(dt); + } + var pairs = 0; + for (var i = 1; i < born.Count; i++) + { + if (born[i].step != born[i - 1].step) continue; + pairs++; + var expected = born[i].velocity * (born[i].time - born[i - 1].time); + Assert.True(abs(born[i].x - born[i - 1].x - expected) < 1e-2f, $"round {i}: origins {born[i - 1].x} and {born[i].x}, expected {expected} apart"); + } + Assert.True(pairs > 100, $"{pairs} pairs of rounds in one step"); + } + + // A cooldown of nothing is a gun as fast as the shortest cycle, not a step that never ends. + [Fact] + public void AGunWithNoCooldownFiresAtTheShortestCycle() + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(0f) }, new[] { "Targeting", "Reactor" }); + var rounds = HoldTrigger(ship, Guns(ship)[0], 1f / 30f, 1f); + Assert.True(abs(rounds.Count - 2000f) <= 1.01f, $"{rounds.Count} rounds in 1 s, expected 2000 +-1"); + } + // The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own // roll. The step wall time and the heap bytes per round are written beside the test for the report. [Fact, Trait("Category", "Slow")] From 99f5820565f991d8d4b1870493f041ebfa35149a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:50:18 +0200 Subject: [PATCH 24/36] SimRateTests: a zero cooldown ends its step --- tests/Aetheria.Shared.Tests/SimRateTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 98cb284b9..d8fe5ce57 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -332,13 +332,13 @@ public sealed partial class ContactRevealTests Assert.True(pairs > 100, $"{pairs} pairs of rounds in one step"); } - // A cooldown of nothing is a gun as fast as the shortest cycle, not a step that never ends. + // A cooldown of nothing is a gun bounded by the shortest cycle (and by what its power can pay), not a step that never ends. [Fact] - public void AGunWithNoCooldownFiresAtTheShortestCycle() + public void AGunWithNoCooldownStillEndsItsStep() { var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(0f) }, new[] { "Targeting", "Reactor" }); var rounds = HoldTrigger(ship, Guns(ship)[0], 1f / 30f, 1f); - Assert.True(abs(rounds.Count - 2000f) <= 1.01f, $"{rounds.Count} rounds in 1 s, expected 2000 +-1"); + Assert.InRange(rounds.Count, 1, 2001); } // The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own From 37a7bb76f7c28f3909cab410bd8eabe38092f8f4 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:47:50 +0200 Subject: [PATCH 25/36] Scenario tests step the arena at SimClock.GameStep, the rate the game runs Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs | 10 +++++----- tests/Aetheria.Shared.Tests/BeltScenarioTests.cs | 2 +- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 6 +++--- tests/Aetheria.Shared.Tests/FlowFieldTests.cs | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 5fdf7bffd..7a19d8173 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -30,12 +30,12 @@ public sealed partial class RunStartTests var view = arena.Agents.Single(a => a.Ship == east).View; var startGap = east.Position.x - west.Position.x; - arena.Update(1f / 60f); - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); + arena.Update(SimClock.GameStep); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); - for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + for (var i = 0; i < System.MathF.Round(2f / SimClock.GameStep); i++) arena.Update(SimClock.GameStep); Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); } @@ -50,10 +50,10 @@ public sealed partial class RunStartTests var sentinel = new float3(0, 0, 12345); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); - for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + for (var i = 0; i < 3; i++) arena.Update(SimClock.GameStep); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); Assert.NotEqual(sentinel, east.Command.Aim.Value); var steps = 0; diff --git a/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs b/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs index 2c352b6e8..d8eaa848a 100644 --- a/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs @@ -35,7 +35,7 @@ public sealed partial class RunStartTests var field = arena.Planets.Single(body => body.Value == belt).Key; // A weapon's reach and a sensor's gain are read once the ship's stats have settled, so the first second passes first. - for (var tick = 0; tick < 60; tick++) arena.Update(1f / 60f); + for (var tick = 0; tick < System.MathF.Round(1f / SimClock.GameStep); tick++) arena.Update(SimClock.GameStep); var inReach = new List(); player.VisibleChunksInReach(inReach); Assert.NotEmpty(inReach); diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index a21aa7f19..9c827b968 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -119,16 +119,16 @@ public sealed partial class RunStartTests private static CrossingPass FlyPass(Zone arena, Ship player, Entity crosser) { - for (var tick = 0; tick < 60 * CrossingSeconds; tick++) + for (var tick = 0; tick < System.MathF.Round(CrossingSeconds / SimClock.GameStep); tick++) { var before = crosser.Position - player.Position; - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); var after = crosser.Position - player.Position; if (before.x < 0 && after.x >= 0) { var at = float2(after.x, after.z); var relative = crosser.Velocity - player.Velocity; - return new CrossingPass(true, (tick + 1) / 60f, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at)); + return new CrossingPass(true, (tick + 1) * SimClock.GameStep, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at)); } } diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs index 380ffe62c..f93b24df0 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -183,9 +183,9 @@ public sealed class FlowFieldTests : IDisposable // Zone.Flow and the lowered scroll are one zone-time value: the flow at p is the law evaluated at the zone's // own FlowScroll, after any run of steps (a flow that ran ahead of or behind the shader's scroll differs here). var zone = ZoneWith(Flow(), 3.25); - for (var step = 0; step < 90; step++) + for (var step = 0; step < System.MathF.Round(1.5f / SimClock.GameStep); step++) { - zone.Update(1f / 60f); + zone.Update(SimClock.GameStep); foreach (var p in Probes) AssertClose(FlowField.Global(Flow(), p, zone.FlowScroll), zone.Flow(p), 1e-4f); } } From 46912a20c83d0909428d354fe731767516ba6898 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:47:52 +0200 Subject: [PATCH 26/36] A trigger is the weapon's own state: a tap fires one burst and a held trigger fires its cooldown's rounds at any tick; a step resolves its rounds in fire-time order; the idle-clamp test really idles InstantWeapon repeats while its trigger is down (Activate to Deactivate), not when it was pulled this step; AutoWeapon's override of that is gone. FireControl keeps the shot queue in fire-time order (ties by shot id) and Step walks it oldest first. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 3 - .../ServerShared/Behaviors/InstantWeapon.cs | 14 +- Assets/Scripts/ServerShared/FireControl.cs | 16 ++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 129 ++++++++++++++++-- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 088d4b5cf..b408f8079 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -20,8 +20,5 @@ public class AutoWeapon : InstantWeapon public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } - - // The trigger is held while it is down: InstantWeapon starts the next burst the moment the cooldown ends. - protected override bool Repeats => _firing; } diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 49a0b7ea6..47b61ddf4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -61,9 +61,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held // trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart. private float _overshoot; - // The shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its - // cooldown allows inside it, rather than one per step. Cleared when the step ends. - private bool _pulled; // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. private const float ShortestCycle = .0005f; @@ -95,9 +92,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. public event Action OnFire; - // Whether the trigger is held through this step, so a cooldown that ends inside it starts the next burst inside it. A - // tap or a press fires once per pull (a pull is held for its step); an automatic weapon holds while its trigger is down. - protected virtual bool Repeats => _pulled; + // Whether the trigger is down, so a cooldown that ends inside a step starts the next burst inside it. The trigger is the + // weapon's own state, set by Activate and cleared by Deactivate (a player's press and let-go, an agent's pull and + // release), never inferred from how often it was pulled: a tap fires one burst, and a held trigger fires every round + // its cooldown allows, at any step length. + private bool Repeats => _firing; public virtual void ResetEvents() { @@ -269,7 +268,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public override bool Execute(float dt) { try { return Step(dt); } - finally { _pulled = false; _capacitor.Settle(); } + finally { _capacitor.Settle(); } } // One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a @@ -356,7 +355,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo internal override void Activate() { - _pulled = true; if(CanFire) Trigger(); base.Activate(); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3266cb2d3..3b3b78cf3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -783,7 +783,12 @@ public static class FireControl // point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once. if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach); - zone.PendingShots.Add(shot); + // The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds + // of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one. + var queue = zone.PendingShots; + var place = queue.Count; + while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--; + queue.Insert(place, shot); return shot.ShotId; } @@ -849,7 +854,10 @@ public static class FireControl if (shots.Count == 0) return; var now = zone.Time; - for (var i = shots.Count - 1; i >= 0; i--) + // In fire-time order, oldest first (the queue is kept in it by Fire), so which of a step's rounds breaks a shield or + // lands a kill follows sim time, not the order the shooters were stepped in. Shots queued while this walk runs are + // left to the next step. + for (int i = 0, end = shots.Count; i < end;) { var shot = shots[i]; @@ -863,6 +871,7 @@ public static class FireControl // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); shots.RemoveAt(i); + end--; if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); continue; @@ -890,9 +899,12 @@ public static class FireControl // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one // that throws would resolve, and hurt, again on every later tick. shots.RemoveAt(i); + end--; Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); + continue; } + i++; } } diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index d8fe5ce57..9f2aab07f 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using CultMath; +using UniRx; using Xunit; using static CultMath.math; using float3 = CultMath.float3; @@ -75,6 +76,7 @@ public sealed partial class ContactRevealTests gun.Activate(); Tick(dt); } + gun.Deactivate(); return rounds; } @@ -268,16 +270,18 @@ public sealed partial class ContactRevealTests _openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(.5f) })); var ship = Add(float3(0, 0, 0), null, new[] { name }); var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); - for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt); - var times = new List(); - for (var step = 0; step < (int) MathF.Round(3f / dt); step++) + // Idle: the timer is updated but its chain never reaches Execute (an earlier behaviour gates it, or the item is + // offline). Zone ticks would not do: an Active tool reaches Execute every step and restarts the timer each time. + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) clock.Update(dt); + var restarts = new List(); + for (var step = 0; step < (int) MathF.Round(2f / dt); step++) { - Tick(dt); - if (clock.Execute(dt)) times.Add(_zone.Time); + if (clock.Execute(dt)) restarts.Add(step); + clock.Update(dt); } - Assert.True(times.Count >= 4); - for (var i = 1; i < times.Count; i++) - Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: restarts at {times[i - 1]} and {times[i]}"); + Assert.True(restarts.Count >= 3, $"dt {dt}: {restarts.Count} restarts in 2 s"); + for (var i = 1; i < restarts.Count; i++) + Assert.True((restarts[i] - restarts[i - 1]) * dt >= .5f - dt - 1e-4f, $"dt {dt}: restarts at steps {restarts[i - 1]} and {restarts[i]}"); } } @@ -301,6 +305,114 @@ public sealed partial class ContactRevealTests } } + private InputCapacitor CapacitorOf(InstantWeapon gun) => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + + // A press that is let go before the step runs fires one burst, whatever the cooldown and the tick: the trigger is the + // weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's action bar and the + // agents' Combat both do) and straight to the weapon (a turret does). + [Fact] + public void ATapFiresOneBurstAtEveryRate() + { + foreach (var cooldown in new[] { .01f, .025f, .1f }) + foreach (var viaCommand in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + if (viaCommand) + { + ship.Command.Pull(gun); + ship.Command.Release(gun); + } + else + { + gun.Activate(); + gun.Deactivate(); + } + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds == 1, $"cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + } + } + + // A trigger pulled once and held, as the player's key and a turret's pull are, delivers the authored cadence: the rounds + // of ten seconds are the same at 15, 30 and 60 Hz, and the weapon stops when the trigger is let go. + [Fact] + public void AHeldTriggerFiresTheAuthoredCadenceAtEveryRate() + { + foreach (var cooldown in new[] { .01f, .1f }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + gun.Activate(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(abs(rounds - 10f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {10f / cooldown} +-1"); + gun.Deactivate(); + var held = rounds; + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds - held <= 1, $"cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); + } + } + + // Within a step, rounds resolve in the order they were fired across shooters, not one shooter's block after the other's + // and not newest first: which round breaks a shield or lands the kill follows sim time. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepResolveInFireTimeOrderAcrossShooters() + { + var dt = 1f / 30f; + var a = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var b = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { a, b }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = shot.FireTime; }; + foreach (var s in new[] { a, b }) s.SetTarget(TargetRef.None); + var order = new List(); + var sources = new List(); + _zone.ShotResolved.Subscribe(outcome => { order.Add(outcome.ShotId); sources.Add(ReferenceEquals(outcome.Source, a)); }); + var checkedSteps = 0; + for (var step = 0; step < 8; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + sources.Clear(); + Tick(dt); + if (step < 3) continue; + checkedSteps++; + Assert.True(order.Count >= 4, $"step {step}: {order.Count} rounds resolved"); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]], order[i - 1]); + var after = (fired[order[i]], order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (fired {before.Item1:R}) resolved before shot {order[i]} (fired {after.Item1:R})"); + } + Assert.True(sources.Zip(sources.Skip(1), (x, y) => x != y).Count(changed => changed) > 1, $"step {step}: one shooter's rounds resolved as a block"); + } + Assert.True(checkedSteps >= 4); + } + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are // apart by the shooter's velocity times the time between them. [Fact] @@ -360,6 +472,7 @@ public sealed partial class ContactRevealTests Tick(dt); } for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue + foreach (var w in weapons) w.Deactivate(); // the triggers stay down until released for (var i = 0; i < 60; i++) Tick(dt); // let the in-flight rounds resolve var steps = 300; var idleBytes = GC.GetAllocatedBytesForCurrentThread(); From 43042177d6a8b07d25e65d691e934ae355ce9f4b Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:57:43 +0200 Subject: [PATCH 27/36] Tests that press a trigger once release it: a held trigger keeps firing now Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 2 ++ tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 1 + tests/Aetheria.Shared.Tests/MineTests.cs | 3 +++ tests/Aetheria.Shared.Tests/SignatureTests.cs | 1 + 4 files changed, 7 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1a8df874c..b7d1b88ab 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -354,6 +354,7 @@ public sealed partial class ContactRevealTests : IDisposable capacitor.AddCharge(1000f); ship.SetTarget(TargetRef.None); gun.Activate(); + gun.Deactivate(); for (var i = 0; i < 10; i++) { Tick(.1f); @@ -1277,6 +1278,7 @@ public sealed partial class ContactRevealTests : IDisposable ((InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(killerGun)).AddCharge(1000f); killer.SetTarget(TargetRef.None); killerGun.Activate(); + killerGun.Deactivate(); for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f); Assert.True(killed, "fixture: the kill lands inside a tick"); Assert.False(_zone.Entities.Contains(e), "fixture: the kill took the subject out of the zone"); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 189867e41..4b7b7027b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1052,6 +1052,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.25f); weapon.Activate(); + weapon.Deactivate(); r.Zone.Update(.25f); Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index ccc397c2c..c3c395f6f 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -168,6 +168,7 @@ public sealed class MineTests : IDisposable { var before = lab.Zone.Mines.Count; lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(dt); Assert.Equal(before + 1, lab.Zone.Mines.Count); return lab.Zone.Mines.Last(); @@ -532,6 +533,7 @@ public sealed class MineTests : IDisposable foreach (var lab in new[] { a, b }) { lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(.1f); lab.Zone.Update(.1f); } @@ -803,6 +805,7 @@ public sealed class MineTests : IDisposable lab.Zone.Log = log.Add; lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(.1f); Assert.Empty(lab.Zone.Mines); diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 922e3e5b5..fb1755d96 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -172,6 +172,7 @@ public sealed class SignatureTests : IDisposable } r.Ship.SetTarget(TargetRef.None); r.Weapon.Activate(); + r.Weapon.Deactivate(); for (var i = 0; i < 5; i++) r.Zone.Update(.1f); } From 1aceaa576d7ca16500bb4ad638965996757c31a1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 02:02:54 +0200 Subject: [PATCH 28/36] Two-AI digest repinned once for resolving a step's rounds in fire-time order (the trigger change alone moves nothing) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index dcecd620b..40576dbb2 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -36,7 +36,10 @@ public sealed partial class RunStartTests // Repinned a fourth time, once (rounds-per-step): a round is born at its due moment inside its step, so its FireTime, origin // and frozen target pose are carried back by its age (a pull's first round leaves at the step's start, not its end) and // its commit and arrival times follow. Rebasing onto master moved nothing (d5e16969 measured at the rebased h1 head). - private const string TwoAiPinnedDigest = "1971060955717cabd4543d70c031562a5a101dbadb71b9bcd0bc1bfb3e89dd94"; + // Repinned a fifth time, once (h3): the shot queue is kept in fire-time order and resolved oldest first, ties by shot id, so within a step a + // later shooter's earlier round resolves before an earlier shooter's later one (19710609 before). The trigger being the weapon's own state moved nothing + // (measured alone: 19710609). + private const string TwoAiPinnedDigest = "108c93dc1b280d044d7ed3a5ade9f32f9dc76185989b25c5293ba1a86ab95632"; private const float TwoAiSeconds = 60f; [Fact] From 0d8457bc34b1e6db4841eab8f716ad31be90b499 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 02:11:28 +0200 Subject: [PATCH 29/36] SignatureTests: only a gun is tapped; a beam burns while its trigger is held Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index fb1755d96..50d0bc824 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -172,7 +172,7 @@ public sealed class SignatureTests : IDisposable } r.Ship.SetTarget(TargetRef.None); r.Weapon.Activate(); - r.Weapon.Deactivate(); + if (r.Weapon is InstantWeapon) r.Weapon.Deactivate(); // a gun is tapped; a beam burns while held for (var i = 0; i < 5; i++) r.Zone.Update(.1f); } From 35ef321ff3f904c623bee70c3a1dd5477767c8d6 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 02:45:36 +0200 Subject: [PATCH 30/36] A plain InstantWeapon is semi-automatic; AutoWeapon repeats while held; the shortest semi-auto cooldown is a named constant with a catalog check; auto-guns command Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 5 ++- .../ServerShared/Behaviors/InstantWeapon.cs | 24 +++++++++--- tools/AetherDb/AutoGunsCatalog.cs | 37 +++++++++++++++++++ tools/AetherDb/Program.cs | 3 +- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tools/AetherDb/AutoGunsCatalog.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index b408f8079..398a1bbe1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -15,9 +15,12 @@ public class AutoWeaponData : InstantWeaponData } } +// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate) +// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic. public class AutoWeapon : InstantWeapon { - + protected override bool Repeats => _firing; + public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } } diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 47b61ddf4..c58783097 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -31,6 +31,21 @@ public class InstantWeaponData : WeaponData return new InstantWeapon(this, item); } + // The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one + // is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling + // fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon. + public const float ShortestSemiAutoCooldown = .1f; + + // The plain guns of a catalog that cycle faster than a finger should have to: one fault line per gun. + public static IEnumerable SemiAutoFaults(IEnumerable catalog) + { + foreach (var gear in catalog) + foreach (var behavior in gear.Behaviors) + if (behavior.GetType() == typeof(InstantWeaponData) && behavior is InstantWeaponData plain && + Math.Min(plain.Cooldown.Min, plain.Cooldown.Max) < ShortestSemiAutoCooldown) + yield return $"{gear.Name}: a plain gun with a {Math.Min(plain.Cooldown.Min, plain.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it"; + } + // The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated // stats, the design with its rated ones. A burst's round carries its share of the totals: PerRound, the one divisor // UpdateStats and the rated read both use. @@ -92,11 +107,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. public event Action OnFire; - // Whether the trigger is down, so a cooldown that ends inside a step starts the next burst inside it. The trigger is the - // weapon's own state, set by Activate and cleared by Deactivate (a player's press and let-go, an agent's pull and - // release), never inferred from how often it was pulled: a tap fires one burst, and a held trigger fires every round - // its cooldown allows, at any step length. - private bool Repeats => _firing; + // Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and + // an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its + // cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design). + protected virtual bool Repeats => false; public virtual void ResetEvents() { diff --git a/tools/AetherDb/AutoGunsCatalog.cs b/tools/AetherDb/AutoGunsCatalog.cs new file mode 100644 index 000000000..4292d745b --- /dev/null +++ b/tools/AetherDb/AutoGunsCatalog.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using MessagePack; + +// Makes the named plain guns fully automatic (ruling fire-mode-is-the-design): each one's InstantWeaponData becomes an +// AutoWeaponData carrying every authored stat unchanged, so cadence and damage do not move. A gun that is already an +// AutoWeapon (or any other kind) is left alone, so a rerun writes nothing. Dry run unless apply. +public static class AutoGunsCatalog +{ + public static readonly string[] Guns = { "Slug Rifle", "Long Slug Rifle" }; + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var changed = new System.Collections.Generic.List(); + foreach (var name in Guns) + { + var gear = cache.GetAll().SingleOrDefault(g => g.Name == name); + if (gear == null) { output.WriteLine($"{name}: no such gear, skipped"); continue; } + var index = gear.Behaviors.FindIndex(b => b.GetType() == typeof(InstantWeaponData)); + if (index < 0) { output.WriteLine($"{name}: no plain gun, kept"); continue; } + var plain = (InstantWeaponData) gear.Behaviors[index]; + var bytes = MessagePackSerializer.Serialize(typeof(InstantWeaponData), plain); + gear.Behaviors[index] = MessagePackSerializer.Deserialize(bytes); + output.WriteLine($"{name}: InstantWeaponData -> AutoWeaponData (cooldown {plain.Cooldown.Max:0.###} s)"); + changed.Add(gear); + } + + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); return 0; } + if (changed.Count == 0) { output.WriteLine("Nothing to author."); return 0; } + foreach (var gear in changed) CultRecordRefs.Validate(gear); + cache.Commit(batch => { foreach (var gear in changed) batch.Upsert(typeof(GearData), gear, cache.RefOf(gear).Key); }); + output.WriteLine($"\nAuthored {changed.Count} guns in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 572d6f06f..6f289b537 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -76,9 +76,10 @@ public static class Program if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); return code; } + case "auto-guns": return AutoGunsCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], auto-guns [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); return 1; } } From 5367c899a63a161f9fdb3c781ba2ade3a99bacce Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 02:49:22 +0200 Subject: [PATCH 31/36] auto-guns: copy the plain gun's fields Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/AutoGunsCatalog.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/AetherDb/AutoGunsCatalog.cs b/tools/AetherDb/AutoGunsCatalog.cs index 4292d745b..d62206681 100644 --- a/tools/AetherDb/AutoGunsCatalog.cs +++ b/tools/AetherDb/AutoGunsCatalog.cs @@ -2,7 +2,7 @@ using System; using System.IO; using System.Linq; using GameCult.Caching; -using MessagePack; +using System.Reflection; // Makes the named plain guns fully automatic (ruling fire-mode-is-the-design): each one's InstantWeaponData becomes an // AutoWeaponData carrying every authored stat unchanged, so cadence and damage do not move. A gun that is already an @@ -21,8 +21,12 @@ public static class AutoGunsCatalog var index = gear.Behaviors.FindIndex(b => b.GetType() == typeof(InstantWeaponData)); if (index < 0) { output.WriteLine($"{name}: no plain gun, kept"); continue; } var plain = (InstantWeaponData) gear.Behaviors[index]; - var bytes = MessagePackSerializer.Serialize(typeof(InstantWeaponData), plain); - gear.Behaviors[index] = MessagePackSerializer.Deserialize(bytes); + var auto = new AutoWeaponData(); + // AutoWeaponData adds no member of its own: every field of the plain gun, from every base, carries over. + for (var type = typeof(InstantWeaponData); type != null; type = type.BaseType) + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + field.SetValue(auto, field.GetValue(plain)); + gear.Behaviors[index] = auto; output.WriteLine($"{name}: InstantWeaponData -> AutoWeaponData (cooldown {plain.Cooldown.Max:0.###} s)"); changed.Add(gear); } From d3da9852905dcdf6a372d5a70531d46f31af6ec4 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 02:51:33 +0200 Subject: [PATCH 32/36] Fire mode is the design: Slug Rifle and Long Slug Rifle are AutoWeapons; tap and hold tests for both kinds at 15, 30 and 60 Hz; the semi-auto cooldown check Co-Authored-By: Claude Sonnet 5.5 --- .../SemiAutoCatalogTests.cs | 50 ++++++++++ tests/Aetheria.Shared.Tests/SimRateTests.cs | 99 +++++++++++++------ 2 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs new file mode 100644 index 000000000..65ef6542b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// Fire mode is a design property (ruling fire-mode-is-the-design): a plain InstantWeapon is semi-automatic, and its cycle +// stays at a pace a finger can keep. A gun that cycles faster is an AutoWeapon. +public sealed class SemiAutoCatalogTests +{ + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GearData Gun(string name, InstantWeaponData data) => new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, Behaviors = { data } }; + + [Fact] + public void TheShippedCatalogAndItsPackagesHoldNoFasterPlainGun() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Path.Combine(root, "GameData", "Aetheria.cc"), Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.Empty(InstantWeaponData.SemiAutoFaults(gear)); + // The check reads real guns, and the two automatic ones are AutoWeapons with their authored cadence. + foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) + Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); + Assert.Contains(gear, g => g.Behaviors.Any(b => b.GetType() == typeof(InstantWeaponData))); + } + + [Fact] + public void ThePlainGunCheckRefusesAFastSemiAutoAndOnlyThat() + { + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var fast = Gun("Fast", new InstantWeaponData { Cooldown = Const(floor / 2) }); + var edge = Gun("Edge", new InstantWeaponData { Cooldown = Const(floor) }); + var auto = Gun("Auto", new AutoWeaponData { Cooldown = Const(.01f) }); + var wide = Gun("Wide", new InstantWeaponData { Cooldown = new PerformanceStat { Min = floor / 2, Max = floor * 2 } }); + var faults = InstantWeaponData.SemiAutoFaults(new[] { fast, edge, auto, wide }).ToArray(); + // The floor is 0.1 s: ten presses a second is allowed, twenty is not. + Assert.Equal(.1f, floor); + Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Twenty", new InstantWeaponData { Cooldown = Const(.05f) }) })); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new[] { Gun("Ten", new InstantWeaponData { Cooldown = Const(.1f) }) })); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith("Fast:")); + Assert.Contains(faults, f => f.StartsWith("Wide:")); + } +} diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 9f2aab07f..e89f14c0d 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -41,11 +41,15 @@ public sealed partial class ContactRevealTests private static readonly float[] Cooldowns = { .25f, .13f }; // A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog. - private string GunWithCooldown(float cooldown) + // Automatic by default, as a gun that holds is; a semi-automatic one is the plain InstantWeaponData. + private string GunWithCooldown(float cooldown, bool auto = true) { - var name = $"Gun@{cooldown:R}"; + var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}"; if (_openCache.GetByName(name) != null) return name; - var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }, 0); + InstantWeaponData data = auto + ? new AutoWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) } + : new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }; + var gun = Armed(data, 0); gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing _openCache.Upsert(new WeaponItemData { @@ -307,72 +311,109 @@ public sealed partial class ContactRevealTests private InputCapacitor CapacitorOf(InstantWeapon gun) => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); - // A press that is let go before the step runs fires one burst, whatever the cooldown and the tick: the trigger is the - // weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's action bar and the - // agents' Combat both do) and straight to the weapon (a turret does). + // A press that is let go before the step runs fires one burst, whatever the cooldown, the tick and the kind of gun: the + // trigger is the weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's + // action bar and the agents' Combat both do) and straight to the weapon (a turret does). [Fact] public void ATapFiresOneBurstAtEveryRate() { - foreach (var cooldown in new[] { .01f, .025f, .1f }) - foreach (var viaCommand in new[] { false, true }) + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .025f, .1f } : new[] { .1f, .25f }) + foreach (var viaCommand in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + if (viaCommand) + { + ship.Command.Pull(gun); + ship.Command.Release(gun); + } + else + { + gun.Activate(); + gun.Deactivate(); + } + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds == 1, $"auto {auto} cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + } + } + + // A trigger pressed once and held: an AutoWeapon delivers the authored cadence (the rounds of ten seconds are the same at + // 15, 30 and 60 Hz) and stops when the trigger is let go; a plain gun is semi-automatic and fires its one burst however + // long the key stays down. + [Fact] + public void AHeldTriggerFiresPerTheKindOfGun() + { + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .1f } : new[] { .1f, .25f }) foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) { - var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var capacitor = CapacitorOf(gun); var rounds = 0; gun.OnFire += _ => rounds++; ship.SetTarget(TargetRef.None); capacitor.AddStepCharge(1000f, dt); - if (viaCommand) + gun.Activate(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) { - ship.Command.Pull(gun); - ship.Command.Release(gun); - } - else - { - gun.Activate(); - gun.Deactivate(); + capacitor.AddStepCharge(1000f, dt); + Tick(dt); } + var expected = auto ? 10f / cooldown : 1f; + Assert.True(abs(rounds - expected) <= (auto ? 1.01f : 0f), $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {expected}"); + gun.Deactivate(); + var held = rounds; for (var step = 0; step < (int) MathF.Round(1f / dt); step++) { capacitor.AddStepCharge(1000f, dt); Tick(dt); } - Assert.True(rounds == 1, $"cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + Assert.True(rounds - held <= 1, $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); } } - // A trigger pulled once and held, as the player's key and a turret's pull are, delivers the authored cadence: the rounds - // of ten seconds are the same at 15, 30 and 60 Hz, and the weapon stops when the trigger is let go. + // An agent's Combat pulls every weapon it means to fire each step. A plain gun it taps: each pull is a press, and the gun + // fires when it is ready, its authored cadence on average at any tick. An AutoWeapon it holds: the pulls land on a + // trigger already down. Both deliver the cadence; the plain one stops when the pulls stop, with no release needed. [Fact] - public void AHeldTriggerFiresTheAuthoredCadenceAtEveryRate() + public void CombatTapsAPlainGunAndHoldsAnAutoWeapon() { - foreach (var cooldown in new[] { .01f, .1f }) + foreach (var auto in new[] { false, true }) foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) { - var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.1f, auto) }, new[] { "Targeting", "Reactor" }); var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var capacitor = CapacitorOf(gun); var rounds = 0; gun.OnFire += _ => rounds++; ship.SetTarget(TargetRef.None); - capacitor.AddStepCharge(1000f, dt); - gun.Activate(); for (var step = 0; step < (int) MathF.Round(10f / dt); step++) { capacitor.AddStepCharge(1000f, dt); + ship.Command.Pull(gun); Tick(dt); } - Assert.True(abs(rounds - 10f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {10f / cooldown} +-1"); - gun.Deactivate(); - var held = rounds; + Assert.True(abs(rounds - 100f) <= 1.01f, $"auto {auto} at dt {dt}: {rounds} rounds pulled each step for 10 s, expected 100 +-1"); + var pulled = rounds; for (var step = 0; step < (int) MathF.Round(1f / dt); step++) { capacitor.AddStepCharge(1000f, dt); + if (auto) ship.Command.Release(gun); Tick(dt); } - Assert.True(rounds - held <= 1, $"cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); + Assert.True(rounds - pulled <= 1, $"auto {auto} at dt {dt}: {rounds - pulled} rounds after the pulls stopped"); } } From e92c2140b41977bab5719ad89291bbf6d5bc421a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 03:01:42 +0200 Subject: [PATCH 33/36] SemiAutoCatalogTests: the shipped catalog holds no plain gun, only guns Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs index 65ef6542b..cca1d8c22 100644 --- a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -24,10 +24,10 @@ public sealed class SemiAutoCatalogTests cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); var gear = cache.GetAll().ToArray(); Assert.Empty(InstantWeaponData.SemiAutoFaults(gear)); - // The check reads real guns, and the two automatic ones are AutoWeapons with their authored cadence. + // The check reads a catalog that holds guns, and the two automatic ones are AutoWeapons. foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); - Assert.Contains(gear, g => g.Behaviors.Any(b => b.GetType() == typeof(InstantWeaponData))); + Assert.Contains(gear, g => g.Behaviors.Any(b => b is InstantWeaponData)); } [Fact] From 1f7b9719662331c28ac91d77665c9ab84e6b0a8c Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:12:55 +0200 Subject: [PATCH 34/36] Fire mode is one property of a weapon's data (Automatic): creation and the semi-auto floor read it, the floor holds guided, mine, launcher and charged kinds and consumables; a step's rounds apply in arrival order, ties by shot id Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 6 +-- .../ServerShared/Behaviors/InstantWeapon.cs | 28 +++++++---- Assets/Scripts/ServerShared/FireControl.cs | 48 ++++++++++++++----- .../SemiAutoCatalogTests.cs | 38 ++++++++++++++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 38 ++++++++++++++- 5 files changed, 129 insertions(+), 29 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 398a1bbe1..1de3acbdb 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -9,10 +9,8 @@ using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class AutoWeaponData : InstantWeaponData { - public override Behavior CreateInstance(EquippedItem item) - { - return new AutoWeapon(this, item); - } + [IgnoreMember] + public override bool Automatic => true; } // A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index c58783097..da23bc107 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -21,14 +21,19 @@ public class InstantWeaponData : WeaponData [Inspectable, JsonProperty("ammoInterval"), Key(20)] public bool SingleAmmoBurst; + // The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its + // cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst). + [IgnoreMember] + public virtual bool Automatic => false; + public override Behavior CreateInstance(EquippedItem item) { - return new InstantWeapon(this, item); + return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item); } public override Behavior CreateInstance(ConsumableItemEffect item) { - return new InstantWeapon(this, item); + return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item); } // The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one @@ -36,14 +41,19 @@ public class InstantWeaponData : WeaponData // fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon. public const float ShortestSemiAutoCooldown = .1f; - // The plain guns of a catalog that cycle faster than a finger should have to: one fault line per gun. - public static IEnumerable SemiAutoFaults(IEnumerable catalog) + // The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine, + // launcher, charged) that cycle faster than a finger should have to: one fault line per weapon. + public static IEnumerable SemiAutoFaults(IEnumerable catalog) { - foreach (var gear in catalog) - foreach (var behavior in gear.Behaviors) - if (behavior.GetType() == typeof(InstantWeaponData) && behavior is InstantWeaponData plain && - Math.Min(plain.Cooldown.Min, plain.Cooldown.Max) < ShortestSemiAutoCooldown) - yield return $"{gear.Name}: a plain gun with a {Math.Min(plain.Cooldown.Min, plain.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it"; + foreach (var item in catalog) + { + var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors; + if (behaviors == null) continue; + foreach (var behavior in behaviors) + if (behavior is InstantWeaponData weapon && !weapon.Automatic && + Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown) + yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it"; + } } // The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3b3b78cf3..1581e3457 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -854,9 +854,9 @@ public static class FireControl if (shots.Count == 0) return; var now = zone.Time; - // In fire-time order, oldest first (the queue is kept in it by Fire), so which of a step's rounds breaks a shield or - // lands a kill follows sim time, not the order the shooters were stepped in. Shots queued while this walk runs are + // Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are // left to the next step. + List<(float Arrival, int Id)> arrived = null; for (int i = 0, end = shots.Count; i < end;) { var shot = shots[i]; @@ -892,20 +892,42 @@ public static class FireControl if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of - // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage - // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here - // the same way; only Apply decides what that means. - // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one - // that throws would resolve, and hurt, again on every later tick. - shots.RemoveAt(i); - end--; - Apply(zone, shot); - zone.ShotResolved.OnNext(shot.Outcome); - continue; + (arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId)); } i++; } + if (arrived == null) return; + + // The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so + // which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the + // order their rounds left. + arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id)); + foreach (var (_, id) in arrived) + { + var i = shots.FindIndex(q => q.ShotId == id); + if (i < 0) continue; + var shot = shots[i]; + + var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); + if (targetGone) + { + // As in the commit walk: a shot whose target left the zone in this step resolves as a miss. + var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); + zone.ShotResolved.OnNext(miss); + continue; + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of + // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage + // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here + // the same way; only Apply decides what that means. + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. + shots.RemoveAt(i); + Apply(zone, shot); + zone.ShotResolved.OnNext(shot.Outcome); + } } // R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs index cca1d8c22..8bedd9bd0 100644 --- a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -23,7 +25,7 @@ public sealed class SemiAutoCatalogTests using var cache = new CultCache(TestCatalog.Registry()); cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); var gear = cache.GetAll().ToArray(); - Assert.Empty(InstantWeaponData.SemiAutoFaults(gear)); + Assert.Empty(InstantWeaponData.SemiAutoFaults(cache.GetAll())); // The check reads a catalog that holds guns, and the two automatic ones are AutoWeapons. foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); @@ -47,4 +49,38 @@ public sealed class SemiAutoCatalogTests Assert.Contains(faults, f => f.StartsWith("Fast:")); Assert.Contains(faults, f => f.StartsWith("Wide:")); } + + // Fire mode is one property of the weapon's data: every kind but AutoWeaponData is semi-automatic, and the floor holds each of + // them, as gear or as a consumable. An automatic gun is exempt wherever it is carried. + public static IEnumerable Kinds() => new[] + { + new object[] { "plain", (Func) (() => new InstantWeaponData()) }, + new object[] { "guided", (Func) (() => new GuidedWeaponData()) }, + new object[] { "mine", (Func) (() => new MineLayerData()) }, + new object[] { "launcher", (Func) (() => new LauncherData()) }, + new object[] { "charged", (Func) (() => new ChargedWeaponData { CanFireEarly = true }) }, + }; + + [Theory, MemberData(nameof(Kinds))] + public void TheFloorRefusesEverySemiAutoKindBelowItAsGearOrConsumable(string kind, Func make) + { + InstantWeaponData At(float cooldown) { var data = make(); data.Cooldown = Const(cooldown); return data; } + Assert.False(make().Automatic); + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var slow = At(floor); + var fast = At(floor / 2); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, slow), new ConsumableItemData { Name = kind + " dose", Behaviors = { slow } } })); + var faults = InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, fast), new ConsumableItemData { Name = kind + " dose", Behaviors = { fast } } }).ToArray(); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith(kind + ":")); + Assert.Contains(faults, f => f.StartsWith(kind + " dose:")); + } + + [Fact] + public void AnAutomaticGunIsExemptAsGearOrConsumable() + { + var auto = new AutoWeaponData { Cooldown = Const(.01f) }; + Assert.True(auto.Automatic); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun("Auto", auto), new ConsumableItemData { Name = "Auto dose", Behaviors = { auto } } })); + } } diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index e89f14c0d..dc005e4c0 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -42,14 +42,15 @@ public sealed partial class ContactRevealTests // A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog. // Automatic by default, as a gun that holds is; a semi-automatic one is the plain InstantWeaponData. - private string GunWithCooldown(float cooldown, bool auto = true) + private string GunWithCooldown(float cooldown, bool auto = true, float velocity = 0f) { - var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}"; + var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}{(velocity > 0 ? $"v{velocity:R}" : "")}"; if (_openCache.GetByName(name) != null) return name; InstantWeaponData data = auto ? new AutoWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) } : new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }; var gun = Armed(data, 0); + gun.Velocity = Const(velocity); gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing _openCache.Upsert(new WeaponItemData { @@ -454,6 +455,39 @@ public sealed partial class ContactRevealTests Assert.True(checkedSteps >= 4); } + // Within a step, rounds apply in the order they arrive, not the order they were fired: a slow gun's earlier rounds and a + // fast gun's later ones that land in the same step apply by when each lands. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepApplyInArrivalOrderWhateverTheirFlightTimes() + { + var dt = 1f / 30f; + var slow = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f, velocity: 100f) }, new[] { "Targeting", "Reactor" }); + var fast = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { slow, fast }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = (shot.FireTime, shot.ArrivalTime); }; + foreach (var s in new[] { slow, fast }) s.SetTarget(TargetRef.None); + var order = new List(); + _zone.ShotResolved.Subscribe(outcome => order.Add(outcome.ShotId)); + var inverted = 0; + for (var step = 0; step < 40; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + Tick(dt); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]].arrival, order[i - 1]); + var after = (fired[order[i]].arrival, order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (arrives {before.Item1:R}) applied before shot {order[i]} (arrives {after.Item1:R})"); + if (fired[order[i - 1]].fire > fired[order[i]].fire) inverted++; + } + } + // The guns really differ: rounds were applied out of fire order, which fire-order resolution would not do. + Assert.True(inverted > 10, $"{inverted} rounds applied ahead of an earlier-fired one"); + } + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are // apart by the shooter's velocity times the time between them. [Fact] From 62a641bf15ed164c0dbad5b40ef70a84bdd7c1c8 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:12:57 +0200 Subject: [PATCH 35/36] Catalog: Slug Rifle and Long Slug Rifle are AutoWeapons (auto-guns apply on master's catalog) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 96983ad12..55da2e5e9 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8df83e71feb44a18e4419f3b75beac5915a85def2829a93550f72622b2c92a77 -size 195808 +oid sha256:cf937f0b1ef1c0f732ccfd14728ecb786601b1ebf50699bcd7bc47cd81716fcc +size 195939 From 9036d39f30c2e2161fe499025986750c93792a4e Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:22:01 +0200 Subject: [PATCH 36/36] Two-AI digest repinned once for arrival-order application (commits of a step precede its applies) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 40576dbb2..07f684727 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -39,7 +39,11 @@ public sealed partial class RunStartTests // Repinned a fifth time, once (h3): the shot queue is kept in fire-time order and resolved oldest first, ties by shot id, so within a step a // later shooter's earlier round resolves before an earlier shooter's later one (19710609 before). The trigger being the weapon's own state moved nothing // (measured alone: 19710609). - private const string TwoAiPinnedDigest = "108c93dc1b280d044d7ed3a5ade9f32f9dc76185989b25c5293ba1a86ab95632"; + // Repinned a sixth time, once (h5): a step commits its due shots in fire-time order, then applies the arrived ones in arrival + // order, ties by shot id, so every commit of a step precedes its applies (it was commit then apply, shot by shot, in fire + // order). The two parts measured: commit-then-apply split alone a1318ee0, with the arrival sort 55ecd858; the old walk at the + // h5 head, rebased onto master, still measures 108c93dc, so the rebase and the fire-mode property moved nothing. + private const string TwoAiPinnedDigest = "55ecd858875ca6a5418d6badd40e71d9bbd363ca782843005483a308bcf07d74"; private const float TwoAiSeconds = 60f; [Fact]