From 1b66c89cd71efb57a0b651ae8d7477dced875e0a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 02:55:13 +0300 Subject: [PATCH 1/2] Replace selectable name sets with a country that owns names and climate presets. Create picks a country; climate is rolled from the school seed and stored. Old Slavic saves lift as Russia. Co-authored-by: Cursor --- AGENTS.md | 6 +- docs/phases/29-country.md | 32 ++-- docs/protocol.md | 30 ++-- src/HSchool.Client/src/i18n/strings.ts | 12 +- src/HSchool.Client/src/net/api.ts | 9 +- .../src/ui/createSchoolDialog.test.ts | 68 ++++++++- .../src/ui/createSchoolDialog.ts | 62 ++++---- src/HSchool.Content/CatalogLoader.cs | 16 +- src/HSchool.Content/DefCatalog.cs | 29 +++- src/HSchool.Content/Defs.cs | 3 +- src/HSchool.Content/PackPaths.cs | 7 +- src/HSchool.Content/PeopleDefValidator.cs | 61 ++++++-- src/HSchool.Content/PeopleDefs.cs | 24 ++- src/HSchool.People/ApplicantPool.cs | 18 +-- src/HSchool.People/CountryClimate.cs | 26 ++++ src/HSchool.People/RosterGenerator.cs | 8 +- src/HSchool.People/Seed.cs | 1 + src/HSchool.People/YearlyIntake.cs | 8 +- src/HSchool.Server/Api/ModEndpoints.cs | 20 +-- src/HSchool.Server/Api/SchoolEndpoints.cs | 10 +- src/HSchool.Server/Game/GameCommand.cs | 2 +- src/HSchool.Server/Game/GameLoopService.cs | 36 +++-- src/HSchool.Server/Game/SchoolStore.cs | 46 +++++- src/HSchool.Server/Game/SchoolWorker.cs | 79 +++++----- .../core/defs/climates/continental-cold.jsonc | 3 + .../defs/climates/temperate-continental.jsonc | 3 + .../mods/core/defs/countries/russia.jsonc | 139 ++++++++++++++++++ .../mods/core/defs/namesets/slavic.jsonc | 136 ----------------- .../mods/core/localizations/en.jsonc | 4 +- .../mods/core/localizations/ru.jsonc | 4 +- src/HSchool.Server/mods/example/README.md | 2 +- .../mods/example/defs/countries/example.jsonc | 20 +++ .../mods/example/defs/namesets/example.jsonc | 17 --- src/HSchool.Simulation/School.cs | 20 ++- src/HSchool.Simulation/SchoolCreationError.cs | 2 +- tests/HSchool.Ai.Tests/WalkingTests.cs | 6 +- .../HSchool.AppHost.Tests/ExamplePackTests.cs | 8 +- tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 101 +++++++++++-- .../ServiceabilityTests.cs | 28 ++++ .../HSchool.Content.Tests/CountryDefTests.cs | 81 ++++++++++ .../HSchool.Content.Tests/ExamplePackTests.cs | 6 +- tests/HSchool.Content.Tests/PeopleDefTests.cs | 51 ++++--- .../HSchool.Content.Tests/VanillaCoreTests.cs | 3 +- .../ApplicantPoolTests.cs | 10 +- tests/HSchool.People.Tests/Fixtures.cs | 2 +- .../HSchool.People.Tests/GoldenRosterTests.cs | 2 +- tests/HSchool.People.Tests/GoldenSaveDump.cs | 4 +- tests/HSchool.People.Tests/ReviewFixTests.cs | 8 +- .../HSchool.People.Tests/RosterFingerprint.cs | 4 +- .../RosterGeneratorTests.cs | 2 +- tests/HSchool.People.Tests/SkillGrantTests.cs | 14 +- tests/HSchool.People.Tests/StaffingTests.cs | 4 +- .../HSchool.People.Tests/YearlyIntakeTests.cs | 10 +- tests/HSchool.People.Tests/golden/roster.txt | 2 +- .../HSchool.Simulation.Tests/DecisionTests.cs | 12 +- tests/HSchool.Simulation.Tests/LunchTests.cs | 6 +- .../PeopleInSchoolTests.cs | 26 ++-- .../HSchool.Simulation.Tests/PresenceTests.cs | 26 ++-- 58 files changed, 902 insertions(+), 477 deletions(-) create mode 100644 src/HSchool.People/CountryClimate.cs create mode 100644 src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc create mode 100644 src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc create mode 100644 src/HSchool.Server/mods/core/defs/countries/russia.jsonc delete mode 100644 src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc create mode 100644 src/HSchool.Server/mods/example/defs/countries/example.jsonc delete mode 100644 src/HSchool.Server/mods/example/defs/namesets/example.jsonc create mode 100644 tests/HSchool.Content.Tests/CountryDefTests.cs diff --git a/AGENTS.md b/AGENTS.md index 3b0e655..3f42042 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ way; this file is *how to work in them*. | --- | --- | | schools, the game clock, game rules | `src/HSchool.Simulation` | | defs, JSONC catalog, map validation | `src/HSchool.Content` | -| skills, traits, body, needs, name sets | `src/HSchool.Content` | +| skills, traits, body, needs, countries | `src/HSchool.Content` | | people generation, families, roster records, yearly intake | `src/HSchool.People` | | timetable planning | `src/HSchool.Schedule` | | routes, day plans, who comes today | `src/HSchool.Ai` | @@ -20,7 +20,7 @@ way; this file is *how to work in them*. | connection handling, workers, saves | `src/HSchool.Server` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` | | screens, dialogs, formatting, UI language | `src/HSchool.Client/src` | -| clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 29–37; no code yet | +| clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 29–37; country is live, clothing is not | ## Commands @@ -109,7 +109,7 @@ say so explicitly in the change description. - Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host. Putting a roster into `World` and ticking needs belongs there too. -- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map, name set and +- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map, country and native language must produce the same roster; the suite does not boot a host. - Timetable planning belongs in `tests/HSchool.Schedule.Tests`. Same staff, map and locks must produce the same table; the suite does not boot a host. diff --git a/docs/phases/29-country.md b/docs/phases/29-country.md index f2804ec..ab2fe05 100644 --- a/docs/phases/29-country.md +++ b/docs/phases/29-country.md @@ -12,32 +12,32 @@ Create выбирает страну, а не набор имён. Имена и ## Задачи -- [ ] `CountryDef` и `ClimatePresetDef` в `HSchool.Content`: страна вкладывает нынешние поля +- [x] `CountryDef` и `ClimatePresetDef` в `HSchool.Content`: страна вкладывает нынешние поля набора имён в `names`, ссылается на пресеты списками id -- [ ] Набор имён больше не выбираемый вид; папка `namesets/` в `core` уезжает в страну -- [ ] Ваниль: одна страна `Russia` с нынешним славянским набором и хотя бы одним пресетом +- [x] Набор имён больше не выбираемый вид; папка `namesets/` в `core` уезжает в страну +- [x] Ваниль: одна страна `Russia` с нынешним славянским набором и хотя бы одним пресетом (числа погоды можно заглушить — живая улица в фазе 32) -- [ ] `GET /api/catalog` отдаёт `countries` (`defName`, подпись, `nativeLanguages`, +- [x] `GET /api/catalog` отдаёт `countries` (`defName`, подпись, `nativeLanguages`, `climatePresets`) вместо `nameSets` -- [ ] `POST /api/schools` принимает `countryId`; неизвестный — `400` `unknown-country`. +- [x] `POST /api/schools` принимает `countryId`; неизвестный — `400` `unknown-country`. `nameSetId` не принимается -- [ ] Пресет климата записывается на школу: случайный из списка страны, от сида. Живой школе +- [x] Пресет климата записывается на школу: случайный из списка страны, от сида. Живой школе его не сменить -- [ ] Форма создания: подпись «Страна»; язык — если у страны их несколько -- [ ] Старый сейв с `nameSetId: Slavic` поднимается как `Russia` + первый пресет; формат +- [x] Форма создания: подпись «Страна»; язык — если у страны их несколько +- [x] Старый сейв с `nameSetId: Slavic` поднимается как `Russia` + первый пресет; формат новее без `countryId` — как с прочими полями сейва, школа не стартует -- [ ] `docs/protocol.md` и `AGENTS.md` (ориентир create / каталог) в том же коммите -- [ ] Золотой отпечаток ростера обновляется, если вход генератора сменил имя поля +- [x] `docs/protocol.md` и `AGENTS.md` (ориентир create / каталог) в том же коммите +- [x] Золотой отпечаток ростера обновляется, если вход генератора сменил имя поля ## Тесты, без которых фаза не закрыта -- [ ] Каталог `core` грузится; страна без имён или с неизвестным пресетом — ошибка загрузки -- [ ] Create с `countryId: Russia` и без языка даёт тот же родной язык, что прежний сид без +- [x] Каталог `core` грузится; страна без имён или с неизвестным пресетом — ошибка загрузки +- [x] Create с `countryId: Russia` и без языка даёт тот же родной язык, что прежний сид без `nativeLanguage` -- [ ] Create с неизвестной страной — `400` `unknown-country` -- [ ] Два create с одним сидом получают один и тот же пресет -- [ ] Старый сейв без страны поднимается -- [ ] Диалог создания показывает страны, не наборы имён; смена страны обновляет список языков +- [x] Create с неизвестной страной — `400` `unknown-country` +- [x] Два create с одним сидом получают один и тот же пресет +- [x] Старый сейв без страны поднимается +- [x] Диалог создания показывает страны, не наборы имён; смена страны обновляет список языков ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index eeb1521..578d960 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -89,13 +89,15 @@ single seats field on a homeroom and the old slot rows on everything else. Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot total is computed on the server. -`nameSets` is the list of placeable name packs (`defName` + label + `nativeLanguages`). Each -`nativeLanguages` entry is a skill (`defName` + label). The create dialog picks a name set and a -native language; the language list comes from that set (vanilla Slavic: Russian, Belarusian, -Ukrainian). It is independent of the UI language. Everyone in the school is generated with that -native. Other languages of the same set often appear at a low skill — a Russian speaker who -understands Belarusian. Those rolls live on the `NameSetDef` (`relatedLanguageChance` and -neighbours), not on this catalog payload. +`countries` is the list of placeable countries (`defName` + label + `nativeLanguages` + +`climatePresets`). Each `nativeLanguages` entry is a skill (`defName` + label). The create +dialog picks a country and, when that country lists more than one tongue, a native language; +the language list comes from that country (vanilla Russia: Russian, Belarusian, Ukrainian). +It is independent of the UI language. Everyone in the school is generated with that native. +Other languages of the same country often appear at a low skill — a Russian speaker who +understands Belarusian. Those rolls live on the nested names of the `CountryDef` +(`relatedLanguageChance` and neighbours), not on this catalog payload. `climatePresets` are +ids; the school rolls one at create from its seed and keeps it. `subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`, `hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson @@ -121,7 +123,7 @@ Body: "startDate": "2012-03-31T06:00:00Z", "modIds": [], "map": null, - "nameSetId": "Slavic", + "countryId": "Russia", "nativeLanguage": null, "seed": null } @@ -131,10 +133,10 @@ Body: pack's `requires` load first (stable topological sort over the player's order). The resolved order is returned as `modIds` on the created school and written to the save, so a restart loads the same catalog. Omit `map` (or send `null`) to use that pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. -`nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla: -`Slavic`). Unknown ids return `400` `unknown-name-set`. -`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick -one from the school seed. An id that is not in the set returns `400` `unknown-native-language`. +`countryId` is a `CountryDef`; omit it to use the first placeable country in the catalog (vanilla: +`Russia`). Unknown ids return `400` `unknown-country`. The old `nameSetId` field is not accepted. +`nativeLanguage` is a skill from that country's `nativeLanguages`. Omit it (or send `null`) to pick +one from the school seed. An id that is not in the country returns `400` `unknown-native-language`. `seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and the server rolls one. Existing saves keep the seed already stored in the people file. @@ -148,8 +150,8 @@ the server rolls one. Existing saves keep the seed already stored in the people | `400` `missing-mod` | A selected pack `requires` an id that was not selected. `missing` is that id. | | `400` `mod-cycle` | Selected packs require each other in a cycle. | | `400` `invalid-catalog` | The selected packs could not be loaded. | -| `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. | -| `400` `unknown-native-language` | `nativeLanguage` is not in that name set's `nativeLanguages`. | +| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. | +| `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. | | `409` `school-limit-reached` | `maxSchools` schools already exist. | Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 96c1f29..86af8c6 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -43,12 +43,12 @@ const ru = { errorMissingMod: 'Не выбран обязательный мод «{id}».', errorModCycle: 'Выбранные моды зависят друг от друга по кругу.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', - errorUnknownNameSet: 'Выбранный набор имён не найден.', - errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.', + errorUnknownCountry: 'Выбранная страна не найдена.', + errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.', catalogLoadFailed: 'Не удалось загрузить каталог модов.', modsTitle: 'Моды', - nameSetTitle: 'Набор имён', + countryTitle: 'Страна', nativeLanguageTitle: 'Родной язык', nativeLanguageRandom: 'Случайный', randomNativeTitle: 'Выбрать язык наугад', @@ -260,12 +260,12 @@ const en: Messages = { errorMissingMod: 'Required mod “{id}” is not selected.', errorModCycle: 'The selected mods depend on each other in a cycle.', errorInvalidCatalog: 'The selected packs could not be loaded.', - errorUnknownNameSet: 'The selected name set is not in the catalog.', - errorUnknownNativeLanguage: 'The selected native language is not in that name set.', + errorUnknownCountry: 'The selected country is not in the catalog.', + errorUnknownNativeLanguage: 'The selected native language is not in that country.', catalogLoadFailed: 'Could not load the mod catalog.', modsTitle: 'Mods', - nameSetTitle: 'Name set', + countryTitle: 'Country', nativeLanguageTitle: 'Native language', nativeLanguageRandom: 'Random', randomNativeTitle: 'Pick a language at random', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 265cf50..245fffb 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -53,7 +53,7 @@ export async function fetchRandomName(lang: string): Promise { export interface CreateSchoolOptions { readonly modIds?: readonly string[]; readonly map?: MapLayout; - readonly nameSetId?: string; + readonly countryId?: string; readonly nativeLanguage?: string; readonly seed?: number; } @@ -71,7 +71,7 @@ export async function createSchool( startDate: startDate.toISOString(), modIds: extras.modIds ?? [], map: extras.map ?? null, - nameSetId: extras.nameSetId ?? null, + countryId: extras.countryId ?? null, nativeLanguage: extras.nativeLanguage ?? null, seed: extras.seed ?? null, }), @@ -92,10 +92,11 @@ export interface DefInfo { readonly pupilSlots?: number; } -export interface NameSetInfo { +export interface CountryInfo { readonly defName: string; readonly label: string; readonly nativeLanguages: readonly DefInfo[]; + readonly climatePresets: readonly string[]; } export interface RoomSlotInfo { @@ -170,7 +171,7 @@ export interface CatalogResponse { readonly rooms: readonly RoomInfo[]; readonly things: readonly DefInfo[]; readonly defaultMap: MapLayout; - readonly nameSets: readonly NameSetInfo[]; + readonly countries: readonly CountryInfo[]; readonly subjects: readonly SubjectInfo[]; readonly dayFrame: DayFrameInfo | null; readonly holidays: readonly HolidayInfo[]; diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts index 84d49ff..0b7307b 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts @@ -54,11 +54,22 @@ function catalog(): CatalogResponse { ], things: [], defaultMap, - nameSets: [ + countries: [ { - defName: 'Russian', - label: 'Russian', - nativeLanguages: [{ defName: 'Russian', label: 'Russian' }], + defName: 'Russia', + label: 'Russia', + nativeLanguages: [ + { defName: 'RussianLanguage', label: 'Russian' }, + { defName: 'BelarusianLanguage', label: 'Belarusian' }, + { defName: 'UkrainianLanguage', label: 'Ukrainian' }, + ], + climatePresets: ['TemperateContinental'], + }, + { + defName: 'Nordic', + label: 'Nordic', + nativeLanguages: [{ defName: 'NorwegianLanguage', label: 'Norwegian' }], + climatePresets: ['TemperateContinental'], }, ], subjects: [], @@ -162,6 +173,55 @@ describe('createSchoolDialog', () => { finish(school()); await expect(opened).resolves.toMatchObject({ name: 'North' }); }); + + it('shows countries and refreshes languages when the country changes', async () => { + const opened = createSchoolDialog({ + defaultStartDate: new Date('2024-09-01T00:00:00.000Z'), + suggestName: async () => 'North', + create: async () => school(), + }); + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled()); + const dialog = document.querySelector('dialog'); + if (dialog === null) { + throw new Error('create dialog is missing'); + } + + expect(byText(dialog, '.field__label', t('countryTitle'))).toBeTruthy(); + expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === 'Name set')).toBe(false); + + const countrySelect = dialog.querySelector('select'); + if (!(countrySelect instanceof HTMLSelectElement)) { + throw new Error('country select is missing'); + } + + expect([...countrySelect.options].map((option) => option.value)).toEqual(['Russia', 'Nordic']); + const nativeField = byText(dialog, '.field__label', t('nativeLanguageTitle')).closest('.field'); + if (!(nativeField instanceof HTMLElement)) { + throw new Error('native language field is missing'); + } + + expect(nativeField.hidden).toBe(false); + + countrySelect.value = 'Nordic'; + countrySelect.dispatchEvent(new Event('change')); + expect(nativeField.hidden).toBe(true); + + countrySelect.value = 'Russia'; + countrySelect.dispatchEvent(new Event('change')); + const nativeSelect = nativeField.querySelector('select'); + if (!(nativeSelect instanceof HTMLSelectElement)) { + throw new Error('native language select is missing'); + } + + expect([...nativeSelect.options].map((option) => option.value)).toEqual([ + '', + 'RussianLanguage', + 'BelarusianLanguage', + 'UkrainianLanguage', + ]); + expect(nativeField.hidden).toBe(false); + void opened; + }); }); describe('map reset from the create editor', () => { diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 7dc5228..2ea5fda 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -32,7 +32,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise(); let catalog: CatalogResponse | null = null; let currentMap: MapLayout | null = null; - let nameSetId: string | null = null; + let countryId: string | null = null; let nativeLanguageId: string | null = null; const modsField = el('div', { class: 'field' }); @@ -40,11 +40,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { - nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value; + const countryLabel = el('span', { class: 'field__label' }); + const countrySelect = el('select', { class: 'input' }); + const countryField = el('label', { class: 'field' }, countryLabel, countrySelect); + countrySelect.addEventListener('change', () => { + countryId = countrySelect.value === '' ? null : countrySelect.value; nativeLanguageId = null; if (catalog !== null) { paintNativeLanguages(catalog); @@ -110,7 +110,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { catalog = next; currentMap = structuredClone(next.defaultMap); - const stillThere = next.nameSets.some((set) => set.defName === nameSetId); - nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null); - if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) { + const stillThere = next.countries.some((country) => country.defName === countryId); + countryId = stillThere ? countryId : (next.countries[0]?.defName ?? null); + if (!languagesOf(next, countryId).some((language) => language.defName === nativeLanguageId)) { nativeLanguageId = null; } - paintNameSets(next); + paintCountries(next); paintNativeLanguages(next); paintMapHint(); setBusy(busy); }; - const paintNameSets = (next: CatalogResponse): void => { - nameSetSelect.replaceChildren(); - for (const set of next.nameSets) { - const option = el('option', { text: set.label }); - option.value = set.defName; - nameSetSelect.append(option); + const paintCountries = (next: CatalogResponse): void => { + countrySelect.replaceChildren(); + for (const country of next.countries) { + const option = el('option', { text: country.label }); + option.value = country.defName; + countrySelect.append(option); } - nameSetSelect.value = nameSetId ?? ''; - nameSetSelect.disabled = next.nameSets.length <= 1; + countrySelect.value = countryId ?? ''; + countrySelect.disabled = next.countries.length <= 1; }; const paintNativeLanguages = (next: CatalogResponse): void => { - const languages = languagesOf(next, nameSetId); + const languages = languagesOf(next, countryId); nativeSelect.replaceChildren(); const random = el('option', { text: t('nativeLanguageRandom') }); random.value = ''; @@ -187,7 +187,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise => { @@ -242,7 +242,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { title.textContent = t('newSchool'); modsLabel.textContent = t('modsTitle'); - nameSetLabel.textContent = t('nameSetTitle'); + countryLabel.textContent = t('countryTitle'); nativeLabel.textContent = t('nativeLanguageTitle'); nativeRandomButton.textContent = t('randomName'); nativeRandomButton.title = t('randomNativeTitle'); @@ -260,7 +260,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise set.defName === nameSetId)?.nativeLanguages ?? []; + return catalog.countries.find((country) => country.defName === countryId)?.nativeLanguages ?? []; } function mapsEqual(left: MapLayout, right: MapLayout): boolean { @@ -401,8 +401,8 @@ function describe(reason: unknown): string { return t('errorModCycle'); case 'invalid-catalog': return t('errorInvalidCatalog'); - case 'unknown-name-set': - return t('errorUnknownNameSet'); + case 'unknown-country': + return t('errorUnknownCountry'); case 'unknown-native-language': return t('errorUnknownNativeLanguage'); default: diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index c7f0c38..d4f6467 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -307,7 +307,8 @@ public sealed class CatalogLoader var traits = new Dictionary(StringComparer.Ordinal); var bodyAttributes = new Dictionary(StringComparer.Ordinal); var needs = new Dictionary(StringComparer.Ordinal); - var nameSets = new Dictionary(StringComparer.Ordinal); + var countries = new Dictionary(StringComparer.Ordinal); + var climatePresets = new Dictionary(StringComparer.Ordinal); var subjects = new Dictionary(StringComparer.Ordinal); var staffing = new Dictionary(StringComparer.Ordinal); var dayFrames = new Dictionary(StringComparer.Ordinal); @@ -354,8 +355,11 @@ public sealed class CatalogLoader case DefKind.Need: needs[key.Name] = Jsonc.Deserialize(json); break; - case DefKind.NameSet: - nameSets[key.Name] = Jsonc.Deserialize(json); + case DefKind.Country: + countries[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.ClimatePreset: + climatePresets[key.Name] = Jsonc.Deserialize(json); break; case DefKind.Subject: subjects[key.Name] = Jsonc.Deserialize(json); @@ -389,7 +393,8 @@ public sealed class CatalogLoader traits, bodyAttributes, needs, - nameSets, + countries, + climatePresets, subjects, staffing, dayFrames, @@ -517,7 +522,8 @@ public sealed class CatalogLoader .Concat(Enumerate(catalog.Traits.Values)) .Concat(Enumerate(catalog.BodyAttributes.Values)) .Concat(Enumerate(catalog.Needs.Values)) - .Concat(Enumerate(catalog.NameSets.Values)) + .Concat(Enumerate(catalog.Countries.Values)) + .Concat(Enumerate(catalog.ClimatePresets.Values)) .Concat(Enumerate(catalog.Subjects.Values)) .Concat(Enumerate(catalog.Staffing.Values)) .Concat(Enumerate(catalog.DayFrames.Values)) diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index fd40997..bbcb411 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -20,7 +20,8 @@ public sealed class DefCatalog IReadOnlyDictionary traits, IReadOnlyDictionary bodyAttributes, IReadOnlyDictionary needs, - IReadOnlyDictionary nameSets, + IReadOnlyDictionary countries, + IReadOnlyDictionary climatePresets, IReadOnlyDictionary subjects, IReadOnlyDictionary staffing, IReadOnlyDictionary dayFrames, @@ -42,7 +43,8 @@ public sealed class DefCatalog Traits = traits; BodyAttributes = bodyAttributes; Needs = needs; - NameSets = nameSets; + Countries = countries; + ClimatePresets = climatePresets; Subjects = subjects; Staffing = staffing; DayFrames = dayFrames; @@ -89,7 +91,9 @@ public sealed class DefCatalog public IReadOnlyDictionary Needs { get; } - public IReadOnlyDictionary NameSets { get; } + public IReadOnlyDictionary Countries { get; } + + public IReadOnlyDictionary ClimatePresets { get; } public IReadOnlyDictionary Subjects { get; } @@ -129,7 +133,8 @@ public sealed class DefCatalog DefKind.Trait => Traits.GetValueOrDefault(defName), DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName), DefKind.Need => Needs.GetValueOrDefault(defName), - DefKind.NameSet => NameSets.GetValueOrDefault(defName), + DefKind.Country => Countries.GetValueOrDefault(defName), + DefKind.ClimatePreset => ClimatePresets.GetValueOrDefault(defName), DefKind.Subject => Subjects.GetValueOrDefault(defName), DefKind.Staffing => Staffing.GetValueOrDefault(defName), DefKind.DayFrame => DayFrames.GetValueOrDefault(defName), @@ -207,7 +212,8 @@ public sealed class DefCatalog TraitDef => DefKind.Trait, BodyAttributeDef => DefKind.BodyAttribute, NeedDef => DefKind.Need, - NameSetDef => DefKind.NameSet, + CountryDef => DefKind.Country, + ClimatePresetDef => DefKind.ClimatePreset, SubjectDef => DefKind.Subject, StaffingDef => DefKind.Staffing, DayFrameDef => DefKind.DayFrame, @@ -239,4 +245,17 @@ public sealed class DefCatalog set.Remove(defName); return set; } + + /// Nested names of a concrete country, or false when the id is missing or abstract. + public bool TryGetCountryNames(string countryId, out NameSetDef names) + { + if (Countries.TryGetValue(countryId, out var country) && !country.Abstract) + { + names = country.Names; + return true; + } + + names = null!; + return false; + } } diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index d2905d7..f5a600b 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -14,7 +14,8 @@ public enum DefKind Trait, BodyAttribute, Need, - NameSet, + Country, + ClimatePreset, Subject, Staffing, DayFrame, diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index ae043df..1b852d6 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -105,8 +105,11 @@ internal static class PackPaths case "needs": kind = DefKind.Need; return true; - case "namesets": - kind = DefKind.NameSet; + case "countries": + kind = DefKind.Country; + return true; + case "climates": + kind = DefKind.ClimatePreset; return true; case "subjects": kind = DefKind.Subject; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 2e0fc15..25c5d45 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -25,9 +25,9 @@ internal static class PeopleDefValidator ValidateNeed(need); } - foreach (var names in catalog.NameSets.Values) + foreach (var country in catalog.Countries.Values) { - ValidateNameSet(names, catalog); + ValidateCountry(country, catalog); } foreach (var subject in catalog.Subjects.Values) @@ -547,61 +547,90 @@ internal static class PeopleDefValidator } } - private static void ValidateNameSet(NameSetDef names, DefCatalog catalog) + private static void ValidateCountry(CountryDef country, DefCatalog catalog) + { + if (country.Abstract) + { + return; + } + + if (country.ClimatePresets.Count == 0) + { + throw new ContentLoadException($"CountryDef '{country.DefName}' needs at least one climate preset."); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var presetId in country.ClimatePresets) + { + if (string.IsNullOrWhiteSpace(presetId) || !seen.Add(presetId)) + { + throw new ContentLoadException($"CountryDef '{country.DefName}' has a missing or duplicate climate preset."); + } + + if (!catalog.ClimatePresets.TryGetValue(presetId, out var preset) || preset.Abstract) + { + throw new ContentLoadException($"CountryDef '{country.DefName}' references unknown ClimatePresetDef '{presetId}'."); + } + } + + ValidateNameSet(country.Names ?? new NameSetDef(), catalog, country.DefName); + } + + private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'."); + throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown patronymicRule '{names.PatronymicRule}'."); } foreach (var native in names.Spoken) { if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' native language '{native}' is not a SkillDef."); + throw new ContentLoadException($"CountryDef '{countryDefName}' native language '{native}' is not a SkillDef."); } } if (names.RelatedLanguageChance is < 0 or > 1) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageChance must be 0–1."); + throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageChance must be 0–1."); } if (names.RelatedLanguageStdDev < 0) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageStdDev must not be negative."); + throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageStdDev must not be negative."); } if (names.RelatedLanguageMean < 0) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMean must not be negative."); + throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageMean must not be negative."); } if (names.RelatedLanguageMax < 0) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMax must not be negative."); + throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageMax must not be negative."); } if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension."); + throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown defaultGivenDeclension."); } if (!NameGrammar.IsKnownSurname(names.DefaultSurnameDeclension)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultSurnameDeclension."); + throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown defaultSurnameDeclension."); } if (names.MaleGiven.Count == 0 || names.FemaleGiven.Count == 0 || names.Surnames.Count == 0) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' needs male names, female names and surnames."); + throw new ContentLoadException($"CountryDef '{countryDefName}' needs male names, female names and surnames."); } foreach (var given in names.MaleGiven.Concat(names.FemaleGiven)) { if (string.IsNullOrWhiteSpace(given.Form)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' has an empty given name."); + throw new ContentLoadException($"CountryDef '{countryDefName}' has an empty given name."); } if (given.Cases is null) @@ -611,7 +640,7 @@ internal static class PeopleDefValidator : given.Declension; if (!NameGrammar.IsKnownGiven(model)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' given '{given.Form}' has unknown declension '{model}'."); + throw new ContentLoadException($"CountryDef '{countryDefName}' given '{given.Form}' has unknown declension '{model}'."); } } } @@ -620,7 +649,7 @@ internal static class PeopleDefValidator { if (string.IsNullOrWhiteSpace(surname.Male) || string.IsNullOrWhiteSpace(surname.Female)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' has a surname missing a gendered form."); + throw new ContentLoadException($"CountryDef '{countryDefName}' has a surname missing a gendered form."); } if (surname.MaleCases is null && surname.FemaleCases is null) @@ -630,7 +659,7 @@ internal static class PeopleDefValidator : surname.Declension; if (!NameGrammar.IsKnownSurname(model)) { - throw new ContentLoadException($"NameSetDef '{names.DefName}' surname '{surname.Male}' has unknown declension '{model}'."); + throw new ContentLoadException($"CountryDef '{countryDefName}' surname '{surname.Male}' has unknown declension '{model}'."); } } } diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 390d787..999fa00 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -359,7 +359,10 @@ public sealed class SurnameEntry public CaseTable? FemaleCases { get; init; } } -public sealed class NameSetDef : Def +/// +/// Nested name grammar of a . Not a selectable catalog kind of its own. +/// +public sealed class NameSetDef { public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic; @@ -368,7 +371,7 @@ public sealed class NameSetDef : Def public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov; /// - /// Languages this set can speak natively. Slavic names cover Russian, Belarusian and + /// Languages this country can speak natively. Slavic names cover Russian, Belarusian and /// Ukrainian; the school picks one at create. Singular is still /// accepted in JSONC for a one-language pack. /// @@ -401,3 +404,20 @@ public sealed class NameSetDef : Def public IReadOnlyList Surnames { get; init; } = []; } + +/// +/// What the player picks at create: nested names plus climate-preset ids. Weather numbers live +/// on and stay unused until phase 32. +/// +public sealed class CountryDef : Def +{ + public IReadOnlyList ClimatePresets { get; init; } = []; + + public NameSetDef Names { get; init; } = new(); +} + +/// +/// Outdoor climate a country may roll. Monthly temperatures land in phase 32; the id is enough +/// to persist which preset a school was born with. +/// +public sealed class ClimatePresetDef : Def; diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs index eeaab4f..b7c6d7a 100644 --- a/src/HSchool.People/ApplicantPool.cs +++ b/src/HSchool.People/ApplicantPool.cs @@ -15,12 +15,12 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList @@ -31,7 +31,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList +/// A country lists climate presets; the school rolls one at birth from the school seed. +/// Reloads without a stored pick take the first listed preset so old saves stay put. +/// +public static class CountryClimate +{ + public static string? Pick(CountryDef country, int schoolSeed, bool rollIfOmitted) + { + ArgumentNullException.ThrowIfNull(country); + var presets = country.ClimatePresets; + if (presets.Count == 0) + { + return null; + } + + if (!rollIfOmitted || presets.Count == 1) + { + return presets[0]; + } + + var rng = new Random(Seed.ForSchool(schoolSeed, Seed.ClimatePresetSalt)); + return presets[rng.Next(presets.Count)]; + } +} diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index f1fb8a2..0c9da70 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -15,17 +15,17 @@ public static class RosterGenerator DefCatalog catalog, MapLayout map, int schoolSeed, - string nameSetId, + string countryId, DateTime? asOf = null, string? nativeLanguage = null) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(map); - ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId); + ArgumentException.ThrowIfNullOrWhiteSpace(countryId); - if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) + if (!catalog.TryGetCountryNames(countryId, out var names)) { - throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId)); + throw new ArgumentException($"Unknown country '{countryId}'.", nameof(countryId)); } var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: true); diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 4bd5d84..27ce514 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -15,6 +15,7 @@ public static class Seed public const int CommuteSalt = 7; public const int SkillGrantSalt = 8; public const int NativeLanguageSalt = 9; + public const int ClimatePresetSalt = 10; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.People/YearlyIntake.cs b/src/HSchool.People/YearlyIntake.cs index d87363e..9518746 100644 --- a/src/HSchool.People/YearlyIntake.cs +++ b/src/HSchool.People/YearlyIntake.cs @@ -38,17 +38,17 @@ public static class YearlyIntake DefCatalog catalog, Roster roster, int schoolSeed, - string nameSetId, + string countryId, DateTime asOf, string? nativeLanguage = null) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(roster); - ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId); + ArgumentException.ThrowIfNullOrWhiteSpace(countryId); - if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) + if (!catalog.TryGetCountryNames(countryId, out var names)) { - throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId)); + throw new ArgumentException($"Unknown country '{countryId}'.", nameof(countryId)); } var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false); diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 038ff60..7d57298 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -122,7 +122,7 @@ internal sealed record CatalogResponse( IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, - IReadOnlyList NameSets, + IReadOnlyList Countries, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, @@ -135,7 +135,7 @@ internal sealed record CatalogResponse( Placeable(catalog.Floors.Values, catalog, locale), PlaceableRooms(catalog, locale), PlaceableThings(catalog, locale), - PlaceableNameSets(catalog, locale), + PlaceableCountries(catalog, locale), PlaceableSubjects(catalog, locale), MapDayFrame(catalog, locale), PlaceableHolidays(catalog, locale), @@ -149,20 +149,21 @@ internal sealed record CatalogResponse( .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def))) .ToArray(); - private static IReadOnlyList PlaceableNameSets(DefCatalog catalog, string locale) => - catalog.NameSets.Values + private static IReadOnlyList PlaceableCountries(DefCatalog catalog, string locale) => + catalog.Countries.Values .Where(def => !def.Abstract) .OrderBy(def => def.DefName, StringComparer.Ordinal) - .Select(def => new NameSetInfoResponse( + .Select(def => new CountryInfoResponse( def.DefName, catalog.Label(locale, def), - def.Spoken + def.Names.Spoken .Select(skill => new DefInfoResponse( skill, catalog.Skills.TryGetValue(skill, out var language) ? catalog.Label(locale, language) : skill)) - .ToArray())) + .ToArray(), + def.ClimatePresets.ToArray())) .ToArray(); private static IReadOnlyList PlaceableThings(DefCatalog catalog, string locale) => @@ -230,10 +231,11 @@ internal sealed record CatalogResponse( internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0); -internal sealed record NameSetInfoResponse( +internal sealed record CountryInfoResponse( string DefName, string Label, - IReadOnlyList NativeLanguages); + IReadOnlyList NativeLanguages, + IReadOnlyList ClimatePresets); internal sealed record RoomInfoResponse( string DefName, diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 8a897a9..3e31eb9 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -53,7 +53,7 @@ internal static class SchoolEndpoints DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), request.ModIds, request.Map, - request.NameSetId, + request.CountryId, request.NativeLanguage, request.Seed, NewCompletion()); @@ -77,10 +77,10 @@ internal static class SchoolEndpoints Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."), SchoolCreationError.InvalidCatalog => Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."), - SchoolCreationError.UnknownNameSet => - Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."), + SchoolCreationError.UnknownCountry => + Problem(StatusCodes.Status400BadRequest, "unknown-country", "The selected country is not in the catalog."), SchoolCreationError.UnknownNativeLanguage => - Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."), + Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that country."), SchoolCreationError.MissingMod => Problem( StatusCodes.Status400BadRequest, @@ -508,7 +508,7 @@ internal sealed record CreateSchoolRequest( DateTime StartDate, IReadOnlyList? ModIds, MapLayout? Map, - string? NameSetId, + string? CountryId, string? NativeLanguage, int? Seed); diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 1415cc6..a0f4e64 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -16,7 +16,7 @@ internal abstract record GameCommand DateTime StartDate, IReadOnlyList? ExtraModIds, MapLayout? Map, - string? NameSetId, + string? CountryId, string? NativeLanguage, int? Seed, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index f60b77e..312d409 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -357,20 +357,14 @@ internal sealed class GameLoopService( return; } - var nameSetId = ResolveNameSetId(catalog, command.NameSetId); - if (nameSetId is null) + var countryId = ResolveCountryId(catalog, command.CountryId); + if (countryId is null || !catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) { - command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet)); + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownCountry)); return; } - if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) - { - command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet)); - return; - } - - if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(names, command.NativeLanguage)) + if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(country.Names, command.NativeLanguage)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage)); return; @@ -379,9 +373,10 @@ internal sealed class GameLoopService( var id = _nextId++; store.WriteNextId(_nextId); var seed = command.Seed ?? Random.Shared.Next(); - var nativeLanguage = NativeLanguages.Pick(names, seed, command.NativeLanguage, rollIfOmitted: true); + var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true); + var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage, seed); + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed); Track(worker); worker.Start(); @@ -548,7 +543,8 @@ internal sealed class GameLoopService( isNew: false, save.ModIds, save.Map, - save.NameSetId, + save.CountryId, + save.ClimatePresetId, save.NativeLanguage, createSeed: null, save.Presence); @@ -598,7 +594,8 @@ internal sealed class GameLoopService( bool isNew, IReadOnlyList? modIds, MapLayout? map, - string? nameSetId, + string? countryId, + string? climatePresetId, string? nativeLanguage, int? createSeed = null, IReadOnlyList? presence = null) => @@ -611,7 +608,8 @@ internal sealed class GameLoopService( isNew, modIds, map, - nameSetId, + countryId, + climatePresetId, nativeLanguage, createSeed, presence, @@ -624,12 +622,12 @@ internal sealed class GameLoopService( loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}")); /// - /// Empty request uses the first placeable set (core's Slavic). A named id must exist in the - /// catalog already loaded for this pack list — unknown extras were rejected above. + /// Empty request uses the first placeable country (core's Russia). A named id must exist in + /// the catalog already loaded for this pack list — unknown extras were rejected above. /// - internal static string? ResolveNameSetId(DefCatalog catalog, string? requested) + internal static string? ResolveCountryId(DefCatalog catalog, string? requested) { - var available = catalog.NameSets.Values + var available = catalog.Countries.Values .Where(def => !def.Abstract) .Select(def => def.DefName) .OrderBy(name => name, StringComparer.Ordinal) diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index c807740..c247ce5 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -26,6 +26,11 @@ internal sealed class SchoolSave public MapLayout? Map { get; init; } + public string? CountryId { get; init; } + + 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; } @@ -42,7 +47,7 @@ internal sealed record SchoolSaveIndex(int NextId); /// internal sealed class SchoolStore { - public const int CurrentFormat = 2; + public const int CurrentFormat = 3; private const string IndexFileName = "index.json"; @@ -178,6 +183,8 @@ internal sealed class SchoolStore SpeedIndex = save.SpeedIndex, ModIds = save.ModIds, Map = save.Map, + CountryId = save.CountryId, + ClimatePresetId = save.ClimatePresetId, NameSetId = save.NameSetId, NativeLanguage = save.NativeLanguage, Presence = save.Presence, @@ -194,11 +201,40 @@ internal sealed class SchoolStore } /// - /// Named upgrade seam for saves older than . Empty on purpose: - /// missing fields already default and extra fields are ignored. Put a migration here when a - /// format bump actually needs one. + /// 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) => save; + 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, + }; + } public void Save(SchoolSave save) { diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 465ea7a..c7ff754 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -32,7 +32,8 @@ internal sealed class SchoolWorker private readonly bool _isNew; private readonly IReadOnlyList? _modIds; private readonly MapLayout? _savedMap; - private readonly string? _nameSetId; + private readonly string? _countryId; + private string? _climatePresetId; private string? _nativeLanguage; private readonly int? _createSeed; private readonly IReadOnlyList? _savedPresence; @@ -66,7 +67,8 @@ internal sealed class SchoolWorker bool isNew, IReadOnlyList? modIds, MapLayout? savedMap, - string? nameSetId, + string? countryId, + string? climatePresetId, string? nativeLanguage, int? createSeed, IReadOnlyList? savedPresence, @@ -86,7 +88,8 @@ internal sealed class SchoolWorker _isNew = isNew; _modIds = modIds; _savedMap = savedMap; - _nameSetId = nameSetId; + _countryId = countryId; + _climatePresetId = climatePresetId; _nativeLanguage = nativeLanguage; _createSeed = createSeed; _savedPresence = savedPresence; @@ -857,12 +860,19 @@ internal sealed class SchoolWorker private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) { - var nameSetId = ResolveNameSetId(catalog, _nameSetId); - if (nameSetId is null) + var countryId = ResolveCountryId(catalog, _countryId); + if (countryId is null) { - throw new SchoolContentUnavailableException($"School {_id} has no name set in its catalog."); + throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); } + if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) + { + throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); + } + + _climatePresetId = ResolveClimatePreset(country, _climatePresetId); + var demand = SchoolDemand.From(catalog, map); Roster roster; ApplicantPool applicants; @@ -878,10 +888,10 @@ internal sealed class SchoolWorker } seed = createSeed; - native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); + native = ResolveNative(country, seed, _nativeLanguage, generating: true); _nativeLanguage = native; - roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); + roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); generated = true; } else @@ -890,16 +900,16 @@ internal sealed class SchoolWorker if (loaded is null) { seed = school.Id; - native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); + native = ResolveNative(country, seed, _nativeLanguage, generating: true); _nativeLanguage = native; - roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); + roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); generated = true; } else { seed = loaded.Seed; - native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false); + native = ResolveNative(country, seed, _nativeLanguage, generating: false); _nativeLanguage = native; roster = loaded.ToRoster(); if (loaded.Applicants is { Applicants.Count: > 0 }) @@ -908,7 +918,7 @@ internal sealed class SchoolWorker } else { - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); generated = true; } } @@ -920,47 +930,41 @@ internal sealed class SchoolWorker $"School {_id} roster does not match its map; the people file was left untouched."); } - school.InstallPeople(roster, seed, nameSetId, applicants, _nativeLanguage); + school.InstallPeople(roster, seed, countryId, applicants, _nativeLanguage, _climatePresetId); InstallTimetable(school); school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); school.RestorePresence(_savedPresence); return generated; } - private static string? ResolveNameSetId(DefCatalog catalog, string? requested) + private static string? ResolveCountryId(DefCatalog catalog, string? requested) { - var available = catalog.NameSets.Values - .Where(def => !def.Abstract) - .Select(def => def.DefName) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray(); - if (available.Length == 0) + if (string.IsNullOrWhiteSpace(requested)) { return null; } - if (string.IsNullOrWhiteSpace(requested)) + return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract + ? requested + : null; + } + + private static string? ResolveClimatePreset(CountryDef country, string? requested) + { + if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal)) { - return available[0]; + return requested; } - return available.Contains(requested, StringComparer.Ordinal) ? requested : null; + return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false); } private static string? ResolveNative( - DefCatalog catalog, - string nameSetId, + CountryDef country, int schoolSeed, string? requested, - bool generating) - { - if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) - { - return null; - } - - return NativeLanguages.Pick(names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); - } + bool generating) => + NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); private void Persist() { @@ -983,7 +987,8 @@ internal sealed class SchoolWorker SpeedIndex = school.Clock.SpeedIndex, ModIds = school.Catalog?.PackIds, Map = school.Map, - NameSetId = _nameSetId, + CountryId = school.CountryId, + ClimatePresetId = school.ClimatePresetId, NativeLanguage = _nativeLanguage, Presence = school.CapturePresence(), }); diff --git a/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc new file mode 100644 index 0000000..55ca41c --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc @@ -0,0 +1,3 @@ +{ + "defName": "ContinentalCold", +} diff --git a/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc new file mode 100644 index 0000000..f65cc2c --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc @@ -0,0 +1,3 @@ +{ + "defName": "TemperateContinental", +} diff --git a/src/HSchool.Server/mods/core/defs/countries/russia.jsonc b/src/HSchool.Server/mods/core/defs/countries/russia.jsonc new file mode 100644 index 0000000..6664360 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/countries/russia.jsonc @@ -0,0 +1,139 @@ +{ + "defName": "Russia", + "climatePresets": ["TemperateContinental", "ContinentalCold"], + "names": { + "patronymicRule": "slavic", + "defaultGivenDeclension": "hard", + "defaultSurnameDeclension": "ov", + "nativeLanguages": ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], + "relatedLanguageChance": 0.6, + "relatedLanguageMean": 22, + "relatedLanguageStdDev": 8, + "relatedLanguageMax": 40, + "maleGiven": [ + { "form": "Александр" }, + { "form": "Алексей" }, + { "form": "Андрей" }, + { "form": "Антон" }, + { "form": "Артём" }, + { "form": "Борис" }, + { "form": "Вадим" }, + { "form": "Валерий" }, + { "form": "Виктор" }, + { "form": "Виталий" }, + { "form": "Владимир" }, + { "form": "Владислав" }, + { "form": "Григорий" }, + { "form": "Денис" }, + { "form": "Дмитрий" }, + { "form": "Евгений" }, + { "form": "Егор" }, + { "form": "Иван" }, + { "form": "Игорь", "declension": "soft" }, + { "form": "Илья", "declension": "ya" }, + { "form": "Кирилл" }, + { "form": "Константин" }, + { "form": "Максим" }, + { "form": "Михаил" }, + { "form": "Никита", "declension": "a" }, + { "form": "Николай" }, + { "form": "Олег" }, + { "form": "Павел" }, + { "form": "Пётр" }, + { "form": "Роман" }, + { "form": "Сергей" }, + { "form": "Станислав" }, + { "form": "Степан" }, + { "form": "Фёдор" }, + { "form": "Юрий" }, + ], + "femaleGiven": [ + { "form": "Александра", "declension": "a" }, + { "form": "Алина", "declension": "a" }, + { "form": "Анастасия", "declension": "iya" }, + { "form": "Анна", "declension": "a" }, + { "form": "Валентина", "declension": "a" }, + { "form": "Валерия", "declension": "iya" }, + { "form": "Вера", "declension": "a" }, + { "form": "Виктория", "declension": "iya" }, + { "form": "Дарья", "declension": "ya" }, + { "form": "Екатерина", "declension": "a" }, + { "form": "Елена", "declension": "a" }, + { "form": "Елизавета", "declension": "a" }, + { "form": "Ирина", "declension": "a" }, + { "form": "Ксения", "declension": "iya" }, + { + "form": "Любовь", + "cases": { + "nom": "Любовь", + "gen": "Любови", + "dat": "Любови", + "acc": "Любовь", + "ins": "Любовью", + "pre": "Любови", + }, + }, + { "form": "Людмила", "declension": "a" }, + { "form": "Маргарита", "declension": "a" }, + { "form": "Мария", "declension": "iya" }, + { "form": "Надежда", "declension": "a" }, + { "form": "Наталья", "declension": "ya" }, + { "form": "Нина", "declension": "a" }, + { "form": "Оксана", "declension": "a" }, + { "form": "Ольга", "declension": "a" }, + { "form": "Полина", "declension": "a" }, + { "form": "Светлана", "declension": "a" }, + { "form": "София", "declension": "iya" }, + { "form": "Татьяна", "declension": "a" }, + { "form": "Юлия", "declension": "iya" }, + { "form": "Яна", "declension": "a" }, + { "form": "Вероника", "declension": "a" }, + { "form": "Диана", "declension": "a" }, + { "form": "Марина", "declension": "a" }, + ], + "surnames": [ + { "male": "Иванов", "female": "Иванова" }, + { "male": "Петров", "female": "Петрова" }, + { "male": "Смирнов", "female": "Смирнова" }, + { "male": "Кузнецов", "female": "Кузнецова" }, + { "male": "Попов", "female": "Попова" }, + { "male": "Васильев", "female": "Васильева" }, + { "male": "Соколов", "female": "Соколова" }, + { "male": "Михайлов", "female": "Михайлова" }, + { "male": "Новиков", "female": "Новикова" }, + { "male": "Фёдоров", "female": "Фёдорова" }, + { "male": "Морозов", "female": "Морозова" }, + { "male": "Волков", "female": "Волкова" }, + { "male": "Алексеев", "female": "Алексеева" }, + { "male": "Лебедев", "female": "Лебедева" }, + { "male": "Семёнов", "female": "Семёнова" }, + { "male": "Егоров", "female": "Егорова" }, + { "male": "Павлов", "female": "Павлова" }, + { "male": "Козлов", "female": "Козлова" }, + { "male": "Степанов", "female": "Степанова" }, + { "male": "Николаев", "female": "Николаева" }, + { "male": "Орлов", "female": "Орлова" }, + { "male": "Андреев", "female": "Андреева" }, + { "male": "Макаров", "female": "Макарова" }, + { "male": "Никитин", "female": "Никитина", "declension": "in" }, + { "male": "Захаров", "female": "Захарова" }, + { "male": "Зайцев", "female": "Зайцева" }, + { "male": "Соловьёв", "female": "Соловьёва" }, + { "male": "Борисов", "female": "Борисова" }, + { "male": "Яковлев", "female": "Яковлева" }, + { "male": "Григорьев", "female": "Григорьева" }, + { "male": "Романов", "female": "Романова" }, + { "male": "Воробьёв", "female": "Воробьёва" }, + { "male": "Сергеев", "female": "Сергеева" }, + { "male": "Кузьмин", "female": "Кузьмина", "declension": "in" }, + { "male": "Фролов", "female": "Фролова" }, + { "male": "Александров", "female": "Александрова" }, + { "male": "Дмитриев", "female": "Дмитриева" }, + { "male": "Королёв", "female": "Королёва" }, + { "male": "Громов", "female": "Громова" }, + { "male": "Ильин", "female": "Ильина", "declension": "in" }, + { "male": "Козловский", "female": "Козловская", "declension": "sky" }, + { "male": "Орловский", "female": "Орловская", "declension": "sky" }, + ], + }, +} diff --git a/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc b/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc deleted file mode 100644 index 6364a9b..0000000 --- a/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc +++ /dev/null @@ -1,136 +0,0 @@ -{ - "defName": "Slavic", - "patronymicRule": "slavic", - "defaultGivenDeclension": "hard", - "defaultSurnameDeclension": "ov", - "nativeLanguages": ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], - "relatedLanguageChance": 0.6, - "relatedLanguageMean": 22, - "relatedLanguageStdDev": 8, - "relatedLanguageMax": 40, - "maleGiven": [ - { "form": "Александр" }, - { "form": "Алексей" }, - { "form": "Андрей" }, - { "form": "Антон" }, - { "form": "Артём" }, - { "form": "Борис" }, - { "form": "Вадим" }, - { "form": "Валерий" }, - { "form": "Виктор" }, - { "form": "Виталий" }, - { "form": "Владимир" }, - { "form": "Владислав" }, - { "form": "Григорий" }, - { "form": "Денис" }, - { "form": "Дмитрий" }, - { "form": "Евгений" }, - { "form": "Егор" }, - { "form": "Иван" }, - { "form": "Игорь", "declension": "soft" }, - { "form": "Илья", "declension": "ya" }, - { "form": "Кирилл" }, - { "form": "Константин" }, - { "form": "Максим" }, - { "form": "Михаил" }, - { "form": "Никита", "declension": "a" }, - { "form": "Николай" }, - { "form": "Олег" }, - { "form": "Павел" }, - { "form": "Пётр" }, - { "form": "Роман" }, - { "form": "Сергей" }, - { "form": "Станислав" }, - { "form": "Степан" }, - { "form": "Фёдор" }, - { "form": "Юрий" }, - ], - "femaleGiven": [ - { "form": "Александра", "declension": "a" }, - { "form": "Алина", "declension": "a" }, - { "form": "Анастасия", "declension": "iya" }, - { "form": "Анна", "declension": "a" }, - { "form": "Валентина", "declension": "a" }, - { "form": "Валерия", "declension": "iya" }, - { "form": "Вера", "declension": "a" }, - { "form": "Виктория", "declension": "iya" }, - { "form": "Дарья", "declension": "ya" }, - { "form": "Екатерина", "declension": "a" }, - { "form": "Елена", "declension": "a" }, - { "form": "Елизавета", "declension": "a" }, - { "form": "Ирина", "declension": "a" }, - { "form": "Ксения", "declension": "iya" }, - { - "form": "Любовь", - "cases": { - "nom": "Любовь", - "gen": "Любови", - "dat": "Любови", - "acc": "Любовь", - "ins": "Любовью", - "pre": "Любови", - }, - }, - { "form": "Людмила", "declension": "a" }, - { "form": "Маргарита", "declension": "a" }, - { "form": "Мария", "declension": "iya" }, - { "form": "Надежда", "declension": "a" }, - { "form": "Наталья", "declension": "ya" }, - { "form": "Нина", "declension": "a" }, - { "form": "Оксана", "declension": "a" }, - { "form": "Ольга", "declension": "a" }, - { "form": "Полина", "declension": "a" }, - { "form": "Светлана", "declension": "a" }, - { "form": "София", "declension": "iya" }, - { "form": "Татьяна", "declension": "a" }, - { "form": "Юлия", "declension": "iya" }, - { "form": "Яна", "declension": "a" }, - { "form": "Вероника", "declension": "a" }, - { "form": "Диана", "declension": "a" }, - { "form": "Марина", "declension": "a" }, - ], - "surnames": [ - { "male": "Иванов", "female": "Иванова" }, - { "male": "Петров", "female": "Петрова" }, - { "male": "Смирнов", "female": "Смирнова" }, - { "male": "Кузнецов", "female": "Кузнецова" }, - { "male": "Попов", "female": "Попова" }, - { "male": "Васильев", "female": "Васильева" }, - { "male": "Соколов", "female": "Соколова" }, - { "male": "Михайлов", "female": "Михайлова" }, - { "male": "Новиков", "female": "Новикова" }, - { "male": "Фёдоров", "female": "Фёдорова" }, - { "male": "Морозов", "female": "Морозова" }, - { "male": "Волков", "female": "Волкова" }, - { "male": "Алексеев", "female": "Алексеева" }, - { "male": "Лебедев", "female": "Лебедева" }, - { "male": "Семёнов", "female": "Семёнова" }, - { "male": "Егоров", "female": "Егорова" }, - { "male": "Павлов", "female": "Павлова" }, - { "male": "Козлов", "female": "Козлова" }, - { "male": "Степанов", "female": "Степанова" }, - { "male": "Николаев", "female": "Николаева" }, - { "male": "Орлов", "female": "Орлова" }, - { "male": "Андреев", "female": "Андреева" }, - { "male": "Макаров", "female": "Макарова" }, - { "male": "Никитин", "female": "Никитина", "declension": "in" }, - { "male": "Захаров", "female": "Захарова" }, - { "male": "Зайцев", "female": "Зайцева" }, - { "male": "Соловьёв", "female": "Соловьёва" }, - { "male": "Борисов", "female": "Борисова" }, - { "male": "Яковлев", "female": "Яковлева" }, - { "male": "Григорьев", "female": "Григорьева" }, - { "male": "Романов", "female": "Романова" }, - { "male": "Воробьёв", "female": "Воробьёва" }, - { "male": "Сергеев", "female": "Сергеева" }, - { "male": "Кузьмин", "female": "Кузьмина", "declension": "in" }, - { "male": "Фролов", "female": "Фролова" }, - { "male": "Александров", "female": "Александрова" }, - { "male": "Дмитриев", "female": "Дмитриева" }, - { "male": "Королёв", "female": "Королёва" }, - { "male": "Громов", "female": "Громова" }, - { "male": "Ильин", "female": "Ильина", "declension": "in" }, - { "male": "Козловский", "female": "Козловская", "declension": "sky" }, - { "male": "Орловский", "female": "Орловская", "declension": "sky" }, - ], -} diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 55b500f..6e32ee1 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -104,7 +104,9 @@ "Hunger": "Hunger", "Toilet": "Toilet", "Social": "Social", - "Slavic": "Slavic", + "Russia": "Russia", + "TemperateContinental": "Temperate continental", + "ContinentalCold": "Cold continental", "Build": "Build", "Skinny": "Skinny", "Average": "Average", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 066a13d..1e97878 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -104,7 +104,9 @@ "Hunger": "Голод", "Toilet": "Туалет", "Social": "Общение", - "Slavic": "Славянский", + "Russia": "Россия", + "TemperateContinental": "Умеренно-континентальный", + "ContinentalCold": "Континентальный холодный", "Build": "Телосложение", "Skinny": "Худощавое", "Average": "Обычное", diff --git a/src/HSchool.Server/mods/example/README.md b/src/HSchool.Server/mods/example/README.md index 8c3e46e..0e14a95 100644 --- a/src/HSchool.Server/mods/example/README.md +++ b/src/HSchool.Server/mods/example/README.md @@ -10,7 +10,7 @@ example/ pack.jsonc # version + requires: ["core"] localizations/{ru,en}.jsonc defs/traits/traits.jsonc # two traits - defs/namesets/example.jsonc + defs/countries/example.jsonc defs/rooms/store.jsonc # one placeable room defs/things/chair.jsonc # last-wins: same defName as core patches/principals-office.jsonc diff --git a/src/HSchool.Server/mods/example/defs/countries/example.jsonc b/src/HSchool.Server/mods/example/defs/countries/example.jsonc new file mode 100644 index 0000000..cdf028d --- /dev/null +++ b/src/HSchool.Server/mods/example/defs/countries/example.jsonc @@ -0,0 +1,20 @@ +{ + "defName": "ExampleNames", + "climatePresets": ["TemperateContinental"], + "names": { + "patronymicRule": "slavic", + "defaultGivenDeclension": "hard", + "defaultSurnameDeclension": "ov", + "nativeLanguages": ["RussianLanguage"], + "relatedLanguageChance": 0, + "maleGiven": [ + { "form": "Иван" }, + ], + "femaleGiven": [ + { "form": "Анна", "declension": "a" }, + ], + "surnames": [ + { "male": "Тестов", "female": "Тестова" }, + ], + }, +} diff --git a/src/HSchool.Server/mods/example/defs/namesets/example.jsonc b/src/HSchool.Server/mods/example/defs/namesets/example.jsonc deleted file mode 100644 index 8426802..0000000 --- a/src/HSchool.Server/mods/example/defs/namesets/example.jsonc +++ /dev/null @@ -1,17 +0,0 @@ -{ - "defName": "ExampleNames", - "patronymicRule": "slavic", - "defaultGivenDeclension": "hard", - "defaultSurnameDeclension": "ov", - "nativeLanguages": ["RussianLanguage"], - "relatedLanguageChance": 0, - "maleGiven": [ - { "form": "Иван" }, - ], - "femaleGiven": [ - { "form": "Анна", "declension": "a" }, - ], - "surnames": [ - { "male": "Тестов", "female": "Тестова" }, - ], -} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index dff5e09..df08133 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -75,8 +75,11 @@ public sealed class School : IDisposable public int PeopleSeed { get; private set; } - /// Name pack used to generate this school's people. Needed again on 1 September. - public string? NameSetId { get; private set; } + /// Country used to generate this school's people. Needed again on 1 September. + public string? CountryId { get; private set; } + + /// Climate preset rolled at birth. Phase 32 reads it; it cannot change on a live school. + public string? ClimatePresetId { get; private set; } /// Skill everyone generated for this school speaks natively. public string? NativeLanguage { get; private set; } @@ -118,14 +121,15 @@ public sealed class School : IDisposable /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// - public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null, string? nativeLanguage = null) + public void InstallPeople(Roster roster, int seed, string? countryId = null, ApplicantPool? applicants = null, string? nativeLanguage = null, string? climatePresetId = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(roster); Roster = roster; PeopleSeed = seed; - NameSetId = nameSetId; + CountryId = countryId; + ClimatePresetId = climatePresetId; NativeLanguage = nativeLanguage; Applicants = applicants; RosterSpawner.Spawn(World, roster); @@ -278,7 +282,7 @@ public sealed class School : IDisposable private bool TryYearlyIntake(DateTime before, DateTime after) { - if (Roster is null || Catalog is null || NameSetId is null) + if (Roster is null || Catalog is null || CountryId is null) { return false; } @@ -286,7 +290,7 @@ public sealed class School : IDisposable var changed = false; foreach (var date in YearlyIntake.DatesBetween(before, after)) { - Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date, NativeLanguage); + Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, CountryId, date, NativeLanguage); changed = true; } @@ -303,12 +307,12 @@ public sealed class School : IDisposable private bool TryApplicantRefresh() { - if (Applicants is null || Roster is null || Catalog is null || NameSetId is null || Catalog.StaffingRules is null) + if (Applicants is null || Roster is null || Catalog is null || CountryId is null || Catalog.StaffingRules is null) { return false; } - var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time, NativeLanguage); + var next = Applicants.Advance(Catalog, Roster, PeopleSeed, CountryId, Clock.Time, NativeLanguage); if (next.Week == Applicants.Week) { return false; diff --git a/src/HSchool.Simulation/SchoolCreationError.cs b/src/HSchool.Simulation/SchoolCreationError.cs index 8c9187e..e2dea68 100644 --- a/src/HSchool.Simulation/SchoolCreationError.cs +++ b/src/HSchool.Simulation/SchoolCreationError.cs @@ -13,7 +13,7 @@ public enum SchoolCreationError InvalidMap, UnknownMod, InvalidCatalog, - UnknownNameSet, + UnknownCountry, UnknownNativeLanguage, MissingMod, ModCycle, diff --git a/tests/HSchool.Ai.Tests/WalkingTests.cs b/tests/HSchool.Ai.Tests/WalkingTests.cs index 198420e..0cff74a 100644 --- a/tests/HSchool.Ai.Tests/WalkingTests.cs +++ b/tests/HSchool.Ai.Tests/WalkingTests.cs @@ -68,7 +68,7 @@ public class WalkingTests public void ComesToday_IsFalseOnSundayAndHolidays() { var (catalog, map) = Fixtures.Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayLesson); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayLesson); var pupil = roster.People.First(person => person.IsStudent); var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId); var table = new Timetable( @@ -90,7 +90,7 @@ public class WalkingTests public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes() { var (catalog, map) = Fixtures.Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayLesson); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayLesson); var idle = roster.Classes[0]; var busy = roster.Classes[1]; var idlePupil = roster.People.First(person => person.Id == idle.PupilIds[0]); @@ -117,7 +117,7 @@ public class WalkingTests { var (catalog, map) = Fixtures.Vanilla(); var walks = WalkGraph.Build(catalog, map); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", TuesdayLesson); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Russia", TuesdayLesson); var pupil = roster.People.First(person => person.IsStudent); var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId); var table = new Timetable( diff --git a/tests/HSchool.AppHost.Tests/ExamplePackTests.cs b/tests/HSchool.AppHost.Tests/ExamplePackTests.cs index 740e187..9e093a7 100644 --- a/tests/HSchool.AppHost.Tests/ExamplePackTests.cs +++ b/tests/HSchool.AppHost.Tests/ExamplePackTests.cs @@ -28,8 +28,8 @@ public class ExamplePackTests(AppHostFixture fixture) Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label); Assert.Equal("Кладовая", Assert.Single(ru.Rooms, room => room.DefName == "ExampleStore").Label); Assert.Equal("Storeroom", Assert.Single(en.Rooms, room => room.DefName == "ExampleStore").Label); - Assert.Equal("Примерные имена", Assert.Single(ru.NameSets, set => set.DefName == "ExampleNames").Label); - Assert.Equal("Example names", Assert.Single(en.NameSets, set => set.DefName == "ExampleNames").Label); + Assert.Equal("Примерные имена", Assert.Single(ru.Countries, set => set.DefName == "ExampleNames").Label); + Assert.Equal("Example names", Assert.Single(en.Countries, set => set.DefName == "ExampleNames").Label); Assert.Equal("yard", ru.DefaultMap.Territory?.Id); } @@ -139,14 +139,14 @@ public class ExamplePackTests(AppHostFixture fixture) IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, - IReadOnlyList NameSets, + IReadOnlyList Countries, DayFrameResponse? DayFrame, IReadOnlyList Holidays, MapLayoutResponse DefaultMap); private sealed record DefInfoResponse(string DefName, string Label); - private sealed record NameSetInfoResponse(string DefName, string Label); + private sealed record CountryInfoResponse(string DefName, string Label); private sealed record RoomInfoResponse( string DefName, diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index cef7a56..e7c3290 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -278,13 +278,14 @@ public class SchoolApiTests(AppHostFixture fixture) 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); - Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label); - Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label); - var slavic = Assert.Single(ru.NameSets, set => set.DefName == "Slavic"); + Assert.Equal("Россия", Assert.Single(ru.Countries, country => country.DefName == "Russia").Label); + Assert.Equal("Russia", Assert.Single(en.Countries, country => country.DefName == "Russia").Label); + var russia = Assert.Single(ru.Countries, country => country.DefName == "Russia"); Assert.Equal( ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], - slavic.NativeLanguages.Select(language => language.DefName).ToArray()); - Assert.Equal("Белорусский", Assert.Single(slavic.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label); + russia.NativeLanguages.Select(language => language.DefName).ToArray()); + Assert.Equal("Белорусский", Assert.Single(russia.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label); + Assert.Equal(["TemperateContinental", "ContinentalCold"], russia.ClimatePresets); Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label); Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label); var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom"); @@ -393,7 +394,7 @@ public class SchoolApiTests(AppHostFixture fixture) } [Fact] - public async Task CreateSchool_WithAnUnknownNameSet_IsRejected() + public async Task CreateSchool_WithAnUnknownCountry_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); @@ -402,14 +403,47 @@ public class SchoolApiTests(AppHostFixture fixture) "/api/schools", new { - name = "Чужие имена", + name = "Чужая страна", startDate = ExpectedDefaultStart, - nameSetId = "NoSuchNames", + countryId = "NoSuchCountry", }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("unknown-name-set", await ProblemCodeAsync(response)); + Assert.Equal("unknown-country", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task CreateSchool_WithRussiaAndNoLanguage_PicksTheSameNativeAsTheSeed() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + const int seed = 42; + + var first = await CreateWithCountryAsync(client, "Страна А", ExpectedDefaultStart, "Russia", seed); + var second = await CreateWithCountryAsync(client, "Страна Б", ExpectedDefaultStart, "Russia", seed); + + var firstSave = await ReadSchoolSaveAsync(client, first.Id); + var secondSave = await ReadSchoolSaveAsync(client, second.Id); + Assert.Equal("Russia", firstSave.CountryId); + Assert.Equal(firstSave.NativeLanguage, secondSave.NativeLanguage); + Assert.Contains(firstSave.NativeLanguage, new[] { "RussianLanguage", "BelarusianLanguage", "UkrainianLanguage" }); + } + + [Fact] + public async Task CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + const int seed = 20260818; + + var first = await CreateWithCountryAsync(client, "Климат А", ExpectedDefaultStart, "Russia", seed); + var second = await CreateWithCountryAsync(client, "Климат Б", ExpectedDefaultStart, "Russia", seed); + + var firstSave = await ReadSchoolSaveAsync(client, first.Id); + var secondSave = await ReadSchoolSaveAsync(client, second.Id); + Assert.Equal(firstSave.ClimatePresetId, secondSave.ClimatePresetId); + Assert.Contains(firstSave.ClimatePresetId, new[] { "TemperateContinental", "ContinentalCold" }); } [Fact] @@ -424,7 +458,7 @@ public class SchoolApiTests(AppHostFixture fixture) { name = "Чужой язык", startDate = ExpectedDefaultStart, - nameSetId = "Slavic", + countryId = "Russia", nativeLanguage = "Klingon", }, TestContext.Current.CancellationToken); @@ -518,6 +552,46 @@ public class SchoolApiTests(AppHostFixture fixture) return created; } + private static async Task CreateWithCountryAsync( + HttpClient client, + string name, + DateTime startDate, + string countryId, + int seed) + { + using var response = await client.PostAsJsonAsync( + "/api/schools", + new { name, startDate, countryId, seed }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(created); + return created; + } + + private static async Task ReadSchoolSaveAsync(HttpClient client, int id) + { + var directory = await SavesDirectoryAsync(client); + var json = await File.ReadAllTextAsync(Path.Combine(directory, $"{id}.json"), TestContext.Current.CancellationToken); + var save = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(save); + return save; + } + + private static async Task SavesDirectoryAsync(HttpClient client) + { + var payload = await client.GetFromJsonAsync( + "/api/dev/saves-directory", + TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.False(string.IsNullOrWhiteSpace(payload.Path)); + return payload.Path; + } + + private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage); + internal static readonly object SimpleCustomMap = new { territory = new { id = "yard", def = "SchoolYard" }, @@ -623,7 +697,7 @@ public class SchoolApiTests(AppHostFixture fixture) IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, - IReadOnlyList NameSets, + IReadOnlyList Countries, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, @@ -631,10 +705,11 @@ public class SchoolApiTests(AppHostFixture fixture) private sealed record DefInfoResponse(string DefName, string Label); - private sealed record NameSetInfoResponse( + private sealed record CountryInfoResponse( string DefName, string Label, - IReadOnlyList NativeLanguages); + IReadOnlyList NativeLanguages, + IReadOnlyList ClimatePresets); private sealed record RoomInfoResponse( string DefName, diff --git a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs index abc7633..4905727 100644 --- a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs +++ b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs @@ -37,6 +37,34 @@ public class ServiceabilityTests(AppHostFixture fixture) } } + [Fact] + public async Task CurrentFormatSave_WithoutCountryId_LeavesTheSchoolUnstartedAndTheFileUntouched() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var directory = await SavesDirectoryAsync(client); + + try + { + InstallGolden(directory, "current"); + var path = Path.Combine(directory, "1.json"); + BumpFormat(path, 3); + var before = File.ReadAllBytes(path); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools); + 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(); + } + } + [Fact] public async Task CurrentFormatSave_LoadsAsBefore() { diff --git a/tests/HSchool.Content.Tests/CountryDefTests.cs b/tests/HSchool.Content.Tests/CountryDefTests.cs new file mode 100644 index 0000000..67ba040 --- /dev/null +++ b/tests/HSchool.Content.Tests/CountryDefTests.cs @@ -0,0 +1,81 @@ +namespace HSchool.Content.Tests; + +public class CountryDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaCore_LoadsRussiaAndClimatePresets() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + + Assert.True(catalog.Countries.ContainsKey("Russia")); + Assert.False(catalog.Countries.ContainsKey("Slavic")); + var russia = catalog.Countries["Russia"]; + Assert.Equal( + ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], + russia.Names.Spoken); + Assert.Equal(0.6f, russia.Names.RelatedLanguageChance); + Assert.Equal(40, russia.Names.RelatedLanguageMax); + Assert.True(russia.Names.MaleGiven.Count >= 20); + Assert.Equal(["TemperateContinental", "ContinentalCold"], russia.ClimatePresets); + Assert.True(catalog.ClimatePresets.ContainsKey("TemperateContinental")); + Assert.True(catalog.ClimatePresets.ContainsKey("ContinentalCold")); + Assert.Equal("Россия", catalog.Label("ru", russia)); + Assert.Equal("Russia", catalog.Label("en", russia)); + } + + [Fact] + public void CountryWithoutNames_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "climates", + "temperate", + """{ "defName": "TemperateContinental" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "countries", + "ghost", + """{ "defName": "GhostLand", "climatePresets": ["TemperateContinental"] }"""), + ])); + + Assert.Contains("GhostLand", ex.Message, StringComparison.Ordinal); + Assert.Contains("names", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CountryWithUnknownPreset_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "climates", + "temperate", + """{ "defName": "TemperateContinental" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "countries", + "ghost", + """ + { + "defName": "GhostLand", + "climatePresets": ["NoSuchClimate"], + "names": { + "maleGiven": [{ "form": "Иван" }], + "femaleGiven": [{ "form": "Анна", "declension": "a" }], + "surnames": [{ "male": "Иванов", "female": "Иванова" }] + } + } + """), + ])); + + Assert.Contains("NoSuchClimate", ex.Message, StringComparison.Ordinal); + } +} diff --git a/tests/HSchool.Content.Tests/ExamplePackTests.cs b/tests/HSchool.Content.Tests/ExamplePackTests.cs index 2c1bbb8..f399842 100644 --- a/tests/HSchool.Content.Tests/ExamplePackTests.cs +++ b/tests/HSchool.Content.Tests/ExamplePackTests.cs @@ -40,12 +40,12 @@ public class ExamplePackTests Assert.True(catalog.Traits.ContainsKey("ExampleEarlyRiser")); Assert.True(catalog.Traits.ContainsKey("ExampleNightOwl")); - Assert.True(catalog.NameSets.ContainsKey("ExampleNames")); + Assert.True(catalog.Countries.ContainsKey("ExampleNames")); Assert.True(catalog.Rooms.ContainsKey("ExampleStore")); Assert.Equal("Кладовая", catalog.Label("ru", catalog.Rooms["ExampleStore"])); Assert.Equal("Storeroom", catalog.Label("en", catalog.Rooms["ExampleStore"])); - Assert.Equal("Примерные имена", catalog.Label("ru", catalog.NameSets["ExampleNames"])); - Assert.Equal(["RussianLanguage"], catalog.NameSets["ExampleNames"].Spoken.ToArray()); + Assert.Equal("Примерные имена", catalog.Label("ru", catalog.Countries["ExampleNames"])); + Assert.Equal(["RussianLanguage"], catalog.Countries["ExampleNames"].Names.Spoken.ToArray()); Assert.True(catalog.Rooms.ContainsKey("PrincipalsOffice")); Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"])); } diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 606c0a4..4a57c2e 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -25,15 +25,15 @@ public class PeopleDefTests Assert.Equal(0.4f, catalog.Skills["English"].AdultChance); Assert.Equal( ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], - catalog.NameSets["Slavic"].Spoken); - Assert.Equal(0.6f, catalog.NameSets["Slavic"].RelatedLanguageChance); - Assert.Equal(40, catalog.NameSets["Slavic"].RelatedLanguageMax); + catalog.Countries["Russia"].Names.Spoken); + Assert.Equal(0.6f, catalog.Countries["Russia"].Names.RelatedLanguageChance); + Assert.Equal(40, catalog.Countries["Russia"].Names.RelatedLanguageMax); Assert.Contains(catalog.Subjects["ForeignLanguage"].Skills, share => share.Skill == "English"); Assert.False(catalog.Skills.ContainsKey("ForeignLanguage")); - Assert.True(catalog.NameSets.ContainsKey("Slavic")); - Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20); - Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"])); - Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"])); + Assert.True(catalog.Countries.ContainsKey("Russia")); + Assert.True(catalog.Countries["Russia"].Names.MaleGiven.Count >= 20); + Assert.Equal("Россия", catalog.Label("ru", catalog.Countries["Russia"])); + Assert.Equal("Russia", catalog.Label("en", catalog.Countries["Russia"])); Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"])); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.NotNull(catalog.StaffingRules); @@ -213,7 +213,7 @@ public class PeopleDefTests } [Fact] - public void ExtraPack_AddsANameSetAndTrait() + public void ExtraPack_AddsACountryAndTrait() { var catalog = _loader.Load( [CatalogLoader.CorePackId, "addon"], @@ -221,37 +221,48 @@ public class PeopleDefTests PackDocuments.Def(CatalogLoader.CorePackId, "traits", "kind", """{ "defName": "Kind", "weight": 1 }"""), PackDocuments.Def( CatalogLoader.CorePackId, - "namesets", - "slavic", + "climates", + "temperate", + """{ "defName": "TemperateContinental" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "countries", + "russia", """ { - "defName": "Slavic", - "maleGiven": [{ "form": "Иван" }], - "femaleGiven": [{ "form": "Анна", "declension": "a" }], - "surnames": [{ "male": "Иванов", "female": "Иванова" }] + "defName": "Russia", + "climatePresets": ["TemperateContinental"], + "names": { + "maleGiven": [{ "form": "Иван" }], + "femaleGiven": [{ "form": "Анна", "declension": "a" }], + "surnames": [{ "male": "Иванов", "female": "Иванова" }] + } } """), PackDocuments.Def("addon", "traits", "stoic", """{ "defName": "Stoic", "weight": 3 }"""), PackDocuments.Def("addon", "traits", "cheerful", """{ "defName": "Cheerful", "weight": 2 }"""), PackDocuments.Def( "addon", - "namesets", + "countries", "nordic", """ { "defName": "Nordic", - "maleGiven": [{ "form": "Lars", "declension": "indeclinable" }], - "femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }], - "surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }] + "climatePresets": ["TemperateContinental"], + "names": { + "maleGiven": [{ "form": "Lars", "declension": "indeclinable" }], + "femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }], + "surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }] + } } """), PackDocuments.Locale("addon", "ru", """{ "Nordic": "Северный", "Stoic": "Стоик", "Cheerful": "Жизнерадостный" }"""), ]); - Assert.True(catalog.NameSets.ContainsKey("Nordic")); + Assert.True(catalog.Countries.ContainsKey("Nordic")); Assert.True(catalog.Traits.ContainsKey("Stoic")); Assert.True(catalog.Traits.ContainsKey("Cheerful")); - Assert.Equal("Северный", catalog.Label("ru", catalog.NameSets["Nordic"])); + Assert.Equal("Северный", catalog.Label("ru", catalog.Countries["Nordic"])); Assert.Equal("Стоик", catalog.Label("ru", catalog.Traits["Stoic"])); } } diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 488c1c6..214d0ed 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -113,7 +113,8 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.Traits.Values)); keys.AddRange(Names(catalog.BodyAttributes.Values)); keys.AddRange(Names(catalog.Needs.Values)); - keys.AddRange(Names(catalog.NameSets.Values)); + keys.AddRange(Names(catalog.Countries.Values)); + keys.AddRange(Names(catalog.ClimatePresets.Values)); keys.AddRange(Names(catalog.Subjects.Values)); keys.AddRange(Names(catalog.Staffing.Values)); keys.AddRange(Names(catalog.DayFrames.Values)); diff --git a/tests/HSchool.People.Tests/ApplicantPoolTests.cs b/tests/HSchool.People.Tests/ApplicantPoolTests.cs index bfa2945..acd69ed 100644 --- a/tests/HSchool.People.Tests/ApplicantPoolTests.cs +++ b/tests/HSchool.People.Tests/ApplicantPoolTests.cs @@ -17,7 +17,7 @@ public class ApplicantPoolTests { var roster = Fixtures.Generate(Fixtures.VanillaMap()); var first = Create(roster); - var next = first.Advance(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf.AddDays(7)); + var next = first.Advance(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf.AddDays(7)); Assert.NotEqual(first.Week, next.Week); var stayed = first.Applicants @@ -45,12 +45,12 @@ public class ApplicantPoolTests var roster = Fixtures.Generate(Fixtures.VanillaMap()); var size = catalog.StaffingRules!.PoolSize; var asOf = Fixtures.AsOf; - var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Slavic", asOf); + var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", asOf); for (var week = 0; week < 50; week++) { asOf = asOf.AddDays(7); - pool = pool.Advance(catalog, roster, Fixtures.SchoolSeed, "Slavic", asOf); + pool = pool.Advance(catalog, roster, Fixtures.SchoolSeed, "Russia", asOf); Assert.Equal(size, pool.Applicants.Count); } } @@ -94,7 +94,7 @@ public class ApplicantPoolTests for (var seed = 1; seed <= 20 && pool is null; seed++) { var candidate = Fixtures.Generate(Fixtures.VanillaMap(), seed); - var created = ApplicantPool.Create(Fixtures.Catalog(), candidate, seed, "Slavic", Fixtures.AsOf); + var created = ApplicantPool.Create(Fixtures.Catalog(), candidate, seed, "Russia", Fixtures.AsOf); if (created.Applicants.Any(applicant => candidate.People.Any(person => person.Id == applicant.Person.Id))) { roster = candidate; @@ -132,7 +132,7 @@ public class ApplicantPoolTests } private static ApplicantPool Create(Roster roster) => - ApplicantPool.Create(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf); + ApplicantPool.Create(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf); private static string Snapshot(ApplicantPool pool) => string.Join('\n', pool.Applicants.Select(applicant => diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs index e29ec8a..b89c78e 100644 --- a/tests/HSchool.People.Tests/Fixtures.cs +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -96,7 +96,7 @@ internal static class Fixtures } public static Roster Generate(MapLayout map, int seed = SchoolSeed) => - RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf, "RussianLanguage"); + RosterGenerator.Generate(Catalog(), map, seed, "Russia", AsOf, "RussianLanguage"); public static string RepoRoot() { diff --git a/tests/HSchool.People.Tests/GoldenRosterTests.cs b/tests/HSchool.People.Tests/GoldenRosterTests.cs index d37fc01..12cb2e2 100644 --- a/tests/HSchool.People.Tests/GoldenRosterTests.cs +++ b/tests/HSchool.People.Tests/GoldenRosterTests.cs @@ -8,7 +8,7 @@ public class GoldenRosterTests public void GoldenRoster_MatchesFixture() { var roster = Fixtures.Generate(Fixtures.Classrooms(4)); - var actual = RosterFingerprint.Format(roster, Fixtures.SchoolSeed, "Slavic", Native); + var actual = RosterFingerprint.Format(roster, Fixtures.SchoolSeed, "Russia", Native); var relative = Path.Combine("golden", "roster.txt"); var output = Path.Combine(AppContext.BaseDirectory, relative); var source = Path.Combine(Fixtures.RepoRoot(), "tests", "HSchool.People.Tests", relative); diff --git a/tests/HSchool.People.Tests/GoldenSaveDump.cs b/tests/HSchool.People.Tests/GoldenSaveDump.cs index ac669fe..e25f0e6 100644 --- a/tests/HSchool.People.Tests/GoldenSaveDump.cs +++ b/tests/HSchool.People.Tests/GoldenSaveDump.cs @@ -21,8 +21,8 @@ public class GoldenSaveDump var catalog = Fixtures.Catalog(); var map = TwoClassrooms(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Fixtures.AsOf, "RussianLanguage"); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Fixtures.AsOf, "RussianLanguage"); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Fixtures.AsOf, "RussianLanguage"); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", Fixtures.AsOf, "RussianLanguage"); var people = RosterJson.Serialize(RosterDocument.From(1, roster, pool)); var mapNode = JsonNode.Parse(JsonSerializer.Serialize(map, Jsonc.SerializerOptions)); var current = SchoolJson(mapNode, includeNative: true); diff --git a/tests/HSchool.People.Tests/ReviewFixTests.cs b/tests/HSchool.People.Tests/ReviewFixTests.cs index 211e84c..6be3ef1 100644 --- a/tests/HSchool.People.Tests/ReviewFixTests.cs +++ b/tests/HSchool.People.Tests/ReviewFixTests.cs @@ -69,7 +69,7 @@ public class ReviewFixTests for (var year = 0; year < 4; year++) { var before = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); - roster = YearlyIntake.Apply(catalog, roster, Fixtures.SchoolSeed, "Slavic", date); + roster = YearlyIntake.Apply(catalog, roster, Fixtures.SchoolSeed, "Russia", date); date = date.AddYears(1); foreach (var person in roster.People) @@ -98,7 +98,7 @@ public class ReviewFixTests catalog, roster, Fixtures.SchoolSeed, - "Slavic", + "Russia", new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); // Every seat an intake fills is a first-year seat, so a brand-new family that took two or @@ -153,7 +153,7 @@ public class ReviewFixTests public void AChildOfASingleMother_StillCarriesTheFathersSurnameAndPatronymic() { var catalog = Fixtures.Catalog(); - var names = catalog.NameSets["Slavic"]; + var names = catalog.Countries["Russia"].Names; var roster = Fixtures.Generate(Fixtures.Classrooms(11)); var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); @@ -180,7 +180,7 @@ public class ReviewFixTests public void ASingleMotherFamily_CanStillTakeInAYoungerSibling() { var catalog = Fixtures.Catalog(); - var names = catalog.NameSets["Slavic"]; + var names = catalog.Countries["Russia"].Names; var roster = Fixtures.Generate(Fixtures.Classrooms(11)); var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var motherOnly = roster.Families diff --git a/tests/HSchool.People.Tests/RosterFingerprint.cs b/tests/HSchool.People.Tests/RosterFingerprint.cs index 54dcd27..7432f1c 100644 --- a/tests/HSchool.People.Tests/RosterFingerprint.cs +++ b/tests/HSchool.People.Tests/RosterFingerprint.cs @@ -6,11 +6,11 @@ namespace HSchool.People.Tests; /// internal static class RosterFingerprint { - public static string Format(Roster roster, int seed, string nameSet, string native) + public static string Format(Roster roster, int seed, string country, string native) { var lines = new List { - $"# seed={seed} nameset={nameSet} native={native} people={roster.People.Count} classes={roster.Classes.Count}", + $"# seed={seed} country={country} native={native} people={roster.People.Count} classes={roster.Classes.Count}", }; foreach (var schoolClass in roster.Classes.OrderBy(row => row.Id, StringComparer.Ordinal)) diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index 43d901a..49eaf6e 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -3,7 +3,7 @@ namespace HSchool.People.Tests; public class RosterGeneratorTests { [Fact] - public void SameSeedMapAndNameSet_YieldTheSameRoster() + public void SameSeedMapAndCountry_YieldTheSameRoster() { var map = Fixtures.Classrooms(4); var a = Fixtures.Generate(map); diff --git a/tests/HSchool.People.Tests/SkillGrantTests.cs b/tests/HSchool.People.Tests/SkillGrantTests.cs index 13cf1c5..6052dbe 100644 --- a/tests/HSchool.People.Tests/SkillGrantTests.cs +++ b/tests/HSchool.People.Tests/SkillGrantTests.cs @@ -70,11 +70,11 @@ public class SkillGrantTests catalog, Fixtures.Classrooms(11), Fixtures.SchoolSeed, - "Slavic", + "Russia", Fixtures.AsOf, "BelarusianLanguage"); - var max = catalog.NameSets["Slavic"].RelatedLanguageMax; + var max = catalog.Countries["Russia"].Names.RelatedLanguageMax; Assert.All( roster.People, person => @@ -97,7 +97,7 @@ public class SkillGrantTests public void RelatedLanguages_AreCommonAndStayLow() { var catalog = Fixtures.Catalog(); - var max = catalog.NameSets["Slavic"].RelatedLanguageMax; + var max = catalog.Countries["Russia"].Names.RelatedLanguageMax; var roster = Fixtures.Generate(Fixtures.Classrooms(11)); Assert.Contains( roster.People, @@ -138,7 +138,7 @@ public class SkillGrantTests [Fact] public void ReloadWithoutASavedPick_TakesTheFirstLanguageInsteadOfRolling() { - var names = Fixtures.Catalog().NameSets["Slavic"]; + var names = Fixtures.Catalog().Countries["Russia"].Names; Assert.True(names.Spoken.Count > 1, "this pins behaviour that only matters for a multi-language set"); foreach (var seed in new[] { 1, 2, 7, 20260818 }) @@ -164,8 +164,8 @@ public class SkillGrantTests public void OmittedNativeLanguage_IsStableForTheSameSeed() { var map = Fixtures.Classrooms(2); - var a = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf); - var b = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf); + var a = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Russia", Fixtures.AsOf); + var b = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Russia", Fixtures.AsOf); Assert.Equal( a.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))), b.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal)))); @@ -195,7 +195,7 @@ public class SkillGrantTests catalog, before, Fixtures.SchoolSeed, - "Slavic", + "Russia", new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); var grown = after.People.First(person => person.Id == pupil.Id); Assert.True(grown.ClassId is { } classId && after.Classes.Any(schoolClass => diff --git a/tests/HSchool.People.Tests/StaffingTests.cs b/tests/HSchool.People.Tests/StaffingTests.cs index 234815f..69f6c8a 100644 --- a/tests/HSchool.People.Tests/StaffingTests.cs +++ b/tests/HSchool.People.Tests/StaffingTests.cs @@ -178,7 +178,7 @@ public class StaffingTests for (var seed = 1; seed <= 30 && pool is null; seed++) { var candidate = Fixtures.Generate(map, seed); - var created = ApplicantPool.Create(catalog, candidate, seed, "Slavic", Fixtures.AsOf); + var created = ApplicantPool.Create(catalog, candidate, seed, "Russia", Fixtures.AsOf); var rosterIds = candidate.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id)); var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a')); @@ -292,7 +292,7 @@ public class StaffingTests var catalog = Fixtures.Catalog(); var map = Fixtures.VanillaMap(); var roster = Fixtures.Generate(map); - var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf); + var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf); return (catalog, map, roster, pool); } diff --git a/tests/HSchool.People.Tests/YearlyIntakeTests.cs b/tests/HSchool.People.Tests/YearlyIntakeTests.cs index 09e96b9..f5c284d 100644 --- a/tests/HSchool.People.Tests/YearlyIntakeTests.cs +++ b/tests/HSchool.People.Tests/YearlyIntakeTests.cs @@ -23,7 +23,7 @@ public class YearlyIntakeTests Assert.Equal(4, before.Classes.Max(schoolClass => schoolClass.Year)); Assert.DoesNotContain(before.Classes, schoolClass => schoolClass.Year == 11); - var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September); var demand = SchoolDemand.From(catalog, map); Assert.True(RosterFit.Matches(after, demand)); @@ -46,8 +46,8 @@ public class YearlyIntakeTests var map = Fixtures.Classrooms(4); var catalog = Fixtures.Catalog(); var before = Fixtures.Generate(map); - var a = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September)); - var b = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September)); + var a = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September)); + var b = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September)); Assert.Equal(a, b); } @@ -57,7 +57,7 @@ public class YearlyIntakeTests var map = Fixtures.Classrooms(4); var catalog = Fixtures.Catalog(); var before = Fixtures.Generate(map); - var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September); var people = after.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var yearOf = after.Classes.ToDictionary(schoolClass => schoolClass.Id, schoolClass => schoolClass.Year, StringComparer.Ordinal); @@ -106,7 +106,7 @@ public class YearlyIntakeTests } } - var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September); var afterIds = after.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); Assert.NotEmpty(shouldLeave); Assert.All(shouldLeave, id => Assert.DoesNotContain(id, afterIds)); diff --git a/tests/HSchool.People.Tests/golden/roster.txt b/tests/HSchool.People.Tests/golden/roster.txt index 952afd6..64404bb 100644 --- a/tests/HSchool.People.Tests/golden/roster.txt +++ b/tests/HSchool.People.Tests/golden/roster.txt @@ -1,4 +1,4 @@ -# seed=20260818 nameset=Slavic native=RussianLanguage people=130 classes=4 +# seed=20260818 country=Russia native=RussianLanguage people=130 classes=4 class class-classroom-00 year=1 letter=А room=classroom-00 capacity=16 pupils=f1.c1,f1.c2,f13.c0,f18.c0,f19.c0,f24.c1,f26.c1,f27.c1,f27.c2,f28.c2,f32.c1,f33.c0,f34.c0,f6.c0,f6.c2,f8.c0 class class-classroom-01 year=2 letter=А room=classroom-01 capacity=16 pupils=f0.c1,f12.c0,f12.c1,f12.c2,f13.c1,f13.c2,f14.c2,f20.c0,f23.c1,f26.c0,f27.c0,f29.c0,f31.c0,f4.c0,f7.c0,f9.c0 class class-classroom-02 year=3 letter=А room=classroom-02 capacity=16 pupils=f10.c0,f14.c0,f16.c0,f17.c1,f2.c0,f21.c0,f22.c1,f23.c0,f25.c0,f28.c0,f3.c0,f3.c1,f32.c0,f32.c2,f5.c0,f6.c1 diff --git a/tests/HSchool.Simulation.Tests/DecisionTests.cs b/tests/HSchool.Simulation.Tests/DecisionTests.cs index 6747cbf..ffdb2e5 100644 --- a/tests/HSchool.Simulation.Tests/DecisionTests.cs +++ b/tests/HSchool.Simulation.Tests/DecisionTests.cs @@ -172,12 +172,12 @@ public class DecisionTests private static (School School, string Homeroom, string PupilId) TwoHomeroomLessons() { var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayMorning); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", TuesdayMorning); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); var schoolClass = roster.Classes.First(row => row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); var school = School.Create(1, "Два урока", TuesdayMorning, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.InstallPeople(roster, seed: 1, "Russia", pool); school.SetTimetable(new Timetable( [ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), @@ -193,12 +193,12 @@ public class DecisionTests private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed) { - var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", TuesdayMorning); - var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", TuesdayMorning); + var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", TuesdayMorning); var schoolClass = roster.Classes.First(row => row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); var school = School.Create(seed, "Решения", TuesdayMorning, catalog, map); - school.InstallPeople(roster, seed, "Slavic", pool); + school.InstallPeople(roster, seed, "Russia", pool); school.SetTimetable(new Timetable( [ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), diff --git a/tests/HSchool.Simulation.Tests/LunchTests.cs b/tests/HSchool.Simulation.Tests/LunchTests.cs index 0de965b..7ed5c70 100644 --- a/tests/HSchool.Simulation.Tests/LunchTests.cs +++ b/tests/HSchool.Simulation.Tests/LunchTests.cs @@ -93,8 +93,8 @@ public class LunchTests private static School StaffedSchool() { var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Tuesday6); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Tuesday6); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Tuesday6); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", Tuesday6); foreach (var subject in catalog.Subjects.Values.Where(def => !def.Abstract).Select(def => def.DefName)) { @@ -120,7 +120,7 @@ public class LunchTests } var school = School.Create(1, "Столовая", Tuesday6, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.InstallPeople(roster, seed: 1, "Russia", pool); school.SetTimetable(SchoolTimetables.Build(catalog, map, roster, null, weekDays: 5)); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64); return school; diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs index 3748186..282197e 100644 --- a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -14,11 +14,11 @@ public class PeopleInSchoolTests { var (catalog, map) = Vanilla(); var demand = SchoolDemand.From(catalog, map); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", Start); using var school = School.Create(1, "Полная", Start, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.InstallPeople(roster, seed: 1, "Russia", pool); school.Tick(1d / 20d, 5d); Assert.Same(roster, school.Roster); @@ -39,11 +39,11 @@ public class PeopleInSchoolTests { var start = new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc); var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start); using var school = School.Create(1, "Неделя", start, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.InstallPeople(roster, seed: 1, "Russia", pool); var monday = new DateTime(2012, 4, 2, 6, 0, 0, DateTimeKind.Utc); var changed = school.Tick((monday - start).TotalMinutes / 5d, 5d); @@ -60,7 +60,7 @@ public class PeopleInSchoolTests public void OffCampusNeeds_DoNotMoveOnTick() { var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Slavic", Start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Russia", Start); using var school = School.Create(2, "Нужды", Start, catalog, map); school.InstallPeople(roster, seed: 2); @@ -108,8 +108,8 @@ public class PeopleInSchoolTests public void SameSeed_InstallsTheSameNames() { var (catalog, map) = Vanilla(); - var first = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", Start); - var second = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", Start); + var first = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Russia", Start); + var second = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Russia", Start); using var a = School.Create(9, "А", Start, catalog, map); using var b = School.Create(9, "Б", Start, catalog, map); @@ -126,7 +126,7 @@ public class PeopleInSchoolTests { var catalog = Catalog(); var map = Classrooms(4); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Start); var demand = SchoolDemand.From(catalog, map); var oldest = roster.Classes.Max(schoolClass => schoolClass.Year); var graduated = roster.Classes @@ -135,7 +135,7 @@ public class PeopleInSchoolTests .ToHashSet(StringComparer.Ordinal); using var school = School.Create(1, "Четыре кабинета", Start, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic"); + school.InstallPeople(roster, seed: 1, "Russia"); var sept = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc); var changed = school.Tick((sept - Start).TotalMinutes / 5d, 5d); @@ -160,9 +160,9 @@ public class PeopleInSchoolTests var start = new DateTime(2012, 9, 1, 6, 0, 0, DateTimeKind.Utc); var catalog = Catalog(); var map = Classrooms(4); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 3, "Slavic", start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 3, "Russia", start); using var school = School.Create(3, "Первое сентября", start, catalog, map); - school.InstallPeople(roster, seed: 3, "Slavic"); + school.InstallPeople(roster, seed: 3, "Russia"); var years = roster.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year).ToArray(); var changed = school.Tick(1d / 20d, 5d); diff --git a/tests/HSchool.Simulation.Tests/PresenceTests.cs b/tests/HSchool.Simulation.Tests/PresenceTests.cs index 2f98e81..485c619 100644 --- a/tests/HSchool.Simulation.Tests/PresenceTests.cs +++ b/tests/HSchool.Simulation.Tests/PresenceTests.cs @@ -30,15 +30,15 @@ public class PresenceTests public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes() { var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayMorning); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", TuesdayMorning); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); var idle = roster.Classes[0]; var busy = roster.Classes[1]; var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.None, hired.Error); using var school = School.Create(1, "Два класса", TuesdayMorning, catalog, map); - school.InstallPeople(hired.Roster, seed: 1, "Slavic", hired.Pool); + school.InstallPeople(hired.Roster, seed: 1, "Russia", hired.Pool); school.SetTimetable(new Timetable( [new LessonPlacement(busy.Id, "Mathematics", hired.Roster.People.First(person => person.IsStaff).Id, busy.RoomId, Day: 1, Period: 1)], [])); @@ -108,7 +108,7 @@ public class PresenceTests Assert.Contains(snapshot, row => row.NodeId is not null && (row.Path.Count > 0 || row.RemainingMinutes > 0)); using var loaded = School.Load(2, "Сейв", live.Clock.Time, running: true, speedIndex: 0, catalog, map); - loaded.InstallPeople(live.Roster!, seed: 4, "Slavic", live.Applicants); + loaded.InstallPeople(live.Roster!, seed: 4, "Russia", live.Applicants); loaded.SetTimetable(live.Timetable!); loaded.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); loaded.RestorePresence(snapshot); @@ -277,12 +277,12 @@ public class PresenceTests private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed) { - var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", TuesdayMorning); - var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", TuesdayMorning); + var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", TuesdayMorning); var schoolClass = roster.Classes.First(row => row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); var school = School.Create(seed, "Присутствие", TuesdayMorning, catalog, map); - school.InstallPeople(roster, seed, "Slavic", pool); + school.InstallPeople(roster, seed, "Russia", pool); school.SetTimetable(new Timetable( [ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), @@ -295,11 +295,11 @@ public class PresenceTests private static School Open(DefCatalog catalog, MapLayout map, DateTime start, int seed) { - var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", start); - var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", start); + var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start); var schoolClass = roster.Classes[0]; var school = School.Create(seed, "Присутствие", start, catalog, map); - school.InstallPeople(roster, seed, "Slavic", pool); + school.InstallPeople(roster, seed, "Russia", pool); school.SetTimetable(new Timetable( [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 0, Period: 1)], [])); @@ -310,10 +310,10 @@ public class PresenceTests private static School OpenEmpty(DateTime start) { var (catalog, map) = Vanilla(); - var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start); - var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start); var school = School.Create(1, "Пустая", start, catalog, map); - school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.InstallPeople(roster, seed: 1, "Russia", pool); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); return school; } From 13d335360930821ab72a831351bb884fb9e8601b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 02:55:57 +0300 Subject: [PATCH 2/2] Mark phase 29 as done. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index fc5c5c9..df43573 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -127,7 +127,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [29. Страна](29-country.md) | ⬜ | `CountryDef`, create выбирает страну, климат-пресет от сида | +| [29. Страна](29-country.md) | ✅ | `CountryDef`, create выбирает страну, климат-пресет от сида | | [30. Слои и палитра](30-apparel-defs.md) | ⬜ | Поля `ThingDef`, `ColorDef`, ванильная одежда и ноша | | [31. Генератор одевает](31-dress-generator.md) | ⬜ | Гардероб на человеке, ноша-число, `people.json` |