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
+21
View File
@@ -0,0 +1,21 @@
namespace HSchool.Simulation;
/// <summary>
/// The speed buttons the player can pick, as an index on the wire. The table is duplicated in
/// <c>src/HSchool.Client/src/net/protocol.ts</c> — indexes, not multipliers, travel over the socket.
/// </summary>
public static class ClockSpeed
{
/// <summary>×½, ×1, ×2, ×3, ×4.</summary>
public static ReadOnlySpan<double> Multipliers => [0.5d, 1d, 2d, 3d, 4d];
/// <summary>Index of ×1, the speed a school starts at.</summary>
public const int DefaultIndex = 1;
public static int Count => Multipliers.Length;
public static bool IsValid(int index) => index >= 0 && index < Multipliers.Length;
/// <summary>Multiplier for a validated index; out-of-range values fall back to ×1.</summary>
public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex];
}
@@ -1,12 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>
/// Stable replication id. Arch entity ids are recycled, so the client gets this
/// monotonically increasing value instead.
/// </summary>
public struct NetworkId
{
public uint Value;
public NetworkId(uint value) => Value = value;
}
@@ -1,19 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Marks an entity as driven by a connected client's input.</summary>
public struct PlayerControl
{
/// <summary>Network id of the owning connection.</summary>
public uint PlayerId;
/// <summary>Latest intent received from that connection.</summary>
public InputButtons Buttons;
/// <summary>Sequence number of that intent; reserved for prediction/reconciliation.</summary>
public uint LastInputSequence;
/// <summary>Movement speed in units per second.</summary>
public float Speed;
}
@@ -1,14 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>World-space position in simulation units.</summary>
public struct Position
{
public float X;
public float Y;
public Position(float x, float y)
{
X = x;
Y = y;
}
}
@@ -1,13 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Everything the client needs to draw the entity; replicated verbatim in snapshots.</summary>
public struct Renderable
{
public EntityKind Kind;
public float Radius;
/// <summary>Packed 0x00RRGGBB.</summary>
public uint Color;
}
@@ -1,14 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>Simulation units per second, integrated by <c>MovementSystem</c>.</summary>
public struct Velocity
{
public float X;
public float Y;
public Velocity(float x, float y)
{
X = x;
Y = y;
}
}
+67
View File
@@ -0,0 +1,67 @@
namespace HSchool.Simulation;
/// <summary>
/// In-game calendar of one school. Time only moves while <see cref="IsRunning"/> is set, and it
/// moves by whole fixed steps — never by wall-clock deltas — so the same tick count always
/// produces the same date.
/// </summary>
public sealed class GameClock
{
/// <summary>Earliest date a school may start at; anything below is a typo, not a design choice.</summary>
public static readonly DateTime MinStartDate = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static readonly DateTime MaxStartDate = new(2999, 12, 31, 23, 59, 59, DateTimeKind.Utc);
private int _speedIndex = ClockSpeed.DefaultIndex;
public GameClock(DateTime startDate)
{
if (!IsValidStartDate(startDate))
{
throw new ArgumentOutOfRangeException(nameof(startDate), startDate, "Start date is outside the supported range.");
}
// The game calendar is not tied to a real time zone; UTC keeps serialization unambiguous.
Time = DateTime.SpecifyKind(startDate, DateTimeKind.Utc);
}
public DateTime Time { get; private set; }
/// <summary>
/// Schools live on their own: a new calendar starts running and only the player's pause
/// button stops it. Leaving for the menu does not.
/// </summary>
public bool IsRunning { get; set; } = true;
/// <summary>Index into <see cref="ClockSpeed.Multipliers"/>; invalid values are ignored.</summary>
public int SpeedIndex
{
get => _speedIndex;
set
{
if (ClockSpeed.IsValid(value))
{
_speedIndex = value;
}
}
}
public double Multiplier => ClockSpeed.MultiplierAt(_speedIndex);
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
/// <summary>
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
/// base rate and the current speed. Does nothing while paused.
/// </summary>
public void Advance(double realSeconds, double gameMinutesPerRealSecond)
{
if (!IsRunning)
{
return;
}
var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
Time = Time.AddMinutes(gameMinutes);
}
}
-199
View File
@@ -1,199 +0,0 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
using HSchool.Simulation.Systems;
namespace HSchool.Simulation;
/// <summary>
/// The authoritative world: an Arch <see cref="World"/> plus the fixed-step system pipeline.
/// Not thread-safe by design — only the game loop thread may touch it, everything else
/// goes through the command queue in the server layer.
/// </summary>
public sealed class GameWorld : IDisposable
{
private readonly World _world;
private readonly ISimulationSystem[] _systems;
private readonly Dictionary<uint, Entity> _playerEntities = [];
private uint _nextNetworkId = 1;
private bool _disposed;
public GameWorld(SimulationOptions? options = null)
{
Options = options ?? new SimulationOptions();
_world = World.Create();
_systems =
[
new PlayerInputSystem(),
new MovementSystem(),
new WorldBoundsSystem(),
];
SpawnObstacles();
}
public SimulationOptions Options { get; }
/// <summary>Number of fixed steps simulated so far.</summary>
public uint CurrentTick { get; private set; }
public int PlayerCount => _playerEntities.Count;
public int EntityCount => _world.CountEntities(new QueryDescription().WithAll<NetworkId>());
/// <summary>Adds a player body. Returns its replication id.</summary>
public uint SpawnPlayer(uint playerId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_playerEntities.ContainsKey(playerId))
{
throw new InvalidOperationException($"Player {playerId} is already spawned.");
}
var networkId = _nextNetworkId++;
var (x, y) = SpawnPoint(playerId);
var entity = _world.Create(
new NetworkId(networkId),
new Position(x, y),
new Velocity(0f, 0f),
new PlayerControl
{
PlayerId = playerId,
Buttons = InputButtons.None,
Speed = Options.PlayerSpeed,
},
new Renderable
{
Kind = EntityKind.Player,
Radius = Options.PlayerRadius,
Color = Palette.ForPlayer(playerId),
});
_playerEntities[playerId] = entity;
return networkId;
}
public void DespawnPlayer(uint playerId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_playerEntities.Remove(playerId, out var entity) && _world.IsAlive(entity))
{
_world.Destroy(entity);
}
}
/// <summary>Stores the latest intent for a player; applied on the next tick.</summary>
public void ApplyInput(uint playerId, InputButtons buttons, uint sequence)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_playerEntities.TryGetValue(playerId, out var entity) || !_world.IsAlive(entity))
{
return;
}
ref var control = ref _world.Get<PlayerControl>(entity);
// Late/duplicate packets carry a stale sequence; the newest intent wins.
if (sequence < control.LastInputSequence)
{
return;
}
control.Buttons = buttons;
control.LastInputSequence = sequence;
}
/// <summary>Runs one fixed step of the pipeline.</summary>
public void Tick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CurrentTick++;
var context = new SimulationContext(CurrentTick, Options.FixedDeltaTime, Options);
foreach (var system in _systems)
{
system.Update(_world, in context);
}
}
/// <summary>Fills <paramref name="buffer"/> with the replicated state of every visible entity.</summary>
public void CaptureSnapshot(List<EntitySnapshot> buffer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(buffer);
buffer.Clear();
var query = new QueryDescription().WithAll<NetworkId, Position, Renderable>();
_world.Query(in query, (ref NetworkId id, ref Position position, ref Renderable renderable) =>
{
buffer.Add(new EntitySnapshot(
id.Value,
renderable.Kind,
position.X,
position.Y,
renderable.Radius,
renderable.Color));
});
}
/// <summary>Replication id of a connected player, or <c>null</c> if it is not spawned.</summary>
public uint? GetNetworkId(uint playerId) =>
_playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
? _world.Get<NetworkId>(entity).Value
: null;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
World.Destroy(_world);
}
/// <summary>A few static blocks so an empty world still shows something on screen.</summary>
private void SpawnObstacles()
{
ReadOnlySpan<(float X, float Y, float Radius)> layout =
[
(0.5f, 0.5f, 70f),
(0.2f, 0.25f, 45f),
(0.8f, 0.75f, 45f),
];
foreach (var (relativeX, relativeY, radius) in layout)
{
_world.Create(
new NetworkId(_nextNetworkId++),
new Position(Options.WorldWidth * relativeX, Options.WorldHeight * relativeY),
new Renderable
{
Kind = EntityKind.Obstacle,
Radius = radius,
Color = Palette.Obstacle,
});
}
}
/// <summary>Deterministic spread of spawn points around the centre of the field.</summary>
private (float X, float Y) SpawnPoint(uint playerId)
{
const int Slots = 8;
var slot = (int)(playerId % Slots);
var angle = slot * (2f * MathF.PI / Slots);
var radius = MathF.Min(Options.WorldWidth, Options.WorldHeight) * 0.3f;
return (
(Options.WorldWidth * 0.5f) + (MathF.Cos(angle) * radius),
(Options.WorldHeight * 0.5f) + (MathF.Sin(angle) * radius));
}
}
@@ -1,16 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arch" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arch" />
</ItemGroup>
</Project>
@@ -1,12 +0,0 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One stage of the fixed-step pipeline. Systems run in registration order on the
/// loop thread and must not capture per-step state.
/// </summary>
public interface ISimulationSystem
{
void Update(World world, in SimulationContext context);
}
-22
View File
@@ -1,22 +0,0 @@
namespace HSchool.Simulation;
/// <summary>Stable colours for replicated entities, packed as 0x00RRGGBB.</summary>
public static class Palette
{
public const uint Obstacle = 0x3A4553;
private static readonly uint[] PlayerColors =
[
0x4CC9F0,
0xF72585,
0x7BF1A8,
0xFFB703,
0xB388EB,
0xFF7A5C,
0x5CE1E6,
0xE9FF70,
];
/// <summary>Same player id always gets the same colour, on both server and client.</summary>
public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
}
+54
View File
@@ -0,0 +1,54 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One save: a name, a calendar and the ECS world that will hold everything the school is made of.
/// The world is empty for now — pupils, rooms and staff land in it as the game grows — but it is
/// created and destroyed with the school so ownership is never in question.
/// </summary>
public sealed class School : IDisposable
{
/// <summary>Longest name a school may carry, in characters.</summary>
public const int MaxNameLength = 40;
private bool _disposed;
internal School(int id, string name, DateTime startDate)
{
Id = id;
Name = name;
Clock = new GameClock(startDate);
World = World.Create();
}
public int Id { get; }
public string Name { get; }
public GameClock Clock { get; }
/// <summary>The Arch world backing this school. Only the loop thread may touch it.</summary>
public World World { get; }
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary>
public void Tick(double deltaTime, double gameMinutesPerRealSecond)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Clock.Advance(deltaTime, gameMinutesPerRealSecond);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Fully qualified: the `World` property would otherwise shadow the type.
Arch.Core.World.Destroy(World);
}
}
@@ -0,0 +1,57 @@
namespace HSchool.Simulation;
/// <summary>
/// Suggestions for the "random name" button. Lives here rather than in the client because the
/// server is the one that knows which names are already taken.
/// </summary>
public sealed class SchoolNameGenerator(Random? random = null)
{
private const int AttemptsBeforeNumbering = 24;
private static readonly string[] Kinds = ["Школа", "Гимназия", "Лицей", "Школа-интернат"];
private static readonly string[] Epithets =
[
"Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная",
"Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная",
];
private readonly Random _random = random ?? Random.Shared;
/// <summary>
/// A name that is not in <paramref name="taken"/>. Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted.
/// </summary>
public string Next(IEnumerable<string> taken)
{
var used = new HashSet<string>(taken, StringComparer.OrdinalIgnoreCase);
for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++)
{
var candidate = Compose();
if (used.Add(candidate))
{
return candidate;
}
}
for (var number = 1; ; number++)
{
var candidate = $"Школа №{number}";
if (!used.Contains(candidate))
{
return candidate;
}
}
}
private string Compose()
{
var kind = Kinds[_random.Next(Kinds.Length)];
// Half the names are numbered, half are named — both read like a real school.
return _random.Next(2) == 0
? $"{kind} №{_random.Next(1, 100)}"
: $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»";
}
}
+141
View File
@@ -0,0 +1,141 @@
namespace HSchool.Simulation;
/// <summary>Why a school could not be created.</summary>
public enum SchoolCreationError
{
None = 0,
LimitReached,
InvalidName,
InvalidStartDate,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
public readonly record struct SchoolCreationResult(School? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
public static SchoolCreationResult Failed(SchoolCreationError error) => new(null, error);
}
/// <summary>
/// Every school that currently exists, plus the cap from configuration. Not thread-safe by design —
/// only the loop thread touches it, everything else goes through the command queue in the server.
/// </summary>
public sealed class SchoolRegistry : IDisposable
{
private readonly SimulationOptions _options;
private readonly List<School> _schools = [];
private int _nextId = 1;
private bool _disposed;
public SchoolRegistry(SimulationOptions options)
{
_options = options;
NameGenerator = new SchoolNameGenerator();
}
public SchoolNameGenerator NameGenerator { get; }
public int MaxSchools => _options.MaxSchools;
public int Count => _schools.Count;
public bool IsFull => _schools.Count >= _options.MaxSchools;
/// <summary>Schools in creation order — the order the menu lists them in.</summary>
public IReadOnlyList<School> Schools => _schools;
public School? Find(int id) => _schools.Find(school => school.Id == id);
public SchoolCreationResult Create(string name, DateTime startDate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (IsFull)
{
return SchoolCreationResult.Failed(SchoolCreationError.LimitReached);
}
if (!TryNormalizeName(name, out var normalized))
{
return SchoolCreationResult.Failed(SchoolCreationError.InvalidName);
}
if (!GameClock.IsValidStartDate(startDate))
{
return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
}
var school = new School(_nextId++, normalized, startDate);
_schools.Add(school);
return new SchoolCreationResult(school, SchoolCreationError.None);
}
public bool Delete(int id)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var school = Find(id);
if (school is null)
{
return false;
}
_schools.Remove(school);
school.Dispose();
return true;
}
/// <summary>Advances every running school by one fixed step.</summary>
public void Tick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
foreach (var school in _schools)
{
school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
}
}
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name));
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
public static bool TryNormalizeName(string? name, out string normalized)
{
normalized = string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength)
{
return false;
}
normalized = cleaned;
return true;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var school in _schools)
{
school.Dispose();
}
_schools.Clear();
}
}
@@ -1,7 +0,0 @@
namespace HSchool.Simulation;
/// <summary>Per-step data handed to every system.</summary>
/// <param name="Tick">Index of the step being simulated.</param>
/// <param name="DeltaTime">Fixed step length in seconds.</param>
/// <param name="Options">Simulation tunables.</param>
public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
+33 -23
View File
@@ -1,23 +1,33 @@
namespace HSchool.Simulation;
/// <summary>Tunables of the authoritative simulation. Bound from the <c>Simulation</c> config section.</summary>
public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;
public float WorldWidth { get; set; } = 1600f;
public float WorldHeight { get; set; } = 900f;
public float PlayerSpeed { get; set; } = 260f;
public float PlayerRadius { get; set; } = 18f;
/// <summary>Length of one fixed step.</summary>
public float FixedDeltaTime => 1f / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
}
namespace HSchool.Simulation;
/// <summary>Tunables of the authoritative simulation. Bound from the <c>Simulation</c> config section.</summary>
public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;
/// <summary>How many schools may exist at the same time.</summary>
public int MaxSchools { get; set; } = 6;
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
public double GameMinutesPerRealSecond { get; set; } = 5d;
/// <summary>Prefilled start of a new school; the client shows it in the creation form.</summary>
public DateTime DefaultStartDate
{
get => _defaultStartDate;
// Configuration binding yields Kind=Unspecified, which serializes without a "Z" and makes
// the browser read the date in its own time zone. The game calendar is always UTC.
set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
/// <summary>Length of one fixed step.</summary>
public double FixedDeltaTime => 1d / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
}
@@ -1,22 +0,0 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Integrates velocity into position with the fixed step.</summary>
public sealed class MovementSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity>();
public void Update(World world, in SimulationContext context)
{
var deltaTime = context.DeltaTime;
world.Query(in Query, (ref Position position, ref Velocity velocity) =>
{
position.X += velocity.X * deltaTime;
position.Y += velocity.Y * deltaTime;
});
}
}
@@ -1,37 +0,0 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Turns the latest button mask of every player into a velocity vector.</summary>
public sealed class PlayerInputSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<PlayerControl, Velocity>();
public void Update(World world, in SimulationContext context)
{
world.Query(in Query, (ref PlayerControl control, ref Velocity velocity) =>
{
var x = 0f;
var y = 0f;
if ((control.Buttons & InputButtons.Left) != 0) x -= 1f;
if ((control.Buttons & InputButtons.Right) != 0) x += 1f;
if ((control.Buttons & InputButtons.Up) != 0) y -= 1f;
if ((control.Buttons & InputButtons.Down) != 0) y += 1f;
// Normalize so diagonals are not faster than the cardinal directions.
if (x != 0f && y != 0f)
{
const float InverseSqrt2 = 0.70710678f;
x *= InverseSqrt2;
y *= InverseSqrt2;
}
velocity.X = x * control.Speed;
velocity.Y = y * control.Speed;
});
}
}
@@ -1,47 +0,0 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Keeps every body inside the play field and kills the velocity it pushed with.</summary>
public sealed class WorldBoundsSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity, Renderable>();
public void Update(World world, in SimulationContext context)
{
var width = context.Options.WorldWidth;
var height = context.Options.WorldHeight;
world.Query(in Query, (ref Position position, ref Velocity velocity, ref Renderable renderable) =>
{
var minX = renderable.Radius;
var maxX = width - renderable.Radius;
var minY = renderable.Radius;
var maxY = height - renderable.Radius;
if (position.X < minX)
{
position.X = minX;
velocity.X = 0f;
}
else if (position.X > maxX)
{
position.X = maxX;
velocity.X = 0f;
}
if (position.Y < minY)
{
position.Y = minY;
velocity.Y = 0f;
}
else if (position.Y > maxY)
{
position.Y = maxY;
velocity.Y = 0f;
}
});
}
}