Replace selectable name sets with a country that owns names and climate presets.

Create picks a country; climate is rolled from the school seed and stored. Old Slavic saves lift as Russia.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 02:55:13 +03:00
co-authored by Cursor
parent 450495f666
commit 1b66c89cd7
58 changed files with 902 additions and 477 deletions
+6 -6
View File
@@ -43,12 +43,12 @@ const ru = {
errorMissingMod: 'Не выбран обязательный мод «{id}».',
errorModCycle: 'Выбранные моды зависят друг от друга по кругу.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
errorUnknownCountry: 'Выбранная страна не найдена.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды',
nameSetTitle: 'Набор имён',
countryTitle: 'Страна',
nativeLanguageTitle: 'Родной язык',
nativeLanguageRandom: 'Случайный',
randomNativeTitle: 'Выбрать язык наугад',
@@ -260,12 +260,12 @@ const en: Messages = {
errorMissingMod: 'Required mod “{id}” is not selected.',
errorModCycle: 'The selected mods depend on each other in a cycle.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that name set.',
errorUnknownCountry: 'The selected country is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that country.',
catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods',
nameSetTitle: 'Name set',
countryTitle: 'Country',
nativeLanguageTitle: 'Native language',
nativeLanguageRandom: 'Random',
randomNativeTitle: 'Pick a language at random',
+5 -4
View File
@@ -53,7 +53,7 @@ export async function fetchRandomName(lang: string): Promise<string> {
export interface CreateSchoolOptions {
readonly modIds?: readonly string[];
readonly map?: MapLayout;
readonly nameSetId?: string;
readonly countryId?: string;
readonly nativeLanguage?: string;
readonly seed?: number;
}
@@ -71,7 +71,7 @@ export async function createSchool(
startDate: startDate.toISOString(),
modIds: extras.modIds ?? [],
map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null,
countryId: extras.countryId ?? null,
nativeLanguage: extras.nativeLanguage ?? null,
seed: extras.seed ?? null,
}),
@@ -92,10 +92,11 @@ export interface DefInfo {
readonly pupilSlots?: number;
}
export interface NameSetInfo {
export interface CountryInfo {
readonly defName: string;
readonly label: string;
readonly nativeLanguages: readonly DefInfo[];
readonly climatePresets: readonly string[];
}
export interface RoomSlotInfo {
@@ -170,7 +171,7 @@ export interface CatalogResponse {
readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly NameSetInfo[];
readonly countries: readonly CountryInfo[];
readonly subjects: readonly SubjectInfo[];
readonly dayFrame: DayFrameInfo | null;
readonly holidays: readonly HolidayInfo[];
@@ -54,11 +54,22 @@ function catalog(): CatalogResponse {
],
things: [],
defaultMap,
nameSets: [
countries: [
{
defName: 'Russian',
label: 'Russian',
nativeLanguages: [{ defName: 'Russian', label: 'Russian' }],
defName: 'Russia',
label: 'Russia',
nativeLanguages: [
{ defName: 'RussianLanguage', label: 'Russian' },
{ defName: 'BelarusianLanguage', label: 'Belarusian' },
{ defName: 'UkrainianLanguage', label: 'Ukrainian' },
],
climatePresets: ['TemperateContinental'],
},
{
defName: 'Nordic',
label: 'Nordic',
nativeLanguages: [{ defName: 'NorwegianLanguage', label: 'Norwegian' }],
climatePresets: ['TemperateContinental'],
},
],
subjects: [],
@@ -162,6 +173,55 @@ describe('createSchoolDialog', () => {
finish(school());
await expect(opened).resolves.toMatchObject({ name: 'North' });
});
it('shows countries and refreshes languages when the country changes', async () => {
const opened = createSchoolDialog({
defaultStartDate: new Date('2024-09-01T00:00:00.000Z'),
suggestName: async () => 'North',
create: async () => school(),
});
await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled());
const dialog = document.querySelector('dialog');
if (dialog === null) {
throw new Error('create dialog is missing');
}
expect(byText(dialog, '.field__label', t('countryTitle'))).toBeTruthy();
expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === 'Name set')).toBe(false);
const countrySelect = dialog.querySelector('select');
if (!(countrySelect instanceof HTMLSelectElement)) {
throw new Error('country select is missing');
}
expect([...countrySelect.options].map((option) => option.value)).toEqual(['Russia', 'Nordic']);
const nativeField = byText(dialog, '.field__label', t('nativeLanguageTitle')).closest('.field');
if (!(nativeField instanceof HTMLElement)) {
throw new Error('native language field is missing');
}
expect(nativeField.hidden).toBe(false);
countrySelect.value = 'Nordic';
countrySelect.dispatchEvent(new Event('change'));
expect(nativeField.hidden).toBe(true);
countrySelect.value = 'Russia';
countrySelect.dispatchEvent(new Event('change'));
const nativeSelect = nativeField.querySelector('select');
if (!(nativeSelect instanceof HTMLSelectElement)) {
throw new Error('native language select is missing');
}
expect([...nativeSelect.options].map((option) => option.value)).toEqual([
'',
'RussianLanguage',
'BelarusianLanguage',
'UkrainianLanguage',
]);
expect(nativeField.hidden).toBe(false);
void opened;
});
});
describe('map reset from the create editor', () => {
+31 -31
View File
@@ -32,7 +32,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let nameSetId: string | null = null;
let countryId: string | null = null;
let nativeLanguageId: string | null = null;
const modsField = el('div', { class: 'field' });
@@ -40,11 +40,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const modsList = el('div', { class: 'mod-list' });
modsField.append(modsLabel, modsList);
const nameSetLabel = el('span', { class: 'field__label' });
const nameSetSelect = el('select', { class: 'input' });
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
nameSetSelect.addEventListener('change', () => {
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
const countryLabel = el('span', { class: 'field__label' });
const countrySelect = el('select', { class: 'input' });
const countryField = el('label', { class: 'field' }, countryLabel, countrySelect);
countrySelect.addEventListener('change', () => {
countryId = countrySelect.value === '' ? null : countrySelect.value;
nativeLanguageId = null;
if (catalog !== null) {
paintNativeLanguages(catalog);
@@ -110,7 +110,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
nameSetField,
countryField,
nativeField,
mapField,
seedField,
@@ -125,7 +125,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const waiting = value || catalog === null;
submitButton.toggleAttribute('disabled', waiting);
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);
seedInput.toggleAttribute('disabled', value);
};
@@ -149,31 +149,31 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const applyCatalog = (next: CatalogResponse): void => {
catalog = next;
currentMap = structuredClone(next.defaultMap);
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) {
const stillThere = next.countries.some((country) => country.defName === countryId);
countryId = stillThere ? countryId : (next.countries[0]?.defName ?? null);
if (!languagesOf(next, countryId).some((language) => language.defName === nativeLanguageId)) {
nativeLanguageId = null;
}
paintNameSets(next);
paintCountries(next);
paintNativeLanguages(next);
paintMapHint();
setBusy(busy);
};
const paintNameSets = (next: CatalogResponse): void => {
nameSetSelect.replaceChildren();
for (const set of next.nameSets) {
const option = el('option', { text: set.label });
option.value = set.defName;
nameSetSelect.append(option);
const paintCountries = (next: CatalogResponse): void => {
countrySelect.replaceChildren();
for (const country of next.countries) {
const option = el('option', { text: country.label });
option.value = country.defName;
countrySelect.append(option);
}
nameSetSelect.value = nameSetId ?? '';
nameSetSelect.disabled = next.nameSets.length <= 1;
countrySelect.value = countryId ?? '';
countrySelect.disabled = next.countries.length <= 1;
};
const paintNativeLanguages = (next: CatalogResponse): void => {
const languages = languagesOf(next, nameSetId);
const languages = languagesOf(next, countryId);
nativeSelect.replaceChildren();
const random = el('option', { text: t('nativeLanguageRandom') });
random.value = '';
@@ -187,7 +187,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nativeSelect.value = nativeLanguageId ?? '';
nativeSelect.disabled = 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> => {
@@ -242,7 +242,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const localize = (): void => {
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
nameSetLabel.textContent = t('nameSetTitle');
countryLabel.textContent = t('countryTitle');
nativeLabel.textContent = t('nativeLanguageTitle');
nativeRandomButton.textContent = t('randomName');
nativeRandomButton.title = t('randomNativeTitle');
@@ -260,7 +260,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
cancelButton.textContent = t('cancel');
paintMapHint();
if (catalog !== null) {
paintNameSets(catalog);
paintCountries(catalog);
paintNativeLanguages(catalog);
}
};
@@ -284,7 +284,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return;
}
const languages = languagesOf(catalog, nameSetId);
const languages = languagesOf(catalog, countryId);
if (languages.length === 0) {
return;
}
@@ -332,7 +332,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
.create(nameInput.value.trim(), startDate, {
modIds: [...extraModIds],
map: currentMap,
nameSetId: nameSetId ?? undefined,
countryId: countryId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined,
seed: seed ?? undefined,
})
@@ -349,12 +349,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return modal.open(nameInput);
}
function languagesOf(catalog: CatalogResponse | null, nameSetId: string | null): readonly { readonly defName: string; readonly label: string }[] {
if (catalog === null || nameSetId === null) {
function languagesOf(catalog: CatalogResponse | null, countryId: string | null): readonly { readonly defName: string; readonly label: string }[] {
if (catalog === null || countryId === null) {
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 {
@@ -401,8 +401,8 @@ function describe(reason: unknown): string {
return t('errorModCycle');
case 'invalid-catalog':
return t('errorInvalidCatalog');
case 'unknown-name-set':
return t('errorUnknownNameSet');
case 'unknown-country':
return t('errorUnknownCountry');
case 'unknown-native-language':
return t('errorUnknownNativeLanguage');
default: