Merge branch 'review/slice-7-a-recheck'

This commit is contained in:
Leonid Pershin
2026-08-20 15:57:12 +03:00
7 changed files with 182 additions and 137 deletions
+35 -30
View File
@@ -5,53 +5,58 @@
## Срез 7. Этап A — данные и гардероб
- **Фазы:** 2931
- **Проверен на:** `d5547b7`, 2026-08-20
- **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`,
- **Проверен на:** `5df341f`, 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 — климат, жизнь одежды и правила
+1 -1
View File
@@ -26,7 +26,7 @@
| Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) |
| Срез 5. Жизнь школы | `1118c59` | [05-ai](05-ai/reviewed.md) |
| Срез 6. Фундамент | `1c63357` | [06-foundation](06-foundation/reviewed.md) |
| Срез 7. Этап A — данные и гардероб | `d5547b7` | [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) |
+18 -4
View File
@@ -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<InventoryItem> items)
{
foreach (var item in items)
{
if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract)
{
return false;
}
}
return true;
}
/// <summary>
/// Adds textbooks for subjects the pupil just became old enough for. Existing clothes stay.
/// </summary>
+54 -2
View File
@@ -30,6 +30,9 @@ internal sealed class SchoolSave
public string? ClimatePresetId { get; init; }
/// <summary>Legacy field. Format 2 and older; mapped to <see cref="CountryId"/> on load.</summary>
public string? NameSetId { get; init; }
public string? NativeLanguage { get; init; }
public IReadOnlyList<PresenceSnapshot>? 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;
}
/// <summary>Older and newer files stay on disk for the menu to delete; they never start.</summary>
/// <summary>
/// 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.
/// </summary>
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,
};
}
/// <summary>
/// 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.
/// </summary>
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)
{
+1 -1
View File
@@ -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);
+34 -88
View File
@@ -5,49 +5,31 @@ using System.Text.Json.Nodes;
namespace HSchool.AppHost.Tests;
/// <summary>
/// Phase 25 goldens. The folders stay historic (format 2, no country): that shape must not start.
/// Load tests stamp today's format, country and owner onto a copy so missing-field defaults and
/// seed-equals-id still have a host assertion after later slices raised <c>CanStart</c>.
/// Phase 25 goldens stay historic (format 2, <c>nameSetId: Slavic</c>). Phase 29 lifts that to
/// Russia in memory; the file is not rewritten until the school saves itself. Slice 6 recheck
/// treated format 2 as unstartable — that gate contradicted the lift, so these tests load.
/// </summary>
[Collection(AppHostCollection.Name)]
public class GoldenSaveTests(AppHostFixture fixture)
{
[Fact]
public async Task HistoricFormat2Golden_LeavesTheSchoolUnstartedAndTheFileUntouched()
public async Task CurrentFormatSave_LoadsTheSamePeopleAndTime()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var directory = await SchoolApiTests.SavesDirectoryAsync(client);
try
{
InstallFolder(directory, "current");
var path = Path.Combine(directory, "1.json");
var before = File.ReadAllBytes(path);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
await InstallAsync(client, "current");
var state = await SchoolApiTests.GetSchoolsAsync(client);
Assert.Empty(state.Schools);
var broken = Assert.Single(state.Others);
Assert.Equal(1, broken.Id);
Assert.Equal("Золотая", broken.Name);
Assert.Equal(1, broken.Seed);
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);
using var people = await client.GetAsync(
$"/api/schools/{broken.Id}/people?pageSize=1",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, people.StatusCode);
Assert.Equal(before, File.ReadAllBytes(path));
}
finally
{
DeleteSchoolFiles(directory, 1);
using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
cleanup.EnsureSuccessStatusCode();
}
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]
@@ -55,11 +37,7 @@ public class GoldenSaveTests(AppHostFixture fixture)
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var directory = await SchoolApiTests.SavesDirectoryAsync(client);
try
{
await InstallRunnableAsync(client, directory, "legacy-no-native", includeNative: false);
await InstallAsync(client, "legacy-no-native");
var state = await SchoolApiTests.GetSchoolsAsync(client);
var school = Assert.Single(state.Schools);
@@ -69,70 +47,36 @@ public class GoldenSaveTests(AppHostFixture fixture)
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"));
Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime);
}
finally
{
DeleteSchoolFiles(directory, 1);
using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
cleanup.EnsureSuccessStatusCode();
}
var directory = await SchoolApiTests.SavesDirectoryAsync(client);
Assert.Contains("\"items\"", File.ReadAllText(Path.Combine(directory, "1.people.json")), StringComparison.Ordinal);
}
private static async Task InstallRunnableAsync(
HttpClient client,
string directory,
string folder,
bool includeNative)
{
InstallFolder(directory, folder);
StampRunnable(Path.Combine(directory, "1.json"), includeNative);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
}
private static void InstallFolder(string directory, string folder)
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<SessionResponse>("/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 StampRunnable(string path, bool includeNative)
private static void StampOwner(string path, string userName)
{
var node = JsonNode.Parse(File.ReadAllText(path))
?? throw new InvalidOperationException($"Save {path} parsed to nothing.");
node["format"] = 3;
node["countryId"] = "Russia";
node["climatePresetId"] = "TemperateContinental";
node["owner"] = SchoolApiTests.TestUserName;
node.AsObject().Remove("nameSetId");
if (includeNative)
{
node["nativeLanguage"] = "RussianLanguage";
}
else
{
node.AsObject().Remove("nativeLanguage");
}
File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
private static void DeleteSchoolFiles(string directory, int id)
{
foreach (var name in new[] { $"{id}.json", $"{id}.people.json", $"{id}.timetable.json" })
{
var path = Path.Combine(directory, name);
if (File.Exists(path))
{
File.Delete(path);
}
}
node["owner"] = userName;
File.WriteAllText(
path,
node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
private static async Task<IReadOnlyList<PersonRow>> PeopleAsync(HttpClient client, int schoolId)
@@ -164,6 +108,8 @@ public class GoldenSaveTests(AppHostFixture fixture)
.ToArray();
}
private sealed record SessionResponse(string UserName);
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
private sealed record PersonRow(string Id, string FullName, IReadOnlyList<string> Roles);
@@ -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',