Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
@@ -0,0 +1,128 @@
namespace HSchool.Simulation.Tests;
public class GameClockTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private const double OneTwentiethOfASecond = 1d / 20d;
private const double GameMinutesPerRealSecond = 5d;
[Fact]
public void NewClock_StartsRunningAtTheStartDate()
{
var clock = new GameClock(Start);
Assert.Equal(Start, clock.Time);
Assert.True(clock.IsRunning);
Assert.Equal(ClockSpeed.DefaultIndex, clock.SpeedIndex);
Assert.Equal(1d, clock.Multiplier);
}
[Fact]
public void PausedClock_DoesNotMove()
{
var clock = new GameClock(Start) { IsRunning = false };
for (var i = 0; i < 100; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start, clock.Time);
}
[Fact]
public void OneRealSecond_AdvancesFiveGameMinutes()
{
var clock = new GameClock(Start);
// One real second at 20 Hz.
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(5), clock.Time);
}
[Theory]
[InlineData(0, 2.5)]
[InlineData(1, 5)]
[InlineData(2, 10)]
[InlineData(3, 15)]
[InlineData(4, 20)]
public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes)
{
var clock = new GameClock(Start) { SpeedIndex = speedIndex };
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(expectedMinutes), clock.Time);
}
[Theory]
[InlineData(-1)]
[InlineData(5)]
[InlineData(200)]
public void InvalidSpeedIndex_IsIgnored(int speedIndex)
{
var clock = new GameClock(Start) { SpeedIndex = 2 };
clock.SpeedIndex = speedIndex;
Assert.Equal(2, clock.SpeedIndex);
}
[Fact]
public void Pausing_FreezesTimeWhereItStopped()
{
var clock = new GameClock(Start);
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
var paused = clock.Time;
clock.IsRunning = false;
for (var i = 0; i < 100; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(paused, clock.Time);
}
[Fact]
public void SameTickCount_AlwaysProducesTheSameDate()
{
Assert.Equal(RunOneHourOfTicks(), RunOneHourOfTicks());
static DateTime RunOneHourOfTicks()
{
var clock = new GameClock(Start) { SpeedIndex = 0 };
for (var i = 0; i < 20 * 60 * 60; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
return clock.Time;
}
}
[Fact]
public void Time_IsUtcSoSerializationIsUnambiguous()
{
var clock = new GameClock(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Unspecified));
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
}
[Fact]
public void StartDateOutsideTheSupportedRange_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameClock(new DateTime(1800, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
}
}
@@ -1,224 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Tests;
public class GameWorldTests
{
private static SimulationOptions Options() => new()
{
TickRate = 20,
WorldWidth = 1000f,
WorldHeight = 1000f,
PlayerSpeed = 200f,
PlayerRadius = 10f,
};
private static EntitySnapshot Entity(GameWorld world, uint networkId)
{
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
return buffer.Single(entity => entity.Id == networkId);
}
[Fact]
public void Tick_AdvancesTheTickCounter()
{
using var world = new GameWorld(Options());
world.Tick();
world.Tick();
Assert.Equal(2u, world.CurrentTick);
}
[Fact]
public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
Assert.Equal(1, world.PlayerCount);
Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
}
[Fact]
public void SpawnPlayer_Twice_Throws()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
Assert.Throws<InvalidOperationException>(() => world.SpawnPlayer(playerId: 1));
}
[Fact]
public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.Tick();
var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void DiagonalInput_IsNotFasterThanCardinal()
{
var options = Options();
using var world = new GameWorld(options);
var straight = world.SpawnPlayer(playerId: 1);
var diagonal = world.SpawnPlayer(playerId: 2);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
world.Tick();
var straightBefore = Entity(world, straight);
var diagonalBefore = Entity(world, diagonal);
world.Tick();
var straightStep = Distance(straightBefore, Entity(world, straight));
var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
}
[Fact]
public void Player_StopsAtTheWorldBounds()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
for (var i = 0; i < 200; i++)
{
world.Tick();
}
Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void StaleInput_IsIgnored()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
world.Tick();
Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void InputForAnUnknownPlayer_IsIgnored()
{
using var world = new GameWorld(Options());
world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
world.Tick();
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void DespawnPlayer_RemovesItFromSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.Equal(0, world.PlayerCount);
Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
Assert.Null(world.GetNetworkId(playerId: 1));
}
[Fact]
public void DespawnPlayer_Twice_IsHarmless()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void NetworkIds_AreNotRecycledAfterDespawn()
{
using var world = new GameWorld(Options());
var first = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var second = world.SpawnPlayer(playerId: 1);
Assert.NotEqual(first, second);
}
[Fact]
public void EmptyWorld_StillContainsTheStaticObstacles()
{
using var world = new GameWorld(Options());
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.NotEmpty(buffer);
Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
}
[Fact]
public void Simulation_IsDeterministicForTheSameInputs()
{
var first = Run();
var second = Run();
Assert.Equal(first, second);
static (float X, float Y) Run()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 3);
for (var i = 0; i < 25; i++)
{
world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
world.Tick();
}
var entity = Entity(world, networkId);
return (entity.X, entity.Y);
}
}
[Fact]
public void UsingADisposedWorld_Throws()
{
var world = new GameWorld(Options());
world.Dispose();
Assert.Throws<ObjectDisposedException>(world.Tick);
}
private static float Distance(EntitySnapshot from, EntitySnapshot to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return MathF.Sqrt((dx * dx) + (dy * dy));
}
}
@@ -0,0 +1,179 @@
namespace HSchool.Simulation.Tests;
public class SchoolRegistryTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private static SchoolRegistry NewRegistry(int maxSchools = 6) =>
new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 });
[Fact]
public void NewRegistry_IsEmpty()
{
using var registry = NewRegistry();
Assert.Equal(0, registry.Count);
Assert.Equal(6, registry.MaxSchools);
Assert.False(registry.IsFull);
}
[Fact]
public void Create_AddsASchoolAtTheGivenStartDate()
{
using var registry = NewRegistry();
var result = registry.Create("Гимназия №1", Start);
Assert.True(result.Succeeded);
Assert.Equal("Гимназия №1", result.School!.Name);
Assert.Equal(Start, result.School.Clock.Time);
// A new school starts living straight away; only the pause button stops it.
Assert.True(result.School.Clock.IsRunning);
Assert.Equal(1, registry.Count);
}
[Fact]
public void Create_BeyondTheLimit_Fails()
{
using var registry = NewRegistry(maxSchools: 2);
registry.Create("Первая", Start);
registry.Create("Вторая", Start);
var result = registry.Create("Третья", Start);
Assert.False(result.Succeeded);
Assert.Equal(SchoolCreationError.LimitReached, result.Error);
Assert.True(registry.IsFull);
Assert.Equal(2, registry.Count);
}
[Fact]
public void Delete_FreesASlot()
{
using var registry = NewRegistry(maxSchools: 1);
var first = registry.Create("Первая", Start).School!;
Assert.False(registry.Create("Вторая", Start).Succeeded);
Assert.True(registry.Delete(first.Id));
Assert.True(registry.Create("Вторая", Start).Succeeded);
}
[Fact]
public void Delete_UnknownId_ReportsFailure()
{
using var registry = NewRegistry();
Assert.False(registry.Delete(42));
}
[Fact]
public void Ids_AreNotReusedAfterDeletion()
{
using var registry = NewRegistry();
var first = registry.Create("Первая", Start).School!;
registry.Delete(first.Id);
var second = registry.Create("Вторая", Start).School!;
Assert.NotEqual(first.Id, second.Id);
Assert.Null(registry.Find(first.Id));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Create_RejectsBlankNames(string name)
{
using var registry = NewRegistry();
Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error);
}
[Fact]
public void Create_RejectsOverlongNames()
{
using var registry = NewRegistry();
var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start);
Assert.Equal(SchoolCreationError.InvalidName, result.Error);
}
[Fact]
public void Create_TrimsAndStripsControlCharacters()
{
using var registry = NewRegistry();
var result = registry.Create(" Лицей ", Start);
Assert.Equal("Лицей", result.School!.Name);
}
[Fact]
public void Create_RejectsStartDatesOutsideTheSupportedRange()
{
using var registry = NewRegistry();
var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error);
}
[Fact]
public void Tick_AdvancesOnlyRunningSchools()
{
using var registry = NewRegistry();
var running = registry.Create("Идёт", Start).School!;
var paused = registry.Create("Стоит", Start).School!;
paused.Clock.IsRunning = false;
for (var i = 0; i < 20; i++)
{
registry.Tick();
}
Assert.Equal(Start.AddMinutes(5), running.Clock.Time);
Assert.Equal(Start, paused.Clock.Time);
}
[Fact]
public void SuggestName_NeverRepeatsAnExistingName()
{
using var registry = NewRegistry(maxSchools: 20);
for (var i = 0; i < 20; i++)
{
var suggestion = registry.SuggestName();
Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected.");
}
var names = registry.Schools.Select(school => school.Name).ToArray();
Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
[Fact]
public void SuggestedNames_FitTheNameLimit()
{
var generator = new SchoolNameGenerator(new Random(1234));
for (var i = 0; i < 200; i++)
{
var name = generator.Next([]);
Assert.InRange(name.Length, 1, School.MaxNameLength);
}
}
[Fact]
public void Dispose_DropsEverySchool()
{
var registry = NewRegistry();
registry.Create("Школа", Start);
registry.Dispose();
Assert.Equal(0, registry.Count);
}
}