From 5df341f91b61f7178625865ff9981eb74a7a84e7 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 15:52:17 +0300 Subject: [PATCH 1/2] Restore Slavic save lift and redress abstract underwear on load. Recheck of slice 7 stage A found UpgradeOlderSave removed after UpperUnderwear, so format-2 nameSetId saves never started, and golden people.json wearing abstract Underwear failed RequireKnownApparel. Bring back the lift, treat unusable items as undressed, and restore GoldenSaveTests. --- docs/phases/07-inventory/reviewed.md | 65 +++++----- docs/phases/reviewed.md | 2 +- src/HSchool.People/DressGenerator.cs | 22 +++- src/HSchool.Server/Game/SchoolStore.cs | 56 ++++++++- src/HSchool.Server/Game/SchoolWorker.Tick.cs | 2 +- .../HSchool.AppHost.Tests/GoldenSaveTests.cs | 111 ++++++++++++++++++ .../DressGeneratorTests.cs | 32 ++++- 7 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 tests/HSchool.AppHost.Tests/GoldenSaveTests.cs diff --git a/docs/phases/07-inventory/reviewed.md b/docs/phases/07-inventory/reviewed.md index 9ce94d1..0415c12 100644 --- a/docs/phases/07-inventory/reviewed.md +++ b/docs/phases/07-inventory/reviewed.md @@ -5,53 +5,58 @@ ## Срез 7. Этап A — данные и гардероб - **Фазы:** 29–31 -- **Проверен на:** `d5547b7`, 2026-08-20 -- **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`, +- **Проверен на:** `PENDING`, 2026-08-20 — **перепроверка** после `d5547b7` +- **Пути:** `src/HSchool.Content/{ApparelDefs,CatalogLoader}.cs`, `src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`, - `src/HSchool.Server/{Game/SchoolStore.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`, - `src/HSchool.Client/src/ui/{createSchoolDialog.ts,personCard.ts}`, + `src/HSchool.Server/{Game/SchoolStore.cs,Game/SchoolWorker.Tick.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`, + `src/HSchool.Client/src/ui/{createSchoolDialog.ts,personCard.ts,personCardApparel.ts,personCardCarry.ts}`, `tests/HSchool.Content.Tests/{CountryDefTests,ApparelDefTests,VanillaCoreTests}.cs`, `tests/HSchool.People.Tests/{DressGeneratorTests,GoldenRosterTests}.cs`, `tests/HSchool.AppHost.Tests/{SchoolApiTests,InventoryApiTests,GoldenSaveTests,ServiceabilityTests}.cs`, `src/HSchool.Client/src/ui/createSchoolDialog.test.ts` -- **Итог:** все 17 тестов из списков фаз на месте; исправлен дубликат `peopleTabOverview` в - `strings.ts` (ломал `npm run build`); одно замечание рядом про вкладки карточки +- **Итог:** обещания 29–31 после дрейфа (сессия, владельцы, UpperUnderwear, сплит карточки) + в целом держатся; снятый в `660340c` подъём `nameSetId: Slavic` возвращён; золотой сейв с + абстрактным `Underwear` снова одевается. B–C не трогался. Что подтверждено: -- **29:** `CountryDef`/`ClimatePresetDef`; `namesets/` убран; ваниль `Russia` с двумя пресетами; - каталог отдаёт `countries`; create принимает `countryId`, `unknown-country` на чужую страну; - пресет от сида (`CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset`); `nameSetId: Slavic` - поднимается через `UpgradeOlderSave` (`GoldenSaveTests`, `legacy-no-native/`); формат без - страны не стартует (`ServiceabilityTests`); диалог — страны и языки (`createSchoolDialog.test.ts`); - золотой ростер обновлён (`GoldenRosterTests`). -- **30:** поля `ThingDef`/`ColorDef`, слои как роли, мебель без `layers`; ванильная палитра и - одежда в `VanillaCoreTests`; локали ru/en (`EveryVanillaLabel_ExistsInBothLocales`); учебник - один def без `subject` на def. -- **31:** `InventoryItem` с `def`/`color`/`condition`/`location`/`subject`; `DressGenerator` - детерминирован (`DressGeneratorTests`, `InventoryApiTests.SameSeed_YieldsTheSameShirt`); слои - без пересечения, платье на два слоя; пол/яркость/учебник/родители/соискатели/масса/Hauling; - карточка отдаёт worn+carried, список — нет (`InventoryApiTests`); старый `people.json` без - items одевается на reload (`PeopleJsonWithoutInventory_IsDressedOnReloadAndRewritten`); - `HSchool.People`/`HSchool.Content` без ASP.NET; протокол не тронут. +- **29:** `CountryDef`/`ClimatePresetDef`; `namesets/` нет; ваниль `Russia` с двумя пресетами; + каталог отдаёт `countries`, не `nameSets`; create — `countryId`, `unknown-country`; + пресет от сида (`CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset`); формат 3 без + `countryId` не стартует (`ServiceabilityTests`); диалог — страны и языки; + золотой ростер (`GoldenRosterTests`). `UpgradeOlderSave` снова в `SchoolStore`: format 2 + `nameSetId: Slavic` поднимается как `Russia` + первый пресет в памяти, файл не + переписывается, пока школа сама не сохранится (`GoldenSaveTests`, `legacy-no-native/`). +- **30:** поля `ThingDef`/`ColorDef`, слои включая `UpperUnderwear`; мебель без `layers`; + каталог редактора без футболки/учебника; ваниль и локали (`VanillaCoreTests`); учебник — + один def без предмета на def. +- **31:** `InventoryItem`; `DressGenerator` от `ApparelSalt`; слои без пересечения, платье, + пол/яркость/учебник/родители/соискатели/масса/`Hauling`; карточка worn+carried, список — + нет; пустой `people.json` одевается на reload. Протокол этапа A не тронут; + `HSchool.People`/`Content` без ASP.NET. Дописано: -- (нет новых тестов — списки фаз закрыты существующими) +- `GoldenSaveTests.CurrentFormatSave_LoadsTheSamePeopleAndTime` — возвращён (снят в фазе 39) +- `GoldenSaveTests.SaveWithoutNativeLanguage_LoadsWithoutReshuffling` — Slavic-сейв стартует, одежда дописывается +- `DressGeneratorTests.AbstractUnderwearInASave_IsRedressedOnEnsure` Исправлено: -- Дубликат ключа `peopleTabOverview` в `strings.ts` (ru/en): vitest не ловит TS2300/TS1117, - `npm run build` падал; ключ перенесён, `peopleTabPortrait` рядом с остальными вкладками. -- Повтор `using HSchool.Content` в `SchoolStore.cs` (CS0105). +- `660340c` выкинул `NameSetId`/`UpgradeOlderSave` и сузил `CanStart` до format == 3: старые + сейвы с `nameSetId: Slavic` не стартовали. Подъём возвращён; `CanStart` — format ≤ 3 и + непустой `countryId`. +- После `UpperUnderwear` золотой `people.json` носил абстрактный `Underwear`, и + `RequireKnownApparel` не пускал школу. `NeedsDressing`/`EnsureDressed` переодевают + неизвестный или абстрактный def тем же сидом. Замечено рядом: -- Карточка уже с вкладками (`personCard.ts`, фаза 31 обещала один свиток — позже по - `../../design/07-inventory/inventory.md`); портрет/SwarmUI-строки в `strings.ts` — не этап A. -- `ResolveClimatePreset` в `SchoolWorker` при reload без сохранённого пресета берёт первый - пресет страны (`schoolSeed: 0`, `rollIfOmitted: false`) — по дизайну для legacy; create - кидает пресет из `GameLoopService` до spawn. +- Карточка с вкладками — этап C (фаза 36); сплит в `personCardApparel.ts` / `personCardCarry.ts`. +- `ResolveClimatePreset` на reload без сохранённого пресета берёт первый пресет страны. +- POST `nameSetId` по-прежнему игнорируется (поля нет на create); пустой `countryId` на create + берёт первую страну каталога — это create, не сейв. +- Этап B–C в этом проходе не проверялся. ## Срез 7. Этап B–C — климат, жизнь одежды и правила diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 097eb0d..76603a6 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -26,7 +26,7 @@ | Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) | | Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) | | Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) | -| Срез 7. Этап A — данные и гардероб | `d5547b7` | [07-inventory](07-inventory/reviewed.md) | +| Срез 7. Этап A — данные и гардероб | `PENDING` | [07-inventory](07-inventory/reviewed.md) | | Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) | | Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) | diff --git a/src/HSchool.People/DressGenerator.cs b/src/HSchool.People/DressGenerator.cs index 120941c..10053c6 100644 --- a/src/HSchool.People/DressGenerator.cs +++ b/src/HSchool.People/DressGenerator.cs @@ -71,7 +71,7 @@ public static class DressGenerator int? pupilYear, int age) { - if (person.Items.Count > 0) + if (person.Items.Count > 0 && ItemsUsable(catalog, person.Items)) { return person; } @@ -130,11 +130,12 @@ public static class DressGenerator return pool with { Applicants = applicants }; } - public static bool NeedsDressing(Roster roster, ApplicantPool? pool) + public static bool NeedsDressing(DefCatalog catalog, Roster roster, ApplicantPool? pool) { + ArgumentNullException.ThrowIfNull(catalog); foreach (var person in roster.People) { - if (person.Items.Count == 0) + if (person.Items.Count == 0 || !ItemsUsable(catalog, person.Items)) { return true; } @@ -147,7 +148,7 @@ public static class DressGenerator foreach (var applicant in pool.Applicants) { - if (applicant.Person.Items.Count == 0) + if (applicant.Person.Items.Count == 0 || !ItemsUsable(catalog, applicant.Person.Items)) { return true; } @@ -156,6 +157,19 @@ public static class DressGenerator return false; } + private static bool ItemsUsable(DefCatalog catalog, IReadOnlyList items) + { + foreach (var item in items) + { + if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) + { + return false; + } + } + + return true; + } + /// /// Adds textbooks for subjects the pupil just became old enough for. Existing clothes stay. /// diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 20ec7fa..6ce9b15 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -30,6 +30,9 @@ internal sealed class SchoolSave public string? ClimatePresetId { get; init; } + /// Legacy field. Format 2 and older; mapped to on load. + public string? NameSetId { get; init; } + public string? NativeLanguage { get; init; } public IReadOnlyList? Presence { get; init; } @@ -155,6 +158,11 @@ internal sealed class SchoolStore continue; } + if (save.Format < CurrentFormat) + { + save = UpgradeOlderSave(save); + } + // Claimed last, so a file rejected above does not reserve an id a good file needs. if (!claimed.TryAdd(save.Id, path)) { @@ -178,6 +186,7 @@ internal sealed class SchoolStore Map = save.Map, CountryId = save.CountryId, ClimatePresetId = save.ClimatePresetId, + NameSetId = save.NameSetId, NativeLanguage = save.NativeLanguage, Presence = save.Presence, DressRules = save.DressRules, @@ -196,9 +205,52 @@ internal sealed class SchoolStore return saves; } - /// Older and newer files stay on disk for the menu to delete; they never start. + /// + /// Format 2 stored a name set. Slavic becomes Russia; any other id is tried as a country + /// (the example pack kept its defName). Climate is filled on the worker from the country's + /// first preset so the file is not rewritten until the school saves itself. + /// + internal static SchoolSave UpgradeOlderSave(SchoolSave save) + { + if (!string.IsNullOrWhiteSpace(save.CountryId)) + { + return save; + } + + var countryId = save.NameSetId switch + { + "Slavic" or null or "" => "Russia", + _ => save.NameSetId, + }; + + return new SchoolSave + { + Format = save.Format, + Id = save.Id, + Name = save.Name, + GameTime = save.GameTime, + Running = save.Running, + SpeedIndex = save.SpeedIndex, + ModIds = save.ModIds, + Map = save.Map, + CountryId = countryId, + ClimatePresetId = save.ClimatePresetId, + NameSetId = save.NameSetId, + NativeLanguage = save.NativeLanguage, + Presence = save.Presence, + DressRules = save.DressRules, + SpeechRules = save.SpeechRules, + Owner = save.Owner, + PortraitSettings = save.PortraitSettings, + }; + } + + /// + /// Newer files, and current-format files without a country, stay on disk for the menu to + /// delete; they never start. Format 2 name-set saves are lifted first, then this gate. + /// public static bool CanStart(SchoolSave save) => - save.Format == CurrentFormat && !string.IsNullOrWhiteSpace(save.CountryId); + save.Format <= CurrentFormat && !string.IsNullOrWhiteSpace(save.CountryId); public void Save(SchoolSave save) { diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs index ff76730..f11bce3 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs @@ -307,7 +307,7 @@ internal sealed partial class SchoolWorker generated = true; } - if (DressGenerator.NeedsDressing(roster, applicants)) + if (DressGenerator.NeedsDressing(catalog, roster, applicants)) { roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time); applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time); diff --git a/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs new file mode 100644 index 0000000..88bc379 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs @@ -0,0 +1,111 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class GoldenSaveTests(AppHostFixture fixture) +{ + [Fact] + public async Task CurrentFormatSave_LoadsTheSamePeopleAndTime() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + await InstallAsync(client, "current"); + + var state = await SchoolApiTests.GetSchoolsAsync(client); + var school = Assert.Single(state.Schools); + Assert.Equal(1, school.Id); + Assert.Equal(1, school.Seed); + Assert.Equal("Золотая", school.Name); + Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime); + Assert.False(school.Running); + + var people = await PeopleAsync(client, school.Id); + Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray()); + Assert.Contains(people, person => person.Roles.Contains("student")); + } + + [Fact] + public async Task SaveWithoutNativeLanguage_LoadsWithoutReshuffling() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + await InstallAsync(client, "legacy-no-native"); + + var state = await SchoolApiTests.GetSchoolsAsync(client); + var school = Assert.Single(state.Schools); + Assert.Equal(1, school.Id); + Assert.Equal(1, school.Seed); + Assert.Equal("Золотая", school.Name); + + var people = await PeopleAsync(client, school.Id); + Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray()); + + var directory = await SchoolApiTests.SavesDirectoryAsync(client); + Assert.Contains("\"items\"", File.ReadAllText(Path.Combine(directory, "1.people.json")), StringComparison.Ordinal); + } + + private async Task InstallAsync(HttpClient client, string folder) + { + var directory = await SchoolApiTests.SavesDirectoryAsync(client); + var source = Path.Combine(AppContext.BaseDirectory, "golden", folder); + foreach (var file in Directory.EnumerateFiles(source)) + { + File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: true); + } + + var session = await client.GetFromJsonAsync("/api/session", TestContext.Current.CancellationToken); + Assert.NotNull(session); + StampOwner(Path.Combine(directory, "1.json"), session.UserName); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + } + + private static void StampOwner(string path, string userName) + { + var node = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException($"Save {path} parsed to nothing."); + node["owner"] = userName; + File.WriteAllText( + path, + node.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private static async Task> PeopleAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?pageSize=100", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + return page.People + .OrderBy(row => row.Id, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpectedNames() + { + var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "golden", "current", "1.people.json")); + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("people") + .EnumerateArray() + .Select(person => + { + var name = person.GetProperty("name"); + var surname = name.GetProperty("surname").GetString() ?? ""; + var given = name.GetProperty("given").GetString() ?? ""; + var patronymic = name.GetProperty("patronymic").GetString() ?? ""; + return string.Join(' ', new[] { surname, given, patronymic }.Where(part => part.Length > 0)); + }) + .OrderBy(full => full, StringComparer.Ordinal) + .ToArray(); + } + + private sealed record SessionResponse(string UserName); + + private sealed record PeoplePage(IReadOnlyList People); + + private sealed record PersonRow(string Id, string FullName, IReadOnlyList Roles); +} diff --git a/tests/HSchool.People.Tests/DressGeneratorTests.cs b/tests/HSchool.People.Tests/DressGeneratorTests.cs index 52ce8b5..6e9f35e 100644 --- a/tests/HSchool.People.Tests/DressGeneratorTests.cs +++ b/tests/HSchool.People.Tests/DressGeneratorTests.cs @@ -130,9 +130,9 @@ public class DressGeneratorTests roster.Families, roster.Classes); - Assert.True(DressGenerator.NeedsDressing(stripped, ApplicantPool.Empty)); + Assert.True(DressGenerator.NeedsDressing(catalog, stripped, ApplicantPool.Empty)); var dressed = DressGenerator.EnsureRoster(catalog, stripped, Fixtures.SchoolSeed, Fixtures.AsOf); - Assert.False(DressGenerator.NeedsDressing(dressed, ApplicantPool.Empty)); + Assert.False(DressGenerator.NeedsDressing(catalog, dressed, ApplicantPool.Empty)); Assert.Equal(ItemsSnapshot(roster), ItemsSnapshot(dressed)); } @@ -180,6 +180,34 @@ public class DressGeneratorTests Assert.All(roster.People, person => Assert.True(person.Skills.ContainsKey("Hauling"))); } + [Fact] + public void AbstractUnderwearInASave_IsRedressedOnEnsure() + { + var catalog = Fixtures.Catalog(); + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var broken = new Roster( + roster.People.Select(person => + { + var items = person.Items.Select(item => + item.Def.Equals("Briefs", StringComparison.Ordinal) + || item.Def.Equals("Boxers", StringComparison.Ordinal) + || item.Def.Equals("Panties", StringComparison.Ordinal) + ? item with { Def = "Underwear" } + : item).ToArray(); + return person with { Items = items }; + }).ToArray(), + roster.Families, + roster.Classes); + + Assert.True(DressGenerator.NeedsDressing(catalog, broken, ApplicantPool.Empty)); + var dressed = DressGenerator.EnsureRoster(catalog, broken, Fixtures.SchoolSeed, Fixtures.AsOf); + Assert.False(DressGenerator.NeedsDressing(catalog, dressed, ApplicantPool.Empty)); + Assert.Equal(ItemsSnapshot(roster), ItemsSnapshot(dressed)); + Assert.DoesNotContain( + dressed.People.SelectMany(person => person.Items), + item => item.Def.Equals("Underwear", StringComparison.Ordinal)); + } + private static string ItemsSnapshot(Roster roster) => string.Join( '\n', From 6307ae21e5157f933a7da85c57bae31dd6cbaa74 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 15:53:10 +0300 Subject: [PATCH 2/2] Point slice 7 A recheck journal at 5df341f. --- docs/phases/07-inventory/reviewed.md | 2 +- docs/phases/reviewed.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/phases/07-inventory/reviewed.md b/docs/phases/07-inventory/reviewed.md index 0415c12..1652cb3 100644 --- a/docs/phases/07-inventory/reviewed.md +++ b/docs/phases/07-inventory/reviewed.md @@ -5,7 +5,7 @@ ## Срез 7. Этап A — данные и гардероб - **Фазы:** 29–31 -- **Проверен на:** `PENDING`, 2026-08-20 — **перепроверка** после `d5547b7` +- **Проверен на:** `5df341f`, 2026-08-20 — **перепроверка** после `d5547b7` - **Пути:** `src/HSchool.Content/{ApparelDefs,CatalogLoader}.cs`, `src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`, `src/HSchool.Server/{Game/SchoolStore.cs,Game/SchoolWorker.Tick.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`, diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 76603a6..37362e6 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -26,7 +26,7 @@ | Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) | | Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) | | Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) | -| Срез 7. Этап A — данные и гардероб | `PENDING` | [07-inventory](07-inventory/reviewed.md) | +| Срез 7. Этап A — данные и гардероб | `5df341f` | [07-inventory](07-inventory/reviewed.md) | | Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) | | Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) |