Merge phase 29 country.
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 19s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 02:59:46 +03:00
co-authored by Cursor
59 changed files with 903 additions and 478 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ way; this file is *how to work in them*.
| --- | --- | | --- | --- |
| schools, the game clock, game rules | `src/HSchool.Simulation` | | schools, the game clock, game rules | `src/HSchool.Simulation` |
| defs, JSONC catalog, map validation | `src/HSchool.Content` | | 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` | | people generation, families, roster records, yearly intake | `src/HSchool.People` |
| timetable planning | `src/HSchool.Schedule` | | timetable planning | `src/HSchool.Schedule` |
| routes, day plans, who comes today | `src/HSchool.Ai` | | 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` | | connection handling, workers, saves | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| screens, dialogs, formatting, UI language | `src/HSchool.Client/src` | | 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 2937; no code yet | | clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 2937; country is live, clothing is not |
## Commands ## 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. - 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. 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. 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 - 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. produce the same table; the suite does not boot a host.
+16 -16
View File
@@ -12,32 +12,32 @@ Create выбирает страну, а не набор имён. Имена и
## Задачи ## Задачи
- [ ] `CountryDef` и `ClimatePresetDef` в `HSchool.Content`: страна вкладывает нынешние поля - [x] `CountryDef` и `ClimatePresetDef` в `HSchool.Content`: страна вкладывает нынешние поля
набора имён в `names`, ссылается на пресеты списками id набора имён в `names`, ссылается на пресеты списками id
- [ ] Набор имён больше не выбираемый вид; папка `namesets/` в `core` уезжает в страну - [x] Набор имён больше не выбираемый вид; папка `namesets/` в `core` уезжает в страну
- [ ] Ваниль: одна страна `Russia` с нынешним славянским набором и хотя бы одним пресетом - [x] Ваниль: одна страна `Russia` с нынешним славянским набором и хотя бы одним пресетом
(числа погоды можно заглушить — живая улица в фазе 32) (числа погоды можно заглушить — живая улица в фазе 32)
- [ ] `GET /api/catalog` отдаёт `countries` (`defName`, подпись, `nativeLanguages`, - [x] `GET /api/catalog` отдаёт `countries` (`defName`, подпись, `nativeLanguages`,
`climatePresets`) вместо `nameSets` `climatePresets`) вместо `nameSets`
- [ ] `POST /api/schools` принимает `countryId`; неизвестный — `400` `unknown-country`. - [x] `POST /api/schools` принимает `countryId`; неизвестный — `400` `unknown-country`.
`nameSetId` не принимается `nameSetId` не принимается
- [ ] Пресет климата записывается на школу: случайный из списка страны, от сида. Живой школе - [x] Пресет климата записывается на школу: случайный из списка страны, от сида. Живой школе
его не сменить его не сменить
- [ ] Форма создания: подпись «Страна»; язык — если у страны их несколько - [x] Форма создания: подпись «Страна»; язык — если у страны их несколько
- [ ] Старый сейв с `nameSetId: Slavic` поднимается как `Russia` + первый пресет; формат - [x] Старый сейв с `nameSetId: Slavic` поднимается как `Russia` + первый пресет; формат
новее без `countryId` — как с прочими полями сейва, школа не стартует новее без `countryId` — как с прочими полями сейва, школа не стартует
- [ ] `docs/protocol.md` и `AGENTS.md` (ориентир create / каталог) в том же коммите - [x] `docs/protocol.md` и `AGENTS.md` (ориентир create / каталог) в том же коммите
- [ ] Золотой отпечаток ростера обновляется, если вход генератора сменил имя поля - [x] Золотой отпечаток ростера обновляется, если вход генератора сменил имя поля
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Каталог `core` грузится; страна без имён или с неизвестным пресетом — ошибка загрузки - [x] Каталог `core` грузится; страна без имён или с неизвестным пресетом — ошибка загрузки
- [ ] Create с `countryId: Russia` и без языка даёт тот же родной язык, что прежний сид без - [x] Create с `countryId: Russia` и без языка даёт тот же родной язык, что прежний сид без
`nativeLanguage` `nativeLanguage`
- [ ] Create с неизвестной страной — `400` `unknown-country` - [x] Create с неизвестной страной — `400` `unknown-country`
- [ ] Два create с одним сидом получают один и тот же пресет - [x] Два create с одним сидом получают один и тот же пресет
- [ ] Старый сейв без страны поднимается - [x] Старый сейв без страны поднимается
- [ ] Диалог создания показывает страны, не наборы имён; смена страны обновляет список языков - [x] Диалог создания показывает страны, не наборы имён; смена страны обновляет список языков
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -127,7 +127,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [29. Страна](29-country.md) | 🔄 | `CountryDef`, create выбирает страну, климат-пресет от сида | | [29. Страна](29-country.md) | | `CountryDef`, create выбирает страну, климат-пресет от сида |
| [30. Слои и палитра](30-apparel-defs.md) | ✅ | Поля `ThingDef`, `ColorDef`, ванильная одежда и ноша | | [30. Слои и палитра](30-apparel-defs.md) | ✅ | Поля `ThingDef`, `ColorDef`, ванильная одежда и ноша |
| [31. Генератор одевает](31-dress-generator.md) | ⬜ | Гардероб на человеке, ноша-число, `people.json` | | [31. Генератор одевает](31-dress-generator.md) | ⬜ | Гардероб на человеке, ноша-число, `people.json` |
+16 -14
View File
@@ -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 Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot
total is computed on the server. total is computed on the server.
`nameSets` is the list of placeable name packs (`defName` + label + `nativeLanguages`). Each `countries` is the list of placeable countries (`defName` + label + `nativeLanguages` +
`nativeLanguages` entry is a skill (`defName` + label). The create dialog picks a name set and a `climatePresets`). Each `nativeLanguages` entry is a skill (`defName` + label). The create
native language; the language list comes from that set (vanilla Slavic: Russian, Belarusian, dialog picks a country and, when that country lists more than one tongue, a native language;
Ukrainian). It is independent of the UI language. Everyone in the school is generated with that the language list comes from that country (vanilla Russia: Russian, Belarusian, Ukrainian).
native. Other languages of the same set often appear at a low skill — a Russian speaker who It is independent of the UI language. Everyone in the school is generated with that native.
understands Belarusian. Those rolls live on the `NameSetDef` (`relatedLanguageChance` and Other languages of the same country often appear at a low skill — a Russian speaker who
neighbours), not on this catalog payload. 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`, `subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
`hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson `hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson
@@ -121,7 +123,7 @@ Body:
"startDate": "2012-03-31T06:00:00Z", "startDate": "2012-03-31T06:00:00Z",
"modIds": [], "modIds": [],
"map": null, "map": null,
"nameSetId": "Slavic", "countryId": "Russia",
"nativeLanguage": null, "nativeLanguage": null,
"seed": null "seed": null
} }
@@ -131,10 +133,10 @@ Body:
pack's `requires` load first (stable topological sort over the player's order). The resolved 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 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. 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: `countryId` is a `CountryDef`; omit it to use the first placeable country in the catalog (vanilla:
`Slavic`). Unknown ids return `400` `unknown-name-set`. `Russia`). Unknown ids return `400` `unknown-country`. The old `nameSetId` field is not accepted.
`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick `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 set returns `400` `unknown-native-language`. 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 `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. 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` `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` `mod-cycle` | Selected packs require each other in a cycle. |
| `400` `invalid-catalog` | The selected packs could not be loaded. | | `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-country` | `countryId` is not a placeable `CountryDef` in those packs. |
| `400` `unknown-native-language` | `nativeLanguage` is not in that name set's `nativeLanguages`. | | `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. |
| `409` `school-limit-reached` | `maxSchools` schools already exist. | | `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. Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
+6 -6
View File
@@ -43,12 +43,12 @@ const ru = {
errorMissingMod: 'Не выбран обязательный мод «{id}».', errorMissingMod: 'Не выбран обязательный мод «{id}».',
errorModCycle: 'Выбранные моды зависят друг от друга по кругу.', errorModCycle: 'Выбранные моды зависят друг от друга по кругу.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.', errorUnknownCountry: 'Выбранная страна не найдена.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.', catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды', modsTitle: 'Моды',
nameSetTitle: 'Набор имён', countryTitle: 'Страна',
nativeLanguageTitle: 'Родной язык', nativeLanguageTitle: 'Родной язык',
nativeLanguageRandom: 'Случайный', nativeLanguageRandom: 'Случайный',
randomNativeTitle: 'Выбрать язык наугад', randomNativeTitle: 'Выбрать язык наугад',
@@ -260,12 +260,12 @@ const en: Messages = {
errorMissingMod: 'Required mod “{id}” is not selected.', errorMissingMod: 'Required mod “{id}” is not selected.',
errorModCycle: 'The selected mods depend on each other in a cycle.', errorModCycle: 'The selected mods depend on each other in a cycle.',
errorInvalidCatalog: 'The selected packs could not be loaded.', errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.', errorUnknownCountry: 'The selected country is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that name set.', errorUnknownNativeLanguage: 'The selected native language is not in that country.',
catalogLoadFailed: 'Could not load the mod catalog.', catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods', modsTitle: 'Mods',
nameSetTitle: 'Name set', countryTitle: 'Country',
nativeLanguageTitle: 'Native language', nativeLanguageTitle: 'Native language',
nativeLanguageRandom: 'Random', nativeLanguageRandom: 'Random',
randomNativeTitle: 'Pick a language at random', randomNativeTitle: 'Pick a language at random',
+5 -4
View File
@@ -53,7 +53,7 @@ export async function fetchRandomName(lang: string): Promise<string> {
export interface CreateSchoolOptions { export interface CreateSchoolOptions {
readonly modIds?: readonly string[]; readonly modIds?: readonly string[];
readonly map?: MapLayout; readonly map?: MapLayout;
readonly nameSetId?: string; readonly countryId?: string;
readonly nativeLanguage?: string; readonly nativeLanguage?: string;
readonly seed?: number; readonly seed?: number;
} }
@@ -71,7 +71,7 @@ export async function createSchool(
startDate: startDate.toISOString(), startDate: startDate.toISOString(),
modIds: extras.modIds ?? [], modIds: extras.modIds ?? [],
map: extras.map ?? null, map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null, countryId: extras.countryId ?? null,
nativeLanguage: extras.nativeLanguage ?? null, nativeLanguage: extras.nativeLanguage ?? null,
seed: extras.seed ?? null, seed: extras.seed ?? null,
}), }),
@@ -92,10 +92,11 @@ export interface DefInfo {
readonly pupilSlots?: number; readonly pupilSlots?: number;
} }
export interface NameSetInfo { export interface CountryInfo {
readonly defName: string; readonly defName: string;
readonly label: string; readonly label: string;
readonly nativeLanguages: readonly DefInfo[]; readonly nativeLanguages: readonly DefInfo[];
readonly climatePresets: readonly string[];
} }
export interface RoomSlotInfo { export interface RoomSlotInfo {
@@ -170,7 +171,7 @@ export interface CatalogResponse {
readonly rooms: readonly RoomInfo[]; readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[]; readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout; readonly defaultMap: MapLayout;
readonly nameSets: readonly NameSetInfo[]; readonly countries: readonly CountryInfo[];
readonly subjects: readonly SubjectInfo[]; readonly subjects: readonly SubjectInfo[];
readonly dayFrame: DayFrameInfo | null; readonly dayFrame: DayFrameInfo | null;
readonly holidays: readonly HolidayInfo[]; readonly holidays: readonly HolidayInfo[];
@@ -54,11 +54,22 @@ function catalog(): CatalogResponse {
], ],
things: [], things: [],
defaultMap, defaultMap,
nameSets: [ countries: [
{ {
defName: 'Russian', defName: 'Russia',
label: 'Russian', label: 'Russia',
nativeLanguages: [{ defName: 'Russian', label: 'Russian' }], 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: [], subjects: [],
@@ -162,6 +173,55 @@ describe('createSchoolDialog', () => {
finish(school()); finish(school());
await expect(opened).resolves.toMatchObject({ name: 'North' }); 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', () => { describe('map reset from the create editor', () => {
+31 -31
View File
@@ -32,7 +32,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const extraModIds = new Set<string>(); const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null; let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null; let currentMap: MapLayout | null = null;
let nameSetId: string | null = null; let countryId: string | null = null;
let nativeLanguageId: string | null = null; let nativeLanguageId: string | null = null;
const modsField = el('div', { class: 'field' }); const modsField = el('div', { class: 'field' });
@@ -40,11 +40,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const modsList = el('div', { class: 'mod-list' }); const modsList = el('div', { class: 'mod-list' });
modsField.append(modsLabel, modsList); modsField.append(modsLabel, modsList);
const nameSetLabel = el('span', { class: 'field__label' }); const countryLabel = el('span', { class: 'field__label' });
const nameSetSelect = el('select', { class: 'input' }); const countrySelect = el('select', { class: 'input' });
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect); const countryField = el('label', { class: 'field' }, countryLabel, countrySelect);
nameSetSelect.addEventListener('change', () => { countrySelect.addEventListener('change', () => {
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value; countryId = countrySelect.value === '' ? null : countrySelect.value;
nativeLanguageId = null; nativeLanguageId = null;
if (catalog !== null) { if (catalog !== null) {
paintNativeLanguages(catalog); paintNativeLanguages(catalog);
@@ -110,7 +110,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)), el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)), el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField, modsField,
nameSetField, countryField,
nativeField, nativeField,
mapField, mapField,
seedField, seedField,
@@ -125,7 +125,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const waiting = value || catalog === null; const waiting = value || catalog === null;
submitButton.toggleAttribute('disabled', waiting); submitButton.toggleAttribute('disabled', waiting);
randomButton.toggleAttribute('disabled', value); randomButton.toggleAttribute('disabled', value);
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1); nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
editMapButton.toggleAttribute('disabled', waiting); editMapButton.toggleAttribute('disabled', waiting);
seedInput.toggleAttribute('disabled', value); seedInput.toggleAttribute('disabled', value);
}; };
@@ -149,31 +149,31 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const applyCatalog = (next: CatalogResponse): void => { const applyCatalog = (next: CatalogResponse): void => {
catalog = next; catalog = next;
currentMap = structuredClone(next.defaultMap); currentMap = structuredClone(next.defaultMap);
const stillThere = next.nameSets.some((set) => set.defName === nameSetId); const stillThere = next.countries.some((country) => country.defName === countryId);
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null); countryId = stillThere ? countryId : (next.countries[0]?.defName ?? null);
if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) { if (!languagesOf(next, countryId).some((language) => language.defName === nativeLanguageId)) {
nativeLanguageId = null; nativeLanguageId = null;
} }
paintNameSets(next); paintCountries(next);
paintNativeLanguages(next); paintNativeLanguages(next);
paintMapHint(); paintMapHint();
setBusy(busy); setBusy(busy);
}; };
const paintNameSets = (next: CatalogResponse): void => { const paintCountries = (next: CatalogResponse): void => {
nameSetSelect.replaceChildren(); countrySelect.replaceChildren();
for (const set of next.nameSets) { for (const country of next.countries) {
const option = el('option', { text: set.label }); const option = el('option', { text: country.label });
option.value = set.defName; option.value = country.defName;
nameSetSelect.append(option); countrySelect.append(option);
} }
nameSetSelect.value = nameSetId ?? ''; countrySelect.value = countryId ?? '';
nameSetSelect.disabled = next.nameSets.length <= 1; countrySelect.disabled = next.countries.length <= 1;
}; };
const paintNativeLanguages = (next: CatalogResponse): void => { const paintNativeLanguages = (next: CatalogResponse): void => {
const languages = languagesOf(next, nameSetId); const languages = languagesOf(next, countryId);
nativeSelect.replaceChildren(); nativeSelect.replaceChildren();
const random = el('option', { text: t('nativeLanguageRandom') }); const random = el('option', { text: t('nativeLanguageRandom') });
random.value = ''; random.value = '';
@@ -187,7 +187,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nativeSelect.value = nativeLanguageId ?? ''; nativeSelect.value = nativeLanguageId ?? '';
nativeSelect.disabled = languages.length <= 1; nativeSelect.disabled = languages.length <= 1;
nativeRandomButton.toggleAttribute('disabled', busy || languages.length <= 1); nativeRandomButton.toggleAttribute('disabled', busy || languages.length <= 1);
nativeField.hidden = languages.length === 0; nativeField.hidden = languages.length <= 1;
}; };
const reloadCatalog = async (): Promise<void> => { const reloadCatalog = async (): Promise<void> => {
@@ -242,7 +242,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const localize = (): void => { const localize = (): void => {
title.textContent = t('newSchool'); title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle'); modsLabel.textContent = t('modsTitle');
nameSetLabel.textContent = t('nameSetTitle'); countryLabel.textContent = t('countryTitle');
nativeLabel.textContent = t('nativeLanguageTitle'); nativeLabel.textContent = t('nativeLanguageTitle');
nativeRandomButton.textContent = t('randomName'); nativeRandomButton.textContent = t('randomName');
nativeRandomButton.title = t('randomNativeTitle'); nativeRandomButton.title = t('randomNativeTitle');
@@ -260,7 +260,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
cancelButton.textContent = t('cancel'); cancelButton.textContent = t('cancel');
paintMapHint(); paintMapHint();
if (catalog !== null) { if (catalog !== null) {
paintNameSets(catalog); paintCountries(catalog);
paintNativeLanguages(catalog); paintNativeLanguages(catalog);
} }
}; };
@@ -284,7 +284,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return; return;
} }
const languages = languagesOf(catalog, nameSetId); const languages = languagesOf(catalog, countryId);
if (languages.length === 0) { if (languages.length === 0) {
return; return;
} }
@@ -332,7 +332,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
.create(nameInput.value.trim(), startDate, { .create(nameInput.value.trim(), startDate, {
modIds: [...extraModIds], modIds: [...extraModIds],
map: currentMap, map: currentMap,
nameSetId: nameSetId ?? undefined, countryId: countryId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined, nativeLanguage: nativeLanguageId ?? undefined,
seed: seed ?? undefined, seed: seed ?? undefined,
}) })
@@ -349,12 +349,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return modal.open(nameInput); return modal.open(nameInput);
} }
function languagesOf(catalog: CatalogResponse | null, nameSetId: string | null): readonly { readonly defName: string; readonly label: string }[] { function languagesOf(catalog: CatalogResponse | null, countryId: string | null): readonly { readonly defName: string; readonly label: string }[] {
if (catalog === null || nameSetId === null) { if (catalog === null || countryId === null) {
return []; return [];
} }
return catalog.nameSets.find((set) => set.defName === nameSetId)?.nativeLanguages ?? []; return catalog.countries.find((country) => country.defName === countryId)?.nativeLanguages ?? [];
} }
function mapsEqual(left: MapLayout, right: MapLayout): boolean { function mapsEqual(left: MapLayout, right: MapLayout): boolean {
@@ -401,8 +401,8 @@ function describe(reason: unknown): string {
return t('errorModCycle'); return t('errorModCycle');
case 'invalid-catalog': case 'invalid-catalog':
return t('errorInvalidCatalog'); return t('errorInvalidCatalog');
case 'unknown-name-set': case 'unknown-country':
return t('errorUnknownNameSet'); return t('errorUnknownCountry');
case 'unknown-native-language': case 'unknown-native-language':
return t('errorUnknownNativeLanguage'); return t('errorUnknownNativeLanguage');
default: default:
+11 -5
View File
@@ -307,7 +307,8 @@ public sealed class CatalogLoader
var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal); var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal);
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal); var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal); var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal); var countries = new Dictionary<string, CountryDef>(StringComparer.Ordinal);
var climatePresets = new Dictionary<string, ClimatePresetDef>(StringComparer.Ordinal);
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal); var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal); var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal); var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
@@ -355,8 +356,11 @@ public sealed class CatalogLoader
case DefKind.Need: case DefKind.Need:
needs[key.Name] = Jsonc.Deserialize<NeedDef>(json); needs[key.Name] = Jsonc.Deserialize<NeedDef>(json);
break; break;
case DefKind.NameSet: case DefKind.Country:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json); countries[key.Name] = Jsonc.Deserialize<CountryDef>(json);
break;
case DefKind.ClimatePreset:
climatePresets[key.Name] = Jsonc.Deserialize<ClimatePresetDef>(json);
break; break;
case DefKind.Subject: case DefKind.Subject:
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json); subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
@@ -393,7 +397,8 @@ public sealed class CatalogLoader
traits, traits,
bodyAttributes, bodyAttributes,
needs, needs,
nameSets, countries,
climatePresets,
subjects, subjects,
staffing, staffing,
dayFrames, dayFrames,
@@ -541,7 +546,8 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.Traits.Values)) .Concat(Enumerate(catalog.Traits.Values))
.Concat(Enumerate(catalog.BodyAttributes.Values)) .Concat(Enumerate(catalog.BodyAttributes.Values))
.Concat(Enumerate(catalog.Needs.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.Subjects.Values))
.Concat(Enumerate(catalog.Staffing.Values)) .Concat(Enumerate(catalog.Staffing.Values))
.Concat(Enumerate(catalog.DayFrames.Values)) .Concat(Enumerate(catalog.DayFrames.Values))
+24 -5
View File
@@ -20,7 +20,8 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, TraitDef> traits, IReadOnlyDictionary<string, TraitDef> traits,
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes, IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs, IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets, IReadOnlyDictionary<string, CountryDef> countries,
IReadOnlyDictionary<string, ClimatePresetDef> climatePresets,
IReadOnlyDictionary<string, SubjectDef> subjects, IReadOnlyDictionary<string, SubjectDef> subjects,
IReadOnlyDictionary<string, StaffingDef> staffing, IReadOnlyDictionary<string, StaffingDef> staffing,
IReadOnlyDictionary<string, DayFrameDef> dayFrames, IReadOnlyDictionary<string, DayFrameDef> dayFrames,
@@ -43,7 +44,8 @@ public sealed class DefCatalog
Traits = traits; Traits = traits;
BodyAttributes = bodyAttributes; BodyAttributes = bodyAttributes;
Needs = needs; Needs = needs;
NameSets = nameSets; Countries = countries;
ClimatePresets = climatePresets;
Subjects = subjects; Subjects = subjects;
Staffing = staffing; Staffing = staffing;
DayFrames = dayFrames; DayFrames = dayFrames;
@@ -91,7 +93,9 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, NeedDef> Needs { get; } public IReadOnlyDictionary<string, NeedDef> Needs { get; }
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; } public IReadOnlyDictionary<string, CountryDef> Countries { get; }
public IReadOnlyDictionary<string, ClimatePresetDef> ClimatePresets { get; }
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; } public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
@@ -133,7 +137,8 @@ public sealed class DefCatalog
DefKind.Trait => Traits.GetValueOrDefault(defName), DefKind.Trait => Traits.GetValueOrDefault(defName),
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName), DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.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.Subject => Subjects.GetValueOrDefault(defName),
DefKind.Staffing => Staffing.GetValueOrDefault(defName), DefKind.Staffing => Staffing.GetValueOrDefault(defName),
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName), DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
@@ -212,7 +217,8 @@ public sealed class DefCatalog
TraitDef => DefKind.Trait, TraitDef => DefKind.Trait,
BodyAttributeDef => DefKind.BodyAttribute, BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need, NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet, CountryDef => DefKind.Country,
ClimatePresetDef => DefKind.ClimatePreset,
SubjectDef => DefKind.Subject, SubjectDef => DefKind.Subject,
StaffingDef => DefKind.Staffing, StaffingDef => DefKind.Staffing,
DayFrameDef => DefKind.DayFrame, DayFrameDef => DefKind.DayFrame,
@@ -245,4 +251,17 @@ public sealed class DefCatalog
set.Remove(defName); set.Remove(defName);
return set; return set;
} }
/// <summary>Nested names of a concrete country, or false when the id is missing or abstract.</summary>
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;
}
} }
+2 -1
View File
@@ -14,7 +14,8 @@ public enum DefKind
Trait, Trait,
BodyAttribute, BodyAttribute,
Need, Need,
NameSet, Country,
ClimatePreset,
Subject, Subject,
Staffing, Staffing,
DayFrame, DayFrame,
+5 -2
View File
@@ -105,8 +105,11 @@ internal static class PackPaths
case "needs": case "needs":
kind = DefKind.Need; kind = DefKind.Need;
return true; return true;
case "namesets": case "countries":
kind = DefKind.NameSet; kind = DefKind.Country;
return true;
case "climates":
kind = DefKind.ClimatePreset;
return true; return true;
case "subjects": case "subjects":
kind = DefKind.Subject; kind = DefKind.Subject;
+45 -16
View File
@@ -25,9 +25,9 @@ internal static class PeopleDefValidator
ValidateNeed(need); 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) 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<string>(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)) 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) foreach (var native in names.Spoken)
{ {
if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract) 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) if (names.RelatedLanguageChance is < 0 or > 1)
{ {
throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageChance must be 01."); throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageChance must be 01.");
} }
if (names.RelatedLanguageStdDev < 0) 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) 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) 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)) 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)) 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) 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)) foreach (var given in names.MaleGiven.Concat(names.FemaleGiven))
{ {
if (string.IsNullOrWhiteSpace(given.Form)) 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) if (given.Cases is null)
@@ -611,7 +640,7 @@ internal static class PeopleDefValidator
: given.Declension; : given.Declension;
if (!NameGrammar.IsKnownGiven(model)) 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)) 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) if (surname.MaleCases is null && surname.FemaleCases is null)
@@ -630,7 +659,7 @@ internal static class PeopleDefValidator
: surname.Declension; : surname.Declension;
if (!NameGrammar.IsKnownSurname(model)) 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}'.");
} }
} }
} }
+22 -2
View File
@@ -359,7 +359,10 @@ public sealed class SurnameEntry
public CaseTable? FemaleCases { get; init; } public CaseTable? FemaleCases { get; init; }
} }
public sealed class NameSetDef : Def /// <summary>
/// Nested name grammar of a <see cref="CountryDef"/>. Not a selectable catalog kind of its own.
/// </summary>
public sealed class NameSetDef
{ {
public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic; public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic;
@@ -368,7 +371,7 @@ public sealed class NameSetDef : Def
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov; public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
/// <summary> /// <summary>
/// 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 <see cref="NativeLanguage"/> is still /// Ukrainian; the school picks one at create. Singular <see cref="NativeLanguage"/> is still
/// accepted in JSONC for a one-language pack. /// accepted in JSONC for a one-language pack.
/// </summary> /// </summary>
@@ -401,3 +404,20 @@ public sealed class NameSetDef : Def
public IReadOnlyList<SurnameEntry> Surnames { get; init; } = []; public IReadOnlyList<SurnameEntry> Surnames { get; init; } = [];
} }
/// <summary>
/// What the player picks at create: nested names plus climate-preset ids. Weather numbers live
/// on <see cref="ClimatePresetDef"/> and stay unused until phase 32.
/// </summary>
public sealed class CountryDef : Def
{
public IReadOnlyList<string> ClimatePresets { get; init; } = [];
public NameSetDef Names { get; init; } = new();
}
/// <summary>
/// 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.
/// </summary>
public sealed class ClimatePresetDef : Def;
+9 -9
View File
@@ -15,12 +15,12 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
DefCatalog catalog, DefCatalog catalog,
Roster roster, Roster roster,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime asOf, DateTime asOf,
string? nativeLanguage = null) string? nativeLanguage = null)
{ {
var week = ApplicantWeeks.Id(asOf); var week = ApplicantWeeks.Id(asOf);
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []), nativeLanguage: nativeLanguage); return Fill(catalog, roster, schoolSeed, countryId, asOf, new ApplicantPool(week, 0, []), nativeLanguage: nativeLanguage);
} }
/// <summary> /// <summary>
@@ -31,7 +31,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
DefCatalog catalog, DefCatalog catalog,
Roster roster, Roster roster,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime asOf, DateTime asOf,
string? nativeLanguage = null) string? nativeLanguage = null)
{ {
@@ -44,7 +44,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
var pool = this; var pool = this;
for (var week = Week + 1; week <= target; week++) for (var week = Week + 1; week <= target; week++)
{ {
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week, nativeLanguage); pool = Step(pool, catalog, roster, schoolSeed, countryId, asOf, week, nativeLanguage);
} }
return pool; return pool;
@@ -93,7 +93,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
DefCatalog catalog, DefCatalog catalog,
Roster roster, Roster roster,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime asOf, DateTime asOf,
int week, int week,
string? nativeLanguage) string? nativeLanguage)
@@ -113,7 +113,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
catalog, catalog,
roster, roster,
schoolSeed, schoolSeed,
nameSetId, countryId,
asOf, asOf,
new ApplicantPool(week, current.NextIndex, staying), new ApplicantPool(week, current.NextIndex, staying),
rng, rng,
@@ -124,16 +124,16 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
DefCatalog catalog, DefCatalog catalog,
Roster roster, Roster roster,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime asOf, DateTime asOf,
ApplicantPool current, ApplicantPool current,
Random? rng = null, Random? rng = null,
string? nativeLanguage = null) string? nativeLanguage = null)
{ {
var rules = RequireRules(catalog); var rules = RequireRules(catalog);
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); var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
+26
View File
@@ -0,0 +1,26 @@
namespace HSchool.People;
/// <summary>
/// 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.
/// </summary>
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)];
}
}
+4 -4
View File
@@ -15,17 +15,17 @@ public static class RosterGenerator
DefCatalog catalog, DefCatalog catalog,
MapLayout map, MapLayout map,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime? asOf = null, DateTime? asOf = null,
string? nativeLanguage = null) string? nativeLanguage = null)
{ {
ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map); 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); var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: true);
+1
View File
@@ -15,6 +15,7 @@ public static class Seed
public const int CommuteSalt = 7; public const int CommuteSalt = 7;
public const int SkillGrantSalt = 8; public const int SkillGrantSalt = 8;
public const int NativeLanguageSalt = 9; public const int NativeLanguageSalt = 9;
public const int ClimatePresetSalt = 10;
/// <summary>A stream that belongs to the school rather than to one family.</summary> /// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
+4 -4
View File
@@ -38,17 +38,17 @@ public static class YearlyIntake
DefCatalog catalog, DefCatalog catalog,
Roster roster, Roster roster,
int schoolSeed, int schoolSeed,
string nameSetId, string countryId,
DateTime asOf, DateTime asOf,
string? nativeLanguage = null) string? nativeLanguage = null)
{ {
ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(roster); 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); var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
+11 -9
View File
@@ -122,7 +122,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Floors, IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms, IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things, IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<NameSetInfoResponse> NameSets, IReadOnlyList<CountryInfoResponse> Countries,
IReadOnlyList<SubjectInfoResponse> Subjects, IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame, DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays, IReadOnlyList<HolidayInfoResponse> Holidays,
@@ -135,7 +135,7 @@ internal sealed record CatalogResponse(
Placeable(catalog.Floors.Values, catalog, locale), Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale), PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale), PlaceableThings(catalog, locale),
PlaceableNameSets(catalog, locale), PlaceableCountries(catalog, locale),
PlaceableSubjects(catalog, locale), PlaceableSubjects(catalog, locale),
MapDayFrame(catalog, locale), MapDayFrame(catalog, locale),
PlaceableHolidays(catalog, locale), PlaceableHolidays(catalog, locale),
@@ -149,20 +149,21 @@ internal sealed record CatalogResponse(
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def))) .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
.ToArray(); .ToArray();
private static IReadOnlyList<NameSetInfoResponse> PlaceableNameSets(DefCatalog catalog, string locale) => private static IReadOnlyList<CountryInfoResponse> PlaceableCountries(DefCatalog catalog, string locale) =>
catalog.NameSets.Values catalog.Countries.Values
.Where(def => !def.Abstract) .Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal) .OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new NameSetInfoResponse( .Select(def => new CountryInfoResponse(
def.DefName, def.DefName,
catalog.Label(locale, def), catalog.Label(locale, def),
def.Spoken def.Names.Spoken
.Select(skill => new DefInfoResponse( .Select(skill => new DefInfoResponse(
skill, skill,
catalog.Skills.TryGetValue(skill, out var language) catalog.Skills.TryGetValue(skill, out var language)
? catalog.Label(locale, language) ? catalog.Label(locale, language)
: skill)) : skill))
.ToArray())) .ToArray(),
def.ClimatePresets.ToArray()))
.ToArray(); .ToArray();
private static IReadOnlyList<DefInfoResponse> PlaceableThings(DefCatalog catalog, string locale) => private static IReadOnlyList<DefInfoResponse> 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 DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
internal sealed record NameSetInfoResponse( internal sealed record CountryInfoResponse(
string DefName, string DefName,
string Label, string Label,
IReadOnlyList<DefInfoResponse> NativeLanguages); IReadOnlyList<DefInfoResponse> NativeLanguages,
IReadOnlyList<string> ClimatePresets);
internal sealed record RoomInfoResponse( internal sealed record RoomInfoResponse(
string DefName, string DefName,
+5 -5
View File
@@ -53,7 +53,7 @@ internal static class SchoolEndpoints
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds, request.ModIds,
request.Map, request.Map,
request.NameSetId, request.CountryId,
request.NativeLanguage, request.NativeLanguage,
request.Seed, request.Seed,
NewCompletion<SchoolCreationOutcome>()); NewCompletion<SchoolCreationOutcome>());
@@ -77,10 +77,10 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."), Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog => SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."), Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownNameSet => SchoolCreationError.UnknownCountry =>
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."), Problem(StatusCodes.Status400BadRequest, "unknown-country", "The selected country is not in the catalog."),
SchoolCreationError.UnknownNativeLanguage => 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 => SchoolCreationError.MissingMod =>
Problem( Problem(
StatusCodes.Status400BadRequest, StatusCodes.Status400BadRequest,
@@ -508,7 +508,7 @@ internal sealed record CreateSchoolRequest(
DateTime StartDate, DateTime StartDate,
IReadOnlyList<string>? ModIds, IReadOnlyList<string>? ModIds,
MapLayout? Map, MapLayout? Map,
string? NameSetId, string? CountryId,
string? NativeLanguage, string? NativeLanguage,
int? Seed); int? Seed);
+1 -1
View File
@@ -16,7 +16,7 @@ internal abstract record GameCommand
DateTime StartDate, DateTime StartDate,
IReadOnlyList<string>? ExtraModIds, IReadOnlyList<string>? ExtraModIds,
MapLayout? Map, MapLayout? Map,
string? NameSetId, string? CountryId,
string? NativeLanguage, string? NativeLanguage,
int? Seed, int? Seed,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand; TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
+17 -19
View File
@@ -357,20 +357,14 @@ internal sealed class GameLoopService(
return; return;
} }
var nameSetId = ResolveNameSetId(catalog, command.NameSetId); var countryId = ResolveCountryId(catalog, command.CountryId);
if (nameSetId is null) 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; return;
} }
if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(country.Names, command.NativeLanguage))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet));
return;
}
if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(names, command.NativeLanguage))
{ {
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage)); command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage));
return; return;
@@ -379,9 +373,10 @@ internal sealed class GameLoopService(
var id = _nextId++; var id = _nextId++;
store.WriteNextId(_nextId); store.WriteNextId(_nextId);
var seed = command.Seed ?? Random.Shared.Next(); 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); Track(worker);
worker.Start(); worker.Start();
@@ -548,7 +543,8 @@ internal sealed class GameLoopService(
isNew: false, isNew: false,
save.ModIds, save.ModIds,
save.Map, save.Map,
save.NameSetId, save.CountryId,
save.ClimatePresetId,
save.NativeLanguage, save.NativeLanguage,
createSeed: null, createSeed: null,
save.Presence); save.Presence);
@@ -598,7 +594,8 @@ internal sealed class GameLoopService(
bool isNew, bool isNew,
IReadOnlyList<string>? modIds, IReadOnlyList<string>? modIds,
MapLayout? map, MapLayout? map,
string? nameSetId, string? countryId,
string? climatePresetId,
string? nativeLanguage, string? nativeLanguage,
int? createSeed = null, int? createSeed = null,
IReadOnlyList<PresenceSnapshot>? presence = null) => IReadOnlyList<PresenceSnapshot>? presence = null) =>
@@ -611,7 +608,8 @@ internal sealed class GameLoopService(
isNew, isNew,
modIds, modIds,
map, map,
nameSetId, countryId,
climatePresetId,
nativeLanguage, nativeLanguage,
createSeed, createSeed,
presence, presence,
@@ -624,12 +622,12 @@ internal sealed class GameLoopService(
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}")); loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
/// <summary> /// <summary>
/// Empty request uses the first placeable set (core's Slavic). A named id must exist in the /// Empty request uses the first placeable country (core's Russia). A named id must exist in
/// catalog already loaded for this pack list — unknown extras were rejected above. /// the catalog already loaded for this pack list — unknown extras were rejected above.
/// </summary> /// </summary>
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) .Where(def => !def.Abstract)
.Select(def => def.DefName) .Select(def => def.DefName)
.OrderBy(name => name, StringComparer.Ordinal) .OrderBy(name => name, StringComparer.Ordinal)
+41 -5
View File
@@ -26,6 +26,11 @@ internal sealed class SchoolSave
public MapLayout? Map { get; init; } public MapLayout? Map { get; init; }
public string? CountryId { get; init; }
public string? ClimatePresetId { get; init; }
/// <summary>Legacy field. Format 2 and older; mapped to <see cref="CountryId"/> on load.</summary>
public string? NameSetId { get; init; } public string? NameSetId { get; init; }
public string? NativeLanguage { get; init; } public string? NativeLanguage { get; init; }
@@ -42,7 +47,7 @@ internal sealed record SchoolSaveIndex(int NextId);
/// </summary> /// </summary>
internal sealed class SchoolStore internal sealed class SchoolStore
{ {
public const int CurrentFormat = 2; public const int CurrentFormat = 3;
private const string IndexFileName = "index.json"; private const string IndexFileName = "index.json";
@@ -178,6 +183,8 @@ internal sealed class SchoolStore
SpeedIndex = save.SpeedIndex, SpeedIndex = save.SpeedIndex,
ModIds = save.ModIds, ModIds = save.ModIds,
Map = save.Map, Map = save.Map,
CountryId = save.CountryId,
ClimatePresetId = save.ClimatePresetId,
NameSetId = save.NameSetId, NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage, NativeLanguage = save.NativeLanguage,
Presence = save.Presence, Presence = save.Presence,
@@ -194,11 +201,40 @@ internal sealed class SchoolStore
} }
/// <summary> /// <summary>
/// Named upgrade seam for saves older than <see cref="CurrentFormat"/>. Empty on purpose: /// Format 2 stored a name set. Slavic becomes Russia; any other id is tried as a country
/// missing fields already default and extra fields are ignored. Put a migration here when a /// (the example pack kept its defName). Climate is filled on the worker from the country's
/// format bump actually needs one. /// first preset so the file is not rewritten until the school saves itself.
/// </summary> /// </summary>
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) public void Save(SchoolSave save)
{ {
+42 -37
View File
@@ -32,7 +32,8 @@ internal sealed class SchoolWorker
private readonly bool _isNew; private readonly bool _isNew;
private readonly IReadOnlyList<string>? _modIds; private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap; private readonly MapLayout? _savedMap;
private readonly string? _nameSetId; private readonly string? _countryId;
private string? _climatePresetId;
private string? _nativeLanguage; private string? _nativeLanguage;
private readonly int? _createSeed; private readonly int? _createSeed;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence; private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
@@ -66,7 +67,8 @@ internal sealed class SchoolWorker
bool isNew, bool isNew,
IReadOnlyList<string>? modIds, IReadOnlyList<string>? modIds,
MapLayout? savedMap, MapLayout? savedMap,
string? nameSetId, string? countryId,
string? climatePresetId,
string? nativeLanguage, string? nativeLanguage,
int? createSeed, int? createSeed,
IReadOnlyList<PresenceSnapshot>? savedPresence, IReadOnlyList<PresenceSnapshot>? savedPresence,
@@ -86,7 +88,8 @@ internal sealed class SchoolWorker
_isNew = isNew; _isNew = isNew;
_modIds = modIds; _modIds = modIds;
_savedMap = savedMap; _savedMap = savedMap;
_nameSetId = nameSetId; _countryId = countryId;
_climatePresetId = climatePresetId;
_nativeLanguage = nativeLanguage; _nativeLanguage = nativeLanguage;
_createSeed = createSeed; _createSeed = createSeed;
_savedPresence = savedPresence; _savedPresence = savedPresence;
@@ -857,12 +860,19 @@ internal sealed class SchoolWorker
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
{ {
var nameSetId = ResolveNameSetId(catalog, _nameSetId); var countryId = ResolveCountryId(catalog, _countryId);
if (nameSetId is null) 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); var demand = SchoolDemand.From(catalog, map);
Roster roster; Roster roster;
ApplicantPool applicants; ApplicantPool applicants;
@@ -878,10 +888,10 @@ internal sealed class SchoolWorker
} }
seed = createSeed; seed = createSeed;
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); native = ResolveNative(country, seed, _nativeLanguage, generating: true);
_nativeLanguage = native; _nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native);
generated = true; generated = true;
} }
else else
@@ -890,16 +900,16 @@ internal sealed class SchoolWorker
if (loaded is null) if (loaded is null)
{ {
seed = school.Id; seed = school.Id;
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); native = ResolveNative(country, seed, _nativeLanguage, generating: true);
_nativeLanguage = native; _nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native);
generated = true; generated = true;
} }
else else
{ {
seed = loaded.Seed; seed = loaded.Seed;
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false); native = ResolveNative(country, seed, _nativeLanguage, generating: false);
_nativeLanguage = native; _nativeLanguage = native;
roster = loaded.ToRoster(); roster = loaded.ToRoster();
if (loaded.Applicants is { Applicants.Count: > 0 }) if (loaded.Applicants is { Applicants.Count: > 0 })
@@ -908,7 +918,7 @@ internal sealed class SchoolWorker
} }
else 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; 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 {_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); InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence); school.RestorePresence(_savedPresence);
return generated; return generated;
} }
private static string? ResolveNameSetId(DefCatalog catalog, string? requested) private static string? ResolveCountryId(DefCatalog catalog, string? requested)
{ {
var available = catalog.NameSets.Values if (string.IsNullOrWhiteSpace(requested))
.Where(def => !def.Abstract)
.Select(def => def.DefName)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
if (available.Length == 0)
{ {
return null; 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( private static string? ResolveNative(
DefCatalog catalog, CountryDef country,
string nameSetId,
int schoolSeed, int schoolSeed,
string? requested, string? requested,
bool generating) bool generating) =>
{ NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
return null;
}
return NativeLanguages.Pick(names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
}
private void Persist() private void Persist()
{ {
@@ -983,7 +987,8 @@ internal sealed class SchoolWorker
SpeedIndex = school.Clock.SpeedIndex, SpeedIndex = school.Clock.SpeedIndex,
ModIds = school.Catalog?.PackIds, ModIds = school.Catalog?.PackIds,
Map = school.Map, Map = school.Map,
NameSetId = _nameSetId, CountryId = school.CountryId,
ClimatePresetId = school.ClimatePresetId,
NativeLanguage = _nativeLanguage, NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(), Presence = school.CapturePresence(),
}); });
@@ -0,0 +1,3 @@
{
"defName": "ContinentalCold",
}
@@ -0,0 +1,3 @@
{
"defName": "TemperateContinental",
}
@@ -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" },
],
},
}
@@ -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" },
],
}
@@ -144,7 +144,9 @@
"Hunger": "Hunger", "Hunger": "Hunger",
"Toilet": "Toilet", "Toilet": "Toilet",
"Social": "Social", "Social": "Social",
"Slavic": "Slavic", "Russia": "Russia",
"TemperateContinental": "Temperate continental",
"ContinentalCold": "Cold continental",
"Build": "Build", "Build": "Build",
"Skinny": "Skinny", "Skinny": "Skinny",
"Average": "Average", "Average": "Average",
@@ -144,7 +144,9 @@
"Hunger": "Голод", "Hunger": "Голод",
"Toilet": "Туалет", "Toilet": "Туалет",
"Social": "Общение", "Social": "Общение",
"Slavic": "Славянский", "Russia": "Россия",
"TemperateContinental": "Умеренно-континентальный",
"ContinentalCold": "Континентальный холодный",
"Build": "Телосложение", "Build": "Телосложение",
"Skinny": "Худощавое", "Skinny": "Худощавое",
"Average": "Обычное", "Average": "Обычное",
+1 -1
View File
@@ -10,7 +10,7 @@ example/
pack.jsonc # version + requires: ["core"] pack.jsonc # version + requires: ["core"]
localizations/{ru,en}.jsonc localizations/{ru,en}.jsonc
defs/traits/traits.jsonc # two traits defs/traits/traits.jsonc # two traits
defs/namesets/example.jsonc defs/countries/example.jsonc
defs/rooms/store.jsonc # one placeable room defs/rooms/store.jsonc # one placeable room
defs/things/chair.jsonc # last-wins: same defName as core defs/things/chair.jsonc # last-wins: same defName as core
patches/principals-office.jsonc patches/principals-office.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": "Тестова" },
],
},
}
@@ -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": "Тестова" },
],
}
+12 -8
View File
@@ -75,8 +75,11 @@ public sealed class School : IDisposable
public int PeopleSeed { get; private set; } public int PeopleSeed { get; private set; }
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary> /// <summary>Country used to generate this school's people. Needed again on 1 September.</summary>
public string? NameSetId { get; private set; } public string? CountryId { get; private set; }
/// <summary>Climate preset rolled at birth. Phase 32 reads it; it cannot change on a live school.</summary>
public string? ClimatePresetId { get; private set; }
/// <summary>Skill everyone generated for this school speaks natively.</summary> /// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; } public string? NativeLanguage { get; private set; }
@@ -118,14 +121,15 @@ public sealed class School : IDisposable
/// <summary> /// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary> /// </summary>
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); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(roster); ArgumentNullException.ThrowIfNull(roster);
Roster = roster; Roster = roster;
PeopleSeed = seed; PeopleSeed = seed;
NameSetId = nameSetId; CountryId = countryId;
ClimatePresetId = climatePresetId;
NativeLanguage = nativeLanguage; NativeLanguage = nativeLanguage;
Applicants = applicants; Applicants = applicants;
RosterSpawner.Spawn(World, roster); RosterSpawner.Spawn(World, roster);
@@ -282,7 +286,7 @@ public sealed class School : IDisposable
private bool TryYearlyIntake(DateTime before, DateTime after) 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; return false;
} }
@@ -290,7 +294,7 @@ public sealed class School : IDisposable
var changed = false; var changed = false;
foreach (var date in YearlyIntake.DatesBetween(before, after)) 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; changed = true;
} }
@@ -307,12 +311,12 @@ public sealed class School : IDisposable
private bool TryApplicantRefresh() 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; 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) if (next.Week == Applicants.Week)
{ {
return false; return false;
@@ -13,7 +13,7 @@ public enum SchoolCreationError
InvalidMap, InvalidMap,
UnknownMod, UnknownMod,
InvalidCatalog, InvalidCatalog,
UnknownNameSet, UnknownCountry,
UnknownNativeLanguage, UnknownNativeLanguage,
MissingMod, MissingMod,
ModCycle, ModCycle,
+3 -3
View File
@@ -68,7 +68,7 @@ public class WalkingTests
public void ComesToday_IsFalseOnSundayAndHolidays() public void ComesToday_IsFalseOnSundayAndHolidays()
{ {
var (catalog, map) = Fixtures.Vanilla(); 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 pupil = roster.People.First(person => person.IsStudent);
var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId); var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId);
var table = new Timetable( var table = new Timetable(
@@ -90,7 +90,7 @@ public class WalkingTests
public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes() public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes()
{ {
var (catalog, map) = Fixtures.Vanilla(); 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 idle = roster.Classes[0];
var busy = roster.Classes[1]; var busy = roster.Classes[1];
var idlePupil = roster.People.First(person => person.Id == idle.PupilIds[0]); var idlePupil = roster.People.First(person => person.Id == idle.PupilIds[0]);
@@ -117,7 +117,7 @@ public class WalkingTests
{ {
var (catalog, map) = Fixtures.Vanilla(); var (catalog, map) = Fixtures.Vanilla();
var walks = WalkGraph.Build(catalog, map); 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 pupil = roster.People.First(person => person.IsStudent);
var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId); var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId);
var table = new Timetable( var table = new Timetable(
@@ -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 == "PrincipalsOffice").Label);
Assert.Equal("Кладовая", Assert.Single(ru.Rooms, room => room.DefName == "ExampleStore").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("Storeroom", Assert.Single(en.Rooms, room => room.DefName == "ExampleStore").Label);
Assert.Equal("Примерные имена", Assert.Single(ru.NameSets, set => set.DefName == "ExampleNames").Label); Assert.Equal("Примерные имена", Assert.Single(ru.Countries, set => set.DefName == "ExampleNames").Label);
Assert.Equal("Example names", Assert.Single(en.NameSets, 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); Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
} }
@@ -139,14 +139,14 @@ public class ExamplePackTests(AppHostFixture fixture)
IReadOnlyList<DefInfoResponse> Floors, IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms, IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things, IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<NameSetInfoResponse> NameSets, IReadOnlyList<CountryInfoResponse> Countries,
DayFrameResponse? DayFrame, DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays, IReadOnlyList<HolidayInfoResponse> Holidays,
MapLayoutResponse DefaultMap); MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label); 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( private sealed record RoomInfoResponse(
string DefName, string DefName,
+88 -13
View File
@@ -278,13 +278,14 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label); 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("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id); Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label); Assert.Equal("Россия", Assert.Single(ru.Countries, country => country.DefName == "Russia").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label); Assert.Equal("Russia", Assert.Single(en.Countries, country => country.DefName == "Russia").Label);
var slavic = Assert.Single(ru.NameSets, set => set.DefName == "Slavic"); var russia = Assert.Single(ru.Countries, country => country.DefName == "Russia");
Assert.Equal( Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
slavic.NativeLanguages.Select(language => language.DefName).ToArray()); russia.NativeLanguages.Select(language => language.DefName).ToArray());
Assert.Equal("Белорусский", Assert.Single(slavic.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label); 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("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label);
Assert.Equal("Primary", Assert.Single(en.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"); var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom");
@@ -396,7 +397,7 @@ public class SchoolApiTests(AppHostFixture fixture)
} }
[Fact] [Fact]
public async Task CreateSchool_WithAnUnknownNameSet_IsRejected() public async Task CreateSchool_WithAnUnknownCountry_IsRejected()
{ {
using var client = fixture.App.CreateHttpClient("server"); using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client); await ResetAsync(client);
@@ -405,14 +406,47 @@ public class SchoolApiTests(AppHostFixture fixture)
"/api/schools", "/api/schools",
new new
{ {
name = "Чужие имена", name = "Чужая страна",
startDate = ExpectedDefaultStart, startDate = ExpectedDefaultStart,
nameSetId = "NoSuchNames", countryId = "NoSuchCountry",
}, },
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 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] [Fact]
@@ -427,7 +461,7 @@ public class SchoolApiTests(AppHostFixture fixture)
{ {
name = "Чужой язык", name = "Чужой язык",
startDate = ExpectedDefaultStart, startDate = ExpectedDefaultStart,
nameSetId = "Slavic", countryId = "Russia",
nativeLanguage = "Klingon", nativeLanguage = "Klingon",
}, },
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
@@ -521,6 +555,46 @@ public class SchoolApiTests(AppHostFixture fixture)
return created; return created;
} }
private static async Task<SchoolResponse> 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<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static async Task<SchoolSaveFile> 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<SchoolSaveFile>(
json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(save);
return save;
}
private static async Task<string> SavesDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/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 internal static readonly object SimpleCustomMap = new
{ {
territory = new { id = "yard", def = "SchoolYard" }, territory = new { id = "yard", def = "SchoolYard" },
@@ -626,7 +700,7 @@ public class SchoolApiTests(AppHostFixture fixture)
IReadOnlyList<DefInfoResponse> Floors, IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms, IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things, IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<NameSetInfoResponse> NameSets, IReadOnlyList<CountryInfoResponse> Countries,
IReadOnlyList<SubjectInfoResponse> Subjects, IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame, DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays, IReadOnlyList<HolidayInfoResponse> Holidays,
@@ -634,10 +708,11 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record DefInfoResponse(string DefName, string Label); private sealed record DefInfoResponse(string DefName, string Label);
private sealed record NameSetInfoResponse( private sealed record CountryInfoResponse(
string DefName, string DefName,
string Label, string Label,
IReadOnlyList<DefInfoResponse> NativeLanguages); IReadOnlyList<DefInfoResponse> NativeLanguages,
IReadOnlyList<string> ClimatePresets);
private sealed record RoomInfoResponse( private sealed record RoomInfoResponse(
string DefName, string DefName,
@@ -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] [Fact]
public async Task CurrentFormatSave_LoadsAsBefore() public async Task CurrentFormatSave_LoadsAsBefore()
{ {
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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);
}
}
@@ -40,12 +40,12 @@ public class ExamplePackTests
Assert.True(catalog.Traits.ContainsKey("ExampleEarlyRiser")); Assert.True(catalog.Traits.ContainsKey("ExampleEarlyRiser"));
Assert.True(catalog.Traits.ContainsKey("ExampleNightOwl")); 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.True(catalog.Rooms.ContainsKey("ExampleStore"));
Assert.Equal("Кладовая", catalog.Label("ru", catalog.Rooms["ExampleStore"])); Assert.Equal("Кладовая", catalog.Label("ru", catalog.Rooms["ExampleStore"]));
Assert.Equal("Storeroom", catalog.Label("en", catalog.Rooms["ExampleStore"])); Assert.Equal("Storeroom", catalog.Label("en", catalog.Rooms["ExampleStore"]));
Assert.Equal("Примерные имена", catalog.Label("ru", catalog.NameSets["ExampleNames"])); Assert.Equal("Примерные имена", catalog.Label("ru", catalog.Countries["ExampleNames"]));
Assert.Equal(["RussianLanguage"], catalog.NameSets["ExampleNames"].Spoken.ToArray()); Assert.Equal(["RussianLanguage"], catalog.Countries["ExampleNames"].Names.Spoken.ToArray());
Assert.True(catalog.Rooms.ContainsKey("PrincipalsOffice")); Assert.True(catalog.Rooms.ContainsKey("PrincipalsOffice"));
Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"])); Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"]));
} }
+31 -20
View File
@@ -25,15 +25,15 @@ public class PeopleDefTests
Assert.Equal(0.4f, catalog.Skills["English"].AdultChance); Assert.Equal(0.4f, catalog.Skills["English"].AdultChance);
Assert.Equal( Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
catalog.NameSets["Slavic"].Spoken); catalog.Countries["Russia"].Names.Spoken);
Assert.Equal(0.6f, catalog.NameSets["Slavic"].RelatedLanguageChance); Assert.Equal(0.6f, catalog.Countries["Russia"].Names.RelatedLanguageChance);
Assert.Equal(40, catalog.NameSets["Slavic"].RelatedLanguageMax); Assert.Equal(40, catalog.Countries["Russia"].Names.RelatedLanguageMax);
Assert.Contains(catalog.Subjects["ForeignLanguage"].Skills, share => share.Skill == "English"); Assert.Contains(catalog.Subjects["ForeignLanguage"].Skills, share => share.Skill == "English");
Assert.False(catalog.Skills.ContainsKey("ForeignLanguage")); Assert.False(catalog.Skills.ContainsKey("ForeignLanguage"));
Assert.True(catalog.NameSets.ContainsKey("Slavic")); Assert.True(catalog.Countries.ContainsKey("Russia"));
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20); Assert.True(catalog.Countries["Russia"].Names.MaleGiven.Count >= 20);
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"])); Assert.Equal("Россия", catalog.Label("ru", catalog.Countries["Russia"]));
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"])); Assert.Equal("Russia", catalog.Label("en", catalog.Countries["Russia"]));
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"])); Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.NotNull(catalog.StaffingRules); Assert.NotNull(catalog.StaffingRules);
@@ -213,7 +213,7 @@ public class PeopleDefTests
} }
[Fact] [Fact]
public void ExtraPack_AddsANameSetAndTrait() public void ExtraPack_AddsACountryAndTrait()
{ {
var catalog = _loader.Load( var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"], [CatalogLoader.CorePackId, "addon"],
@@ -221,37 +221,48 @@ public class PeopleDefTests
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "kind", """{ "defName": "Kind", "weight": 1 }"""), PackDocuments.Def(CatalogLoader.CorePackId, "traits", "kind", """{ "defName": "Kind", "weight": 1 }"""),
PackDocuments.Def( PackDocuments.Def(
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"namesets", "climates",
"slavic", "temperate",
"""{ "defName": "TemperateContinental" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"countries",
"russia",
""" """
{ {
"defName": "Slavic", "defName": "Russia",
"maleGiven": [{ "form": "Иван" }], "climatePresets": ["TemperateContinental"],
"femaleGiven": [{ "form": "Анна", "declension": "a" }], "names": {
"surnames": [{ "male": "Иванов", "female": "Иванова" }] "maleGiven": [{ "form": "Иван" }],
"femaleGiven": [{ "form": "Анна", "declension": "a" }],
"surnames": [{ "male": "Иванов", "female": "Иванова" }]
}
} }
"""), """),
PackDocuments.Def("addon", "traits", "stoic", """{ "defName": "Stoic", "weight": 3 }"""), PackDocuments.Def("addon", "traits", "stoic", """{ "defName": "Stoic", "weight": 3 }"""),
PackDocuments.Def("addon", "traits", "cheerful", """{ "defName": "Cheerful", "weight": 2 }"""), PackDocuments.Def("addon", "traits", "cheerful", """{ "defName": "Cheerful", "weight": 2 }"""),
PackDocuments.Def( PackDocuments.Def(
"addon", "addon",
"namesets", "countries",
"nordic", "nordic",
""" """
{ {
"defName": "Nordic", "defName": "Nordic",
"maleGiven": [{ "form": "Lars", "declension": "indeclinable" }], "climatePresets": ["TemperateContinental"],
"femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }], "names": {
"surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }] "maleGiven": [{ "form": "Lars", "declension": "indeclinable" }],
"femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }],
"surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }]
}
} }
"""), """),
PackDocuments.Locale("addon", "ru", """{ "Nordic": "Северный", "Stoic": "Стоик", "Cheerful": "Жизнерадостный" }"""), 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("Stoic"));
Assert.True(catalog.Traits.ContainsKey("Cheerful")); 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"])); Assert.Equal("Стоик", catalog.Label("ru", catalog.Traits["Stoic"]));
} }
} }
@@ -128,7 +128,8 @@ public class VanillaCoreTests
keys.AddRange(Names(catalog.Traits.Values)); keys.AddRange(Names(catalog.Traits.Values));
keys.AddRange(Names(catalog.BodyAttributes.Values)); keys.AddRange(Names(catalog.BodyAttributes.Values));
keys.AddRange(Names(catalog.Needs.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.Subjects.Values));
keys.AddRange(Names(catalog.Staffing.Values)); keys.AddRange(Names(catalog.Staffing.Values));
keys.AddRange(Names(catalog.DayFrames.Values)); keys.AddRange(Names(catalog.DayFrames.Values));
@@ -17,7 +17,7 @@ public class ApplicantPoolTests
{ {
var roster = Fixtures.Generate(Fixtures.VanillaMap()); var roster = Fixtures.Generate(Fixtures.VanillaMap());
var first = Create(roster); 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); Assert.NotEqual(first.Week, next.Week);
var stayed = first.Applicants var stayed = first.Applicants
@@ -45,12 +45,12 @@ public class ApplicantPoolTests
var roster = Fixtures.Generate(Fixtures.VanillaMap()); var roster = Fixtures.Generate(Fixtures.VanillaMap());
var size = catalog.StaffingRules!.PoolSize; var size = catalog.StaffingRules!.PoolSize;
var asOf = Fixtures.AsOf; 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++) for (var week = 0; week < 50; week++)
{ {
asOf = asOf.AddDays(7); 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); Assert.Equal(size, pool.Applicants.Count);
} }
} }
@@ -94,7 +94,7 @@ public class ApplicantPoolTests
for (var seed = 1; seed <= 20 && pool is null; seed++) for (var seed = 1; seed <= 20 && pool is null; seed++)
{ {
var candidate = Fixtures.Generate(Fixtures.VanillaMap(), 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))) if (created.Applicants.Any(applicant => candidate.People.Any(person => person.Id == applicant.Person.Id)))
{ {
roster = candidate; roster = candidate;
@@ -132,7 +132,7 @@ public class ApplicantPoolTests
} }
private static ApplicantPool Create(Roster roster) => 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) => private static string Snapshot(ApplicantPool pool) =>
string.Join('\n', pool.Applicants.Select(applicant => string.Join('\n', pool.Applicants.Select(applicant =>
+1 -1
View File
@@ -96,7 +96,7 @@ internal static class Fixtures
} }
public static Roster Generate(MapLayout map, int seed = SchoolSeed) => 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() public static string RepoRoot()
{ {
@@ -8,7 +8,7 @@ public class GoldenRosterTests
public void GoldenRoster_MatchesFixture() public void GoldenRoster_MatchesFixture()
{ {
var roster = Fixtures.Generate(Fixtures.Classrooms(4)); 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 relative = Path.Combine("golden", "roster.txt");
var output = Path.Combine(AppContext.BaseDirectory, relative); var output = Path.Combine(AppContext.BaseDirectory, relative);
var source = Path.Combine(Fixtures.RepoRoot(), "tests", "HSchool.People.Tests", relative); var source = Path.Combine(Fixtures.RepoRoot(), "tests", "HSchool.People.Tests", relative);
+2 -2
View File
@@ -21,8 +21,8 @@ public class GoldenSaveDump
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var map = TwoClassrooms(); var map = TwoClassrooms();
var roster = RosterGenerator.Generate(catalog, map, 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, "Slavic", 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 people = RosterJson.Serialize(RosterDocument.From(1, roster, pool));
var mapNode = JsonNode.Parse(JsonSerializer.Serialize(map, Jsonc.SerializerOptions)); var mapNode = JsonNode.Parse(JsonSerializer.Serialize(map, Jsonc.SerializerOptions));
var current = SchoolJson(mapNode, includeNative: true); var current = SchoolJson(mapNode, includeNative: true);
+4 -4
View File
@@ -69,7 +69,7 @@ public class ReviewFixTests
for (var year = 0; year < 4; year++) for (var year = 0; year < 4; year++)
{ {
var before = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); 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); date = date.AddYears(1);
foreach (var person in roster.People) foreach (var person in roster.People)
@@ -98,7 +98,7 @@ public class ReviewFixTests
catalog, catalog,
roster, roster,
Fixtures.SchoolSeed, Fixtures.SchoolSeed,
"Slavic", "Russia",
new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); 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 // 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() public void AChildOfASingleMother_StillCarriesTheFathersSurnameAndPatronymic()
{ {
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var names = catalog.NameSets["Slavic"]; var names = catalog.Countries["Russia"].Names;
var roster = Fixtures.Generate(Fixtures.Classrooms(11)); var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
@@ -180,7 +180,7 @@ public class ReviewFixTests
public void ASingleMotherFamily_CanStillTakeInAYoungerSibling() public void ASingleMotherFamily_CanStillTakeInAYoungerSibling()
{ {
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var names = catalog.NameSets["Slavic"]; var names = catalog.Countries["Russia"].Names;
var roster = Fixtures.Generate(Fixtures.Classrooms(11)); var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var motherOnly = roster.Families var motherOnly = roster.Families
@@ -6,11 +6,11 @@ namespace HSchool.People.Tests;
/// </summary> /// </summary>
internal static class RosterFingerprint 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<string> var lines = new List<string>
{ {
$"# 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)) foreach (var schoolClass in roster.Classes.OrderBy(row => row.Id, StringComparer.Ordinal))
@@ -3,7 +3,7 @@ namespace HSchool.People.Tests;
public class RosterGeneratorTests public class RosterGeneratorTests
{ {
[Fact] [Fact]
public void SameSeedMapAndNameSet_YieldTheSameRoster() public void SameSeedMapAndCountry_YieldTheSameRoster()
{ {
var map = Fixtures.Classrooms(4); var map = Fixtures.Classrooms(4);
var a = Fixtures.Generate(map); var a = Fixtures.Generate(map);
@@ -70,11 +70,11 @@ public class SkillGrantTests
catalog, catalog,
Fixtures.Classrooms(11), Fixtures.Classrooms(11),
Fixtures.SchoolSeed, Fixtures.SchoolSeed,
"Slavic", "Russia",
Fixtures.AsOf, Fixtures.AsOf,
"BelarusianLanguage"); "BelarusianLanguage");
var max = catalog.NameSets["Slavic"].RelatedLanguageMax; var max = catalog.Countries["Russia"].Names.RelatedLanguageMax;
Assert.All( Assert.All(
roster.People, roster.People,
person => person =>
@@ -97,7 +97,7 @@ public class SkillGrantTests
public void RelatedLanguages_AreCommonAndStayLow() public void RelatedLanguages_AreCommonAndStayLow()
{ {
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var max = catalog.NameSets["Slavic"].RelatedLanguageMax; var max = catalog.Countries["Russia"].Names.RelatedLanguageMax;
var roster = Fixtures.Generate(Fixtures.Classrooms(11)); var roster = Fixtures.Generate(Fixtures.Classrooms(11));
Assert.Contains( Assert.Contains(
roster.People, roster.People,
@@ -138,7 +138,7 @@ public class SkillGrantTests
[Fact] [Fact]
public void ReloadWithoutASavedPick_TakesTheFirstLanguageInsteadOfRolling() 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"); 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 }) foreach (var seed in new[] { 1, 2, 7, 20260818 })
@@ -164,8 +164,8 @@ public class SkillGrantTests
public void OmittedNativeLanguage_IsStableForTheSameSeed() public void OmittedNativeLanguage_IsStableForTheSameSeed()
{ {
var map = Fixtures.Classrooms(2); var map = Fixtures.Classrooms(2);
var a = 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, "Slavic", Fixtures.AsOf); var b = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Russia", Fixtures.AsOf);
Assert.Equal( Assert.Equal(
a.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))), a.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))),
b.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, catalog,
before, before,
Fixtures.SchoolSeed, Fixtures.SchoolSeed,
"Slavic", "Russia",
new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc));
var grown = after.People.First(person => person.Id == pupil.Id); var grown = after.People.First(person => person.Id == pupil.Id);
Assert.True(grown.ClassId is { } classId && after.Classes.Any(schoolClass => Assert.True(grown.ClassId is { } classId && after.Classes.Any(schoolClass =>
+2 -2
View File
@@ -178,7 +178,7 @@ public class StaffingTests
for (var seed = 1; seed <= 30 && pool is null; seed++) for (var seed = 1; seed <= 30 && pool is null; seed++)
{ {
var candidate = Fixtures.Generate(map, 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 rosterIds = candidate.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id)); var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id));
var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a')); var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a'));
@@ -292,7 +292,7 @@ public class StaffingTests
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var map = Fixtures.VanillaMap(); var map = Fixtures.VanillaMap();
var roster = Fixtures.Generate(map); 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); return (catalog, map, roster, pool);
} }
@@ -23,7 +23,7 @@ public class YearlyIntakeTests
Assert.Equal(4, before.Classes.Max(schoolClass => schoolClass.Year)); Assert.Equal(4, before.Classes.Max(schoolClass => schoolClass.Year));
Assert.DoesNotContain(before.Classes, schoolClass => schoolClass.Year == 11); 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); var demand = SchoolDemand.From(catalog, map);
Assert.True(RosterFit.Matches(after, demand)); Assert.True(RosterFit.Matches(after, demand));
@@ -46,8 +46,8 @@ public class YearlyIntakeTests
var map = Fixtures.Classrooms(4); var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map); var before = Fixtures.Generate(map);
var a = 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, "Slavic", September)); var b = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September));
Assert.Equal(a, b); Assert.Equal(a, b);
} }
@@ -57,7 +57,7 @@ public class YearlyIntakeTests
var map = Fixtures.Classrooms(4); var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog(); var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map); 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 people = after.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var yearOf = after.Classes.ToDictionary(schoolClass => schoolClass.Id, schoolClass => schoolClass.Year, 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); var afterIds = after.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
Assert.NotEmpty(shouldLeave); Assert.NotEmpty(shouldLeave);
Assert.All(shouldLeave, id => Assert.DoesNotContain(id, afterIds)); Assert.All(shouldLeave, id => Assert.DoesNotContain(id, afterIds));
+1 -1
View File
@@ -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-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-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 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
@@ -172,12 +172,12 @@ public class DecisionTests
private static (School School, string Homeroom, string PupilId) TwoHomeroomLessons() private static (School School, string Homeroom, string PupilId) TwoHomeroomLessons()
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayMorning); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", TuesdayMorning); var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
var schoolClass = roster.Classes.First(row => var schoolClass = roster.Classes.First(row =>
row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
var school = School.Create(1, "Два урока", TuesdayMorning, catalog, map); 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( school.SetTimetable(new Timetable(
[ [
new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), 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) private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed)
{ {
var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", TuesdayMorning); var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", TuesdayMorning);
var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", TuesdayMorning); var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", TuesdayMorning);
var schoolClass = roster.Classes.First(row => var schoolClass = roster.Classes.First(row =>
row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
var school = School.Create(seed, "Решения", TuesdayMorning, catalog, map); var school = School.Create(seed, "Решения", TuesdayMorning, catalog, map);
school.InstallPeople(roster, seed, "Slavic", pool); school.InstallPeople(roster, seed, "Russia", pool);
school.SetTimetable(new Timetable( school.SetTimetable(new Timetable(
[ [
new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
+3 -3
View File
@@ -93,8 +93,8 @@ public class LunchTests
private static School StaffedSchool() private static School StaffedSchool()
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Tuesday6); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Tuesday6);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", 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)) 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); 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.SetTimetable(SchoolTimetables.Build(catalog, map, roster, null, weekDays: 5));
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64);
return school; return school;
@@ -14,11 +14,11 @@ public class PeopleInSchoolTests
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var demand = SchoolDemand.From(catalog, map); var demand = SchoolDemand.From(catalog, map);
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Start); var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", Start);
using var school = School.Create(1, "Полная", Start, catalog, map); 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); school.Tick(1d / 20d, 5d);
Assert.Same(roster, school.Roster); 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 start = new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start); var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
using var school = School.Create(1, "Неделя", start, catalog, map); 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 monday = new DateTime(2012, 4, 2, 6, 0, 0, DateTimeKind.Utc);
var changed = school.Tick((monday - start).TotalMinutes / 5d, 5d); var changed = school.Tick((monday - start).TotalMinutes / 5d, 5d);
@@ -60,7 +60,7 @@ public class PeopleInSchoolTests
public void OffCampusNeeds_DoNotMoveOnTick() public void OffCampusNeeds_DoNotMoveOnTick()
{ {
var (catalog, map) = Vanilla(); 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); using var school = School.Create(2, "Нужды", Start, catalog, map);
school.InstallPeople(roster, seed: 2); school.InstallPeople(roster, seed: 2);
@@ -108,8 +108,8 @@ public class PeopleInSchoolTests
public void SameSeed_InstallsTheSameNames() public void SameSeed_InstallsTheSameNames()
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var first = 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, "Slavic", Start); var second = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Russia", Start);
using var a = School.Create(9, "А", Start, catalog, map); using var a = School.Create(9, "А", Start, catalog, map);
using var b = 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 catalog = Catalog();
var map = Classrooms(4); 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 demand = SchoolDemand.From(catalog, map);
var oldest = roster.Classes.Max(schoolClass => schoolClass.Year); var oldest = roster.Classes.Max(schoolClass => schoolClass.Year);
var graduated = roster.Classes var graduated = roster.Classes
@@ -135,7 +135,7 @@ public class PeopleInSchoolTests
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
using var school = School.Create(1, "Четыре кабинета", Start, catalog, map); 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 sept = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc);
var changed = school.Tick((sept - Start).TotalMinutes / 5d, 5d); 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 start = new DateTime(2012, 9, 1, 6, 0, 0, DateTimeKind.Utc);
var catalog = Catalog(); var catalog = Catalog();
var map = Classrooms(4); 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); 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 years = roster.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year).ToArray();
var changed = school.Tick(1d / 20d, 5d); var changed = school.Tick(1d / 20d, 5d);
+13 -13
View File
@@ -30,15 +30,15 @@ public class PresenceTests
public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes() public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes()
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayMorning); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", TuesdayMorning); var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
var idle = roster.Classes[0]; var idle = roster.Classes[0];
var busy = roster.Classes[1]; var busy = roster.Classes[1];
var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, Cap); var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, hired.Error); Assert.Equal(StaffingError.None, hired.Error);
using var school = School.Create(1, "Два класса", TuesdayMorning, catalog, map); 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( school.SetTimetable(new Timetable(
[new LessonPlacement(busy.Id, "Mathematics", hired.Roster.People.First(person => person.IsStaff).Id, busy.RoomId, Day: 1, Period: 1)], [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)); 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); 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.SetTimetable(live.Timetable!);
loaded.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); loaded.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
loaded.RestorePresence(snapshot); loaded.RestorePresence(snapshot);
@@ -359,12 +359,12 @@ public class PresenceTests
private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed) private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed)
{ {
var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", TuesdayMorning); var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", TuesdayMorning);
var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", TuesdayMorning); var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", TuesdayMorning);
var schoolClass = roster.Classes.First(row => var schoolClass = roster.Classes.First(row =>
row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
var school = School.Create(seed, "Присутствие", TuesdayMorning, catalog, map); var school = School.Create(seed, "Присутствие", TuesdayMorning, catalog, map);
school.InstallPeople(roster, seed, "Slavic", pool); school.InstallPeople(roster, seed, "Russia", pool);
school.SetTimetable(new Timetable( school.SetTimetable(new Timetable(
[ [
new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
@@ -377,11 +377,11 @@ public class PresenceTests
private static School Open(DefCatalog catalog, MapLayout map, DateTime start, int seed) private static School Open(DefCatalog catalog, MapLayout map, DateTime start, int seed)
{ {
var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", start); var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", start); var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start);
var schoolClass = roster.Classes[0]; var schoolClass = roster.Classes[0];
var school = School.Create(seed, "Присутствие", start, catalog, map); var school = School.Create(seed, "Присутствие", start, catalog, map);
school.InstallPeople(roster, seed, "Slavic", pool); school.InstallPeople(roster, seed, "Russia", pool);
school.SetTimetable(new Timetable( school.SetTimetable(new Timetable(
[new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 0, Period: 1)], [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 0, Period: 1)],
[])); []));
@@ -392,10 +392,10 @@ public class PresenceTests
private static School OpenEmpty(DateTime start) private static School OpenEmpty(DateTime start)
{ {
var (catalog, map) = Vanilla(); var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start); var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start); var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
var school = School.Create(1, "Пустая", start, catalog, map); 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); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
return school; return school;
} }