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