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); + } +}