Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+71 -6
View File
@@ -89,6 +89,70 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.InRange(elapsed.TotalMinutes, 3, 8);
}
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal(school.Id, snapshot.SchoolId);
Assert.Contains(snapshot.Nodes, node => node.Id == "yard" && node.Kind == 0);
var office = Assert.Single(snapshot.Nodes, node => node.Id == "principals-office");
Assert.Equal("Кабинет директора", office.Name);
Assert.Equal("floor-1", office.ParentId);
Assert.Contains("Директор", office.Positions);
Assert.NotEmpty(office.Items);
}
[Fact]
public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
using var socket = await OpenSchoolAsync(school.Id, ProtocolConstants.LocaleEnglish);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name);
}
[Fact]
public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
var office = Assert.Single(snapshot.Nodes, node => node.Id == "office");
Assert.Equal("floor-1", office.ParentId);
}
[Fact]
public async Task ReloadFromDisk_RestoresACustomMap()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Contains(snapshot.Nodes, node => node.Id == "office");
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
}
[Fact]
public async Task Pausing_FreezesTheClock()
{
@@ -280,7 +344,7 @@ public class GameSocketTests(AppHostFixture fixture)
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer,
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1))));
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), ProtocolConstants.LocaleRussian)));
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
@@ -301,19 +365,20 @@ public class GameSocketTests(AppHostFixture fixture)
return school;
}
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId)
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
{
var socket = await ConnectAsync();
var socket = await ConnectAsync(locale);
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
return socket;
}
private async Task<ClientWebSocket> ConnectAsync()
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
{
var socket = await ConnectRawAsync();
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version)));
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket;
}
@@ -132,6 +132,92 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal(suggestion.Name, created.Name);
}
[Fact]
public async Task Mods_ListCoreAsRequired()
{
using var client = fixture.App.CreateHttpClient("server");
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
}
[Fact]
public async Task Catalog_LabelsDefsInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
}
[Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Дырявая",
startDate = ExpectedDefaultStart,
map = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = Array.Empty<object>(),
floors = Array.Empty<object>(),
rooms = Array.Empty<object>(),
links = Array.Empty<object>(),
},
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-map", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужой мод",
startDate = ExpectedDefaultStart,
modIds = new[] { "no-such-mod" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateWithMapAsync(client, "Своя карта", ExpectedDefaultStart, SimpleCustomMap);
Assert.Equal("Своя карта", created.Name);
Assert.Contains((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact]
public async Task Status_ReportsTheLoopAndTheLimit()
{
@@ -188,6 +274,31 @@ public class SchoolApiTests(AppHostFixture fixture)
return created;
}
internal static async Task<SchoolResponse> CreateWithMapAsync(HttpClient client, string name, DateTime startDate, object map)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate, map },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
internal static readonly object SimpleCustomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "office", def = "PrincipalsOffice", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "office" } },
};
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
client.PostAsJsonAsync(
"/api/schools",
@@ -213,4 +324,26 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record ProblemResponse(string? Code);
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(string Id, bool Required);
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(string DefName, string Label, IReadOnlyList<RoomSlotResponse> Slots, IReadOnlyList<string> Positions);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
}
@@ -0,0 +1,36 @@
namespace HSchool.Content.Tests;
public class MapViewTests
{
[Fact]
public void VanillaMap_LabelsTheTreeInTheRequestedLocale()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
var ru = MapView.Build(catalog, map, "ru");
var en = MapView.Build(catalog, map, "en");
Assert.Equal(["yard", "main", "floor-1", "corridor-1", "principals-office"], ru.Select(node => node.Id));
Assert.Equal(string.Empty, ru[0].ParentId);
Assert.Equal("yard", ru.Single(node => node.Id == "main").ParentId);
Assert.Equal("floor-1", ru.Single(node => node.Id == "principals-office").ParentId);
var officeRu = ru.Single(node => node.Id == "principals-office");
Assert.Equal("Кабинет директора", officeRu.Name);
Assert.Equal(["Кресло директора", "Стол", "Стул"], officeRu.Items);
Assert.Equal(["Директор"], officeRu.Positions);
var officeEn = en.Single(node => node.Id == "principals-office");
Assert.Equal("Principal's office", officeEn.Name);
Assert.Equal(["Principal's chair", "Desk", "Chair"], officeEn.Items);
Assert.Equal(["Principal"], officeEn.Positions);
var corridor = ru.Single(node => node.Id == "corridor-1");
Assert.Empty(corridor.Items);
Assert.Empty(corridor.Positions);
}
}
@@ -8,14 +8,17 @@ namespace HSchool.Protocol.Tests;
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTripsAndIsTwoBytes()
public void Hello_RoundTripsAndIsThreeBytes()
{
var message = new ClientHelloMessage(ProtocolConstants.Version);
var message = new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleEnglish);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(2, length);
Assert.Equal(3, length);
Assert.Equal((byte)MessageType.ClientHello, buffer[0]);
Assert.Equal(ProtocolConstants.Version, buffer[1]);
Assert.Equal(ProtocolConstants.LocaleEnglish, buffer[2]);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
@@ -130,6 +133,31 @@ public class ProtocolCodecTests
Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length]));
}
[Fact]
public void MapSnapshot_RoundTripsAndWritesHeaderOffsets()
{
var message = new ServerMapSnapshotMessage(7, [
new MapSnapshotNode(0, "yard", "", "Двор", [], []),
new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стул"], ["Директор"]),
]);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
Assert.Equal((byte)MessageType.ServerMapSnapshot, buffer[0]);
Assert.Equal(7, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(2, read.Nodes.Count);
Assert.Equal("yard", read.Nodes[0].Id);
Assert.Equal("", read.Nodes[0].ParentId);
Assert.Equal("Двор", read.Nodes[0].Name);
Assert.Equal(["Стул"], read.Nodes[1].Items);
Assert.Equal(["Директор"], read.Nodes[1].Positions);
}
[Fact]
public void Numbers_AreLittleEndian()
{
@@ -141,9 +169,9 @@ public class ProtocolCodecTests
}
[Fact]
public void MaxFrameSize_FitsEveryMessage()
public void MaxFrameSize_FitsEveryFixedSizeMessage()
{
// The handlers size their buffers from this constant; the clock frame is the largest one.
// Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));