From 0b1a52a5e554d5aaa4e3817020a8f88d7cc24950 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 12:46:52 +0300 Subject: [PATCH] Skip users.json when loading school saves. The session user list sits beside {id}.json; LoadAll treated it as a school with id 0. Co-authored-by: Cursor --- docs/bugs/03-users-json-as-school.md | 37 ++++++++++++ docs/bugs/README.md | 1 + src/HSchool.Server/Game/SchoolStore.cs | 1 + src/HSchool.Server/Game/UserStore.cs | 4 +- .../HSchool.Server.Tests.csproj | 1 + .../HSchool.Server.Tests/SchoolStoreTests.cs | 60 +++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 docs/bugs/03-users-json-as-school.md create mode 100644 tests/HSchool.Server.Tests/SchoolStoreTests.cs diff --git a/docs/bugs/03-users-json-as-school.md b/docs/bugs/03-users-json-as-school.md new file mode 100644 index 0000000..9bd1f67 --- /dev/null +++ b/docs/bugs/03-users-json-as-school.md @@ -0,0 +1,37 @@ +# Баг 3. SchoolStore принимает users.json за сейв школы + +## Симптом + +После первого входа в логе при старте сервера: + +``` +warn: HSchool.Server.Game.SchoolStore[0] + Save D:\Github\h-school\src\HSchool.Server\saves\users.json has id 0; ids start at 1. Leaving the file in place. +``` + +Ожидали, что список игроков рядом со школами не попадает в загрузку сейвов. + +## Причина + +Фаза 38 положила `saves/users.json` в тот же каталог, что и `{id}.json`. `SchoolStore.LoadAll` обходит все `*.json`, пропуская только `index.json`, `*.people.json` и `*.timetable.json`. У документа пользователей нет поля `id`, десериализация в `SchoolSave` даёт `Id = 0`, срабатывает предупреждение. Файл на месте остаётся, карточки школы из него нет. + +## Путь + +Пропускать `users.json` так же, как `index.json`. Не сужать обход до `{digits}.json` — это другой контракт сканера. + +## Задачи + +- [x] `LoadAll` не читает `users.json` как сейв +- [x] Тест: каталог с `1.json` и `users.json` возвращает одну школу + +## Тест, без которого не закрыт + +`HSchool.Server.Tests`: `LoadAll` рядом с валидным `{id}.json` и `users.json` (документ пользователей, без `id`) возвращает только школу. + +```bash +dotnet test tests/HSchool.Server.Tests --filter FullyQualifiedName~SchoolStore +``` + +## Стоп + +Форму `users.json` и HTTP сессии не трогать. `wwwroot` / `UseFileServer` — соседнее предупреждение при старте, не этот баг. diff --git a/docs/bugs/README.md b/docs/bugs/README.md index 0b36f28..beb0f00 100644 --- a/docs/bugs/README.md +++ b/docs/bugs/README.md @@ -11,3 +11,4 @@ | --- | --- | --- | | [1. Окно входа](01-login-layout.md) | ✅ | Форма входа прижата к углу, поле пароля на всю ширину | | [2. Запуск AppHost](02-client-endpoint-proxy.md) | ✅ | `run-aspire` падает: proxy + Port=TargetPort=5173 у client | +| [3. users.json как сейв](03-users-json-as-school.md) | 🔄 | При старте: `Save …/users.json has id 0; ids start at 1` | diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 038c124..625d1ef 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -119,6 +119,7 @@ internal sealed class SchoolStore { var fileName = Path.GetFileName(path); if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, UserStore.FileName, StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".timetable.json", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/HSchool.Server/Game/UserStore.cs b/src/HSchool.Server/Game/UserStore.cs index ab4ab7a..cc69dc1 100644 --- a/src/HSchool.Server/Game/UserStore.cs +++ b/src/HSchool.Server/Game/UserStore.cs @@ -8,7 +8,7 @@ internal sealed record UserRecord(string Name); /// Persistent user list beside school saves. internal sealed class UserStore { - private const string UsersFileName = "users.json"; + internal const string FileName = "users.json"; private static readonly JsonSerializerOptions Json = new() { @@ -25,7 +25,7 @@ internal sealed class UserStore public UserStore(SchoolStore schools, ILogger logger) { _logger = logger; - _path = Path.Combine(schools.DirectoryPath, UsersFileName); + _path = Path.Combine(schools.DirectoryPath, FileName); Load(); } diff --git a/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj index a7ed373..3dc244c 100644 --- a/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj +++ b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/HSchool.Server.Tests/SchoolStoreTests.cs b/tests/HSchool.Server.Tests/SchoolStoreTests.cs new file mode 100644 index 0000000..0d94b83 --- /dev/null +++ b/tests/HSchool.Server.Tests/SchoolStoreTests.cs @@ -0,0 +1,60 @@ +using HSchool.Server.Game; +using HSchool.Simulation; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace HSchool.Server.Tests; + +public class SchoolStoreTests +{ + [Fact] + public void LoadAll_SkipsUsersJsonBesideASchoolSave() + { + var directory = Directory.CreateTempSubdirectory("h-school-store-"); + try + { + File.WriteAllText( + Path.Combine(directory.FullName, UserStore.FileName), + """{ "users": [ { "name": "Player" } ] }"""); + File.WriteAllText( + Path.Combine(directory.FullName, "1.json"), + """ + { + "format": 3, + "id": 1, + "name": "North", + "gameTime": "2012-03-31T06:00:00Z", + "running": false, + "speedIndex": 0 + } + """); + + var store = CreateStore(directory.FullName); + var saves = store.LoadAll(); + + var save = Assert.Single(saves); + Assert.Equal(1, save.Id); + Assert.Equal("North", save.Name); + } + finally + { + directory.Delete(recursive: true); + } + } + + private static SchoolStore CreateStore(string directory) + { + var options = Options.Create(new SimulationOptions { SavesDirectory = directory }); + return new SchoolStore(options, new StubHost(), NullLogger.Instance); + } + + private sealed class StubHost : IHostEnvironment + { + public string ApplicationName { get; set; } = "HSchool.Server.Tests"; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = Path.GetTempPath(); + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +}