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