Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
@@ -0,0 +1,224 @@
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));
}
}