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 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 20:23:53 +02:00
commit e2f0fc9dc4
6 changed files with 56 additions and 13 deletions

View file

@ -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 = {

View file

@ -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));

View file

@ -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();

View file

@ -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<float>();
var ys = new List<float>();
var leads = new List<float>();

View file

@ -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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => new SimClock(0f)).ParamName);
Assert.Equal("step", Assert.Throws<ArgumentOutOfRangeException>(() => new SimClock(0f, 60)).ParamName);
Assert.Equal("stepsPerRealSecond", Assert.Throws<ArgumentOutOfRangeException>(() => 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<string> History(Zone zone, float[] frames)
{
var clock = new SimClock(1f / 60f);
var clock = new SimClock(1f / 60f, 60);
var shots = new List<string>();
using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}"));
var history = new List<string>();

View file

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