Enhance internationalization support by integrating language selection for school names, updating UI components for localization, and revising documentation. Implement locale-aware formatting for dates and strings, ensuring a seamless user experience in both Russian and English. Update tests to cover new language features.
ci / server (push) Failing after 3m33s
ci / client (push) Successful in 15s

This commit is contained in:
Leonid Pershin
2026-08-18 12:43:14 +03:00
parent b9ddc018d3
commit cc9efca891
24 changed files with 640 additions and 140 deletions
+7 -5
View File
@@ -13,7 +13,7 @@ way; this file is *how to work in them*.
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, the loop | `src/HSchool.Server` | | connection handling, the loop | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| screens, dialogs, formatting | `src/HSchool.Client/src` | | screens, dialogs, formatting, UI language | `src/HSchool.Client/src` |
## Commands ## Commands
@@ -85,10 +85,11 @@ say so explicitly in the change description.
- `strict` is on, no `any`, no non-null `!` assertions. - `strict` is on, no `any`, no non-null `!` assertions.
- Relative imports carry the `.ts` extension (bundler resolution is configured for it). - Relative imports carry the `.ts` extension (bundler resolution is configured for it).
- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `main.ts` - Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `i18n/`
wires them together. holds the RU/EN dictionaries, `main.ts` wires them together.
- No framework, plain DOM. `ui/dom.ts` is the whole helper budget. - No framework, plain DOM. `ui/dom.ts` is the whole helper budget.
- UI strings are Russian; game dates are formatted through `format/gameTime.ts`, never inline. - UI strings go through `t(...)` in `i18n/strings.ts`, never inline. Game dates go through
`format/gameTime.ts`, which follows the active locale.
**Both** **Both**
@@ -105,7 +106,8 @@ say so explicitly in the change description.
- Those tests also share one server, so schools survive between them: start each test by clearing - Those tests also share one server, so schools survive between them: start each test by clearing
the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty. the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty.
- The screens have no unit tests — a DOM environment would cost a dependency the project does not - The screens have no unit tests — a DOM environment would cost a dependency the project does not
have. Verify UI changes by running the app. have. Verify UI changes by running the app. Dictionaries and date formatting are covered in
Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`).
## Dependencies ## Dependencies
+3 -2
View File
@@ -41,7 +41,8 @@ rather than guessing.
## What you can do ## What you can do
- **Main menu** — every school as a card with its name and its current in-game date and time, - **Main menu** — every school as a card with its name and its current in-game date and time,
ticking live. Deleting one asks for confirmation first. ticking live. Deleting one asks for confirmation first. The footer switches the UI between
Russian and English; the choice is remembered in the browser.
- **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00 - **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00
by default). At six schools the create button is disabled and says why. by default). At six schools the create button is disabled and says why.
- **Inside a school** — the date, time and weekday of the game calendar, play/pause and the - **Inside a school** — the date, time and weekday of the game calendar, play/pause and the
@@ -81,7 +82,7 @@ dotnet test
npm --prefix src/HSchool.Client test npm --prefix src/HSchool.Client test
``` ```
Vitest covers the client codec and the calendar formatting. Vitest covers the client codec, the calendar formatting and the RU/EN dictionaries.
## Layout ## Layout
+4
View File
@@ -44,6 +44,10 @@ Everything the main menu needs in one request.
`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken. `{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
Optional `?lang=en` draws from the English word list (`Northern Academy`); any other value, or
none, stays Russian. The client sends the active UI language. Names the player types are not
translated — they are saved as written.
### `POST /api/schools` ### `POST /api/schools`
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }` Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
+2 -2
View File
@@ -9,8 +9,8 @@
<body> <body>
<main id="app"></main> <main id="app"></main>
<footer id="status"> <footer id="status">
<span data-status="connection">подключение…</span> <span data-status="connection"></span>
<span data-status="ping">-- мс</span> <span data-status="ping"></span>
</footer> </footer>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+15 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { import {
formatGameDate, formatGameDate,
formatGameDateTime, formatGameDateTime,
@@ -10,6 +11,10 @@ import {
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday. // The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0)); const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
const initial = getLocale();
beforeEach(() => setLocale('ru'));
afterEach(() => setLocale(initial));
describe('game time formatting', () => { describe('game time formatting', () => {
it('shows the time of day in 24-hour form', () => { it('shows the time of day in 24-hour form', () => {
@@ -30,6 +35,15 @@ describe('game time formatting', () => {
expect(formatGameDateTime(START)).toContain('06:00'); expect(formatGameDateTime(START)).toContain('06:00');
}); });
it('formats the same instant in English when the locale is en', () => {
setLocale('en');
expect(formatGameWeekday(START)).toBe('Tuesday');
expect(formatGameDate(START)).toContain('April');
expect(formatGameDateTime(START)).toContain('03/04/2012');
expect(formatGameTimeOfDay(START)).toBe('06:00');
});
it('reads the calendar in UTC, so the school day does not shift with the viewer', () => { it('reads the calendar in UTC, so the school day does not shift with the viewer', () => {
// Just before midnight UTC: any local-time formatting would land on the 4th. // Just before midnight UTC: any local-time formatting would land on the 4th.
const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0)); const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0));
+40 -17
View File
@@ -6,26 +6,40 @@
* whatever offset they happen to live in. * whatever offset they happen to live in.
*/ */
const LOCALE = 'ru-RU'; import { intlTag, onLocaleChange } from '../i18n/locale.ts';
const UTC = 'UTC'; const UTC = 'UTC';
const dateFormat = new Intl.DateTimeFormat(LOCALE, { interface Formats {
readonly date: Intl.DateTimeFormat;
readonly time: Intl.DateTimeFormat;
readonly weekday: Intl.DateTimeFormat;
readonly short: Intl.DateTimeFormat;
}
let cached: { tag: string; formats: Formats } | null = null;
function formats(): Formats {
const tag = intlTag();
if (cached?.tag === tag) {
return cached.formats;
}
const next: Formats = {
date: new Intl.DateTimeFormat(tag, {
timeZone: UTC, timeZone: UTC,
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
}); }),
time: new Intl.DateTimeFormat(tag, {
const timeFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: UTC, timeZone: UTC,
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
}); }),
weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }),
const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' }); short: new Intl.DateTimeFormat(tag, {
const shortFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: UTC, timeZone: UTC,
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
@@ -33,26 +47,35 @@ const shortFormat = new Intl.DateTimeFormat(LOCALE, {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
}),
};
cached = { tag, formats: next };
return next;
}
onLocaleChange(() => {
cached = null;
}); });
/** "3 апреля 2012 г." */ /** "3 апреля 2012 г." / "3 April 2012" */
export function formatGameDate(date: Date): string { export function formatGameDate(date: Date): string {
return dateFormat.format(date); return formats().date.format(date);
} }
/** "06:00" */ /** "06:00" */
export function formatGameTimeOfDay(date: Date): string { export function formatGameTimeOfDay(date: Date): string {
return timeFormat.format(date); return formats().time.format(date);
} }
/** "вторник" */ /** "вторник" / "Tuesday" */
export function formatGameWeekday(date: Date): string { export function formatGameWeekday(date: Date): string {
return weekdayFormat.format(date); return formats().weekday.format(date);
} }
/** "03.04.2012, 06:00" — the compact form the school cards use. */ /** Compact form the school cards use — day-month-year in both languages. */
export function formatGameDateTime(date: Date): string { export function formatGameDateTime(date: Date): string {
return shortFormat.format(date); return formats().short.format(date);
} }
/** Splits an ISO instant into the `<input type="date">` and `<input type="time">` values it needs. */ /** Splits an ISO instant into the `<input type="date">` and `<input type="time">` values it needs. */
+75
View File
@@ -0,0 +1,75 @@
/** The two UI languages. English dates use en-GB so day-month-year matches the Russian order. */
export type Locale = 'ru' | 'en';
export const LOCALES = ['ru', 'en'] as const;
const STORAGE_KEY = 'h-school.locale';
let current: Locale = detect();
const listeners = new Set<() => void>();
function detect(): Locale {
const stored = readStored();
if (stored !== null) {
return stored;
}
const language = typeof navigator === 'undefined' ? '' : navigator.language.toLowerCase();
return language.startsWith('ru') ? 'ru' : 'en';
}
function readStored(): Locale | null {
try {
const value = globalThis.localStorage?.getItem(STORAGE_KEY);
return value === 'ru' || value === 'en' ? value : null;
} catch {
return null;
}
}
function persist(locale: Locale): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, locale);
} catch {
// Private mode, or tests without a store — the in-memory value still changes.
}
}
function applyToDocument(locale: Locale): void {
if (typeof document === 'undefined') {
return;
}
document.documentElement.lang = locale;
}
applyToDocument(current);
export function getLocale(): Locale {
return current;
}
/** BCP 47 tag for `Intl` formatters. */
export function intlTag(locale: Locale = current): 'ru-RU' | 'en-GB' {
return locale === 'en' ? 'en-GB' : 'ru-RU';
}
export function setLocale(locale: Locale): void {
if (locale === current) {
return;
}
current = locale;
persist(locale);
applyToDocument(locale);
for (const listener of listeners) {
listener();
}
}
/** Returns an unsubscribe function. */
export function onLocaleChange(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getLocale, setLocale } from './locale.ts';
import { schoolWord, t } from './strings.ts';
const initial = getLocale();
afterEach(() => setLocale(initial));
describe('t', () => {
it('returns the Russian string for the active locale', () => {
setLocale('ru');
expect(t('createSchool')).toBe('Создать школу');
});
it('returns the English string after a switch', () => {
setLocale('en');
expect(t('createSchool')).toBe('Create school');
});
it('interpolates placeholders', () => {
setLocale('en');
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
});
});
describe('schoolWord', () => {
it('uses the Russian one/few/many forms', () => {
setLocale('ru');
expect(schoolWord(1)).toBe('школа');
expect(schoolWord(2)).toBe('школы');
expect(schoolWord(5)).toBe('школ');
expect(schoolWord(21)).toBe('школа');
});
it('uses the English one/other forms', () => {
setLocale('en');
expect(schoolWord(1)).toBe('school');
expect(schoolWord(2)).toBe('schools');
expect(schoolWord(5)).toBe('schools');
});
});
+137
View File
@@ -0,0 +1,137 @@
import { getLocale, intlTag, type Locale } from './locale.ts';
const ru = {
statusConnecting: 'подключение…',
statusConnected: 'сервер на связи',
statusReconnecting: 'переподключение…',
statusClosed: 'соединение закрыто',
pingMs: '{ms} мс',
pingPlaceholder: '-- мс',
language: 'Язык',
schoolsTitle: 'Школы',
createSchool: 'Создать школу',
emptySchools: 'Пока ни одной школы. Создайте первую.',
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
schoolCount: 'Школ: {current} из {max}.',
schoolLimitReached: 'Достигнут лимит: {max} {schoolWord}. Удалите одну, чтобы создать новую.',
deleteSchool: 'Удалить',
deleteSchoolTitle: 'Удалить школу?',
deleteSchoolMessage: '«{name}» будет удалена без возможности восстановления.',
deleteFailed: 'Не удалось удалить «{name}».',
paused: '⏸ на паузе',
newSchool: 'Новая школа',
schoolName: 'Название',
schoolNamePlaceholder: 'Название школы',
randomName: 'Случайное',
randomNameTitle: 'Придумать название',
gameStart: 'Начало игры',
create: 'Создать',
cancel: 'Отмена',
confirm: 'Подтвердить',
randomNameFailed: 'Не удалось получить название с сервера.',
startDateRequired: 'Укажите дату и время начала.',
serverUnavailable: 'Сервер недоступен. Попробуйте ещё раз.',
errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.',
errorInvalidName: 'Название должно быть от 1 до 40 символов.',
errorInvalidStartDate: 'Дата начала вне допустимого диапазона.',
backToMenu: '← В главное меню',
emptySchoolHint: 'Школа пока пуста — здесь появится сама игра.',
pause: 'Пауза',
resume: 'Продолжить',
} as const;
type Messages = { [K in keyof typeof ru]: string };
const en: Messages = {
statusConnecting: 'connecting…',
statusConnected: 'connected',
statusReconnecting: 'reconnecting…',
statusClosed: 'connection closed',
pingMs: '{ms} ms',
pingPlaceholder: '-- ms',
language: 'Language',
schoolsTitle: 'Schools',
createSchool: 'Create school',
emptySchools: 'No schools yet. Create the first one.',
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
createDisabledTitle: 'Delete a school to create a new one',
schoolCount: 'Schools: {current} of {max}.',
schoolLimitReached: 'Limit reached: {max} {schoolWord}. Delete one to create another.',
deleteSchool: 'Delete',
deleteSchoolTitle: 'Delete this school?',
deleteSchoolMessage: '"{name}" will be deleted permanently.',
deleteFailed: 'Could not delete "{name}".',
paused: '⏸ paused',
newSchool: 'New school',
schoolName: 'Name',
schoolNamePlaceholder: 'School name',
randomName: 'Random',
randomNameTitle: 'Suggest a name',
gameStart: 'Game start',
create: 'Create',
cancel: 'Cancel',
confirm: 'Confirm',
randomNameFailed: 'Could not fetch a name from the server.',
startDateRequired: 'Enter a start date and time.',
serverUnavailable: 'The server is unavailable. Try again.',
errorSchoolLimit: 'School limit reached — delete one to create another.',
errorInvalidName: 'The name must be 1 to 40 characters.',
errorInvalidStartDate: 'The start date is outside the allowed range.',
backToMenu: '← Main menu',
emptySchoolHint: 'The school is empty for now — the game itself will appear here.',
pause: 'Pause',
resume: 'Resume',
};
const catalogs: Record<Locale, Messages> = { ru, en };
export type MessageKey = keyof Messages;
export function t(key: MessageKey, vars?: Record<string, string | number>): string {
let text: string = catalogs[getLocale()][key];
if (vars !== undefined) {
for (const [name, value] of Object.entries(vars)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
}
interface PluralForms {
readonly one: string;
readonly few?: string;
readonly many?: string;
readonly other: string;
}
const schoolWordForms: Record<Locale, PluralForms> = {
ru: { one: 'школа', few: 'школы', many: 'школ', other: 'школ' },
en: { one: 'school', other: 'schools' },
};
export function schoolWord(count: number): string {
const forms = schoolWordForms[getLocale()];
const rule = new Intl.PluralRules(intlTag()).select(count);
switch (rule) {
case 'one':
return forms.one;
case 'few':
return forms.few ?? forms.other;
case 'many':
return forms.many ?? forms.other;
default:
return forms.other;
}
}
+35 -11
View File
@@ -1,23 +1,31 @@
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts'; import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
import { onLocaleChange } from './i18n/locale.ts';
import { t, type MessageKey } from './i18n/strings.ts';
import { GameScreen } from './ui/gameScreen.ts'; import { GameScreen } from './ui/gameScreen.ts';
import { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts'; import { MainMenu } from './ui/mainMenu.ts';
import type { School } from './net/api.ts'; import type { School } from './net/api.ts';
import './style.css'; import './style.css';
const STATUS_LABELS: Record<ConnectionStatus, string> = { const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
connecting: 'подключение…', connecting: 'statusConnecting',
connected: 'сервер на связи', connected: 'statusConnected',
reconnecting: 'переподключение…', reconnecting: 'statusReconnecting',
closed: 'соединение закрыто', closed: 'statusClosed',
}; };
/** Wires the two screens to one WebSocket connection. */ /** Wires the two screens to one WebSocket connection. */
function bootstrap(): void { function bootstrap(): void {
const app = requireElement('#app'); const app = requireElement('#app');
const footer = requireElement('#status');
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]'); const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
const pingLabel = document.querySelector<HTMLElement>('[data-status="ping"]'); const pingLabel = document.querySelector<HTMLElement>('[data-status="ping"]');
footer.prepend(localeSwitch());
let openSchool: School | null = null; let openSchool: School | null = null;
let connectionStatus: ConnectionStatus = 'connecting';
let lastPingMs: number | null = null;
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) }); const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
const game = new GameScreen({ const game = new GameScreen({
@@ -28,9 +36,8 @@ function bootstrap(): void {
const connection = new GameConnection(gameSocketUrl(), { const connection = new GameConnection(gameSocketUrl(), {
onStatus: (status) => { onStatus: (status) => {
if (statusLabel !== null) { connectionStatus = status;
statusLabel.textContent = STATUS_LABELS[status]; paintChrome();
}
}, },
onClock: (clock) => { onClock: (clock) => {
if (openSchool?.id === clock.schoolId) { if (openSchool?.id === clock.schoolId) {
@@ -44,12 +51,22 @@ function bootstrap(): void {
} }
}, },
onLatency: (rttMs) => { onLatency: (rttMs) => {
if (pingLabel !== null) { lastPingMs = rttMs;
pingLabel.textContent = `${Math.round(rttMs)} мс`; paintChrome();
}
}, },
}); });
function paintChrome(): void {
if (statusLabel !== null) {
statusLabel.textContent = t(STATUS_KEYS[connectionStatus]);
}
if (pingLabel !== null) {
pingLabel.textContent =
lastPingMs === null ? t('pingPlaceholder') : t('pingMs', { ms: Math.round(lastPingMs) });
}
}
function enterSchool(school: School): void { function enterSchool(school: School): void {
openSchool = school; openSchool = school;
menu.stop(); menu.stop();
@@ -69,6 +86,13 @@ function bootstrap(): void {
menu.start(); menu.start();
} }
onLocaleChange(() => {
paintChrome();
menu.localize();
game.localize();
});
paintChrome();
connection.connect(); connection.connect();
showMenu(); showMenu();
+4 -2
View File
@@ -34,8 +34,10 @@ export async function fetchSchools(): Promise<SchoolsResponse> {
return request<SchoolsResponse>('/api/schools'); return request<SchoolsResponse>('/api/schools');
} }
export async function fetchRandomName(): Promise<string> { export async function fetchRandomName(lang: string): Promise<string> {
const response = await request<{ name: string }>('/api/schools/random-name'); const response = await request<{ name: string }>(
`/api/schools/random-name?lang=${encodeURIComponent(lang)}`,
);
return response.name; return response.name;
} }
+28
View File
@@ -34,11 +34,39 @@ body {
right: 16px; right: 16px;
bottom: 12px; bottom: 12px;
display: flex; display: flex;
align-items: center;
gap: 14px; gap: 14px;
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
.locale-switch {
display: flex;
gap: 4px;
}
.locale-switch__button {
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
}
.locale-switch__button:hover {
border-color: var(--accent);
color: var(--text);
}
.locale-switch__button[aria-pressed='true'] {
border-color: var(--accent);
color: var(--accent);
}
/* Screens */ /* Screens */
.screen__header { .screen__header {
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
@@ -17,7 +18,7 @@ export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
const confirmButton = el('button', { const confirmButton = el('button', {
class: options.danger === true ? 'button button--danger' : 'button button--primary', class: options.danger === true ? 'button button--danger' : 'button button--primary',
type: 'button', type: 'button',
text: options.confirmLabel ?? 'Подтвердить', text: options.confirmLabel ?? t('confirm'),
onClick: () => modal.close(true), onClick: () => modal.close(true),
}); });
@@ -30,7 +31,7 @@ export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
el('button', { el('button', {
class: 'button', class: 'button',
type: 'button', type: 'button',
text: options.cancelLabel ?? 'Отмена', text: options.cancelLabel ?? t('cancel'),
onClick: () => modal.close(false), onClick: () => modal.close(false),
}), }),
confirmButton, confirmButton,
+15 -14
View File
@@ -1,5 +1,6 @@
import { ApiError, type School } from '../net/api.ts'; import { ApiError, type School } from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts'; import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
@@ -20,7 +21,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const nameInput = el('input', { class: 'input', type: 'text' }); const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40; nameInput.maxLength = 40;
nameInput.placeholder = 'Название школы'; nameInput.placeholder = t('schoolNamePlaceholder');
nameInput.required = true; nameInput.required = true;
const dateInput = el('input', { class: 'input', type: 'date' }); const dateInput = el('input', { class: 'input', type: 'date' });
@@ -37,11 +38,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const randomButton = el('button', { const randomButton = el('button', {
class: 'button', class: 'button',
type: 'button', type: 'button',
text: 'Случайное', text: t('randomName'),
title: 'Придумать название', title: t('randomNameTitle'),
}); });
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: 'Создать' }); const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: t('create') });
const form = el( const form = el(
'form', 'form',
@@ -49,20 +50,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el( el(
'label', 'label',
{ class: 'field' }, { class: 'field' },
el('span', { class: 'field__label', text: 'Название' }), el('span', { class: 'field__label', text: t('schoolName') }),
el('div', { class: 'field__row' }, nameInput, randomButton), el('div', { class: 'field__row' }, nameInput, randomButton),
), ),
el( el(
'div', 'div',
{ class: 'field' }, { class: 'field' },
el('span', { class: 'field__label', text: 'Начало игры' }), el('span', { class: 'field__label', text: t('gameStart') }),
el('div', { class: 'field__row' }, dateInput, timeInput), el('div', { class: 'field__row' }, dateInput, timeInput),
), ),
error, error,
el( el(
'div', 'div',
{ class: 'dialog__actions' }, { class: 'dialog__actions' },
el('button', { class: 'button', type: 'button', text: 'Отмена', onClick: () => modal.close(null) }), el('button', { class: 'button', type: 'button', text: t('cancel'), onClick: () => modal.close(null) }),
submitButton, submitButton,
), ),
); );
@@ -92,7 +93,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nameInput.value = name; nameInput.value = name;
error.hidden = true; error.hidden = true;
}) })
.catch(() => showError('Не удалось получить название с сервера.')) .catch(() => showError(t('randomNameFailed')))
.finally(() => setBusy(false)); .finally(() => setBusy(false));
}); });
@@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value); const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
if (startDate === null) { if (startDate === null) {
showError('Укажите дату и время начала.'); showError(t('startDateRequired'));
return; return;
} }
@@ -118,23 +119,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
}); });
}); });
modal.element.append(el('h2', { class: 'dialog__title', text: 'Новая школа' }), form); modal.element.append(el('h2', { class: 'dialog__title', text: t('newSchool') }), form);
return modal.open(nameInput); return modal.open(nameInput);
} }
function describe(reason: unknown): string { function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) { if (!(reason instanceof ApiError)) {
return 'Сервер недоступен. Попробуйте ещё раз.'; return t('serverUnavailable');
} }
switch (reason.code) { switch (reason.code) {
case 'school-limit-reached': case 'school-limit-reached':
return 'Достигнут лимит школ — удалите одну, чтобы создать новую.'; return t('errorSchoolLimit');
case 'invalid-name': case 'invalid-name':
return 'Название должно быть от 1 до 40 символов.'; return t('errorInvalidName');
case 'invalid-start-date': case 'invalid-start-date':
return 'Дата начала вне допустимого диапазона.'; return t('errorInvalidStartDate');
default: default:
return reason.message; return reason.message;
} }
+24 -8
View File
@@ -1,5 +1,6 @@
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts'; import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import { t } from '../i18n/strings.ts';
import type { School } from '../net/api.ts'; import type { School } from '../net/api.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
@@ -17,14 +18,18 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
*/ */
export class GameScreen { export class GameScreen {
private readonly root = el('section', { class: 'screen game' }); private readonly root = el('section', { class: 'screen game' });
private readonly backButton = el('button', { class: 'button', type: 'button' });
private readonly schoolName = el('h1', { class: 'screen__title' }); private readonly schoolName = el('h1', { class: 'screen__title' });
private readonly time = el('p', { class: 'clock__time', text: '--:--' }); private readonly time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' }); private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' }); private readonly weekday = el('p', { class: 'clock__weekday' });
private readonly emptyHint = el('p', { class: 'hint' });
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' }); private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly speedButtons: HTMLButtonElement[]; private readonly speedButtons: HTMLButtonElement[];
private running = false; private running = false;
private lastGameTime: Date | null = null;
private lastSpeedIndex = 0;
constructor(options: GameScreenOptions) { constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) => this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
@@ -36,15 +41,11 @@ export class GameScreen {
}), }),
); );
this.backButton.addEventListener('click', options.onLeave);
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
this.root.append( this.root.append(
el( el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
'header',
{ class: 'screen__header' },
el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }),
this.schoolName,
),
el( el(
'div', 'div',
{ class: 'clock' }, { class: 'clock' },
@@ -53,14 +54,27 @@ export class GameScreen {
this.weekday, this.weekday,
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons), el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
), ),
el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }), this.emptyHint,
); );
this.localize();
} }
get element(): HTMLElement { get element(): HTMLElement {
return this.root; return this.root;
} }
localize(): void {
this.backButton.textContent = t('backToMenu');
this.emptyHint.textContent = t('emptySchoolHint');
if (this.lastGameTime !== null) {
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex);
} else {
this.playPauseButton.title = t('resume');
}
}
/** Called when the screen opens, before the first clock frame arrives. */ /** Called when the screen opens, before the first clock frame arrives. */
show(school: School): void { show(school: School): void {
this.schoolName.textContent = school.name; this.schoolName.textContent = school.name;
@@ -73,13 +87,15 @@ export class GameScreen {
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void { private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
this.running = running; this.running = running;
this.lastGameTime = gameTime;
this.lastSpeedIndex = speedIndex;
this.time.textContent = formatGameTimeOfDay(gameTime); this.time.textContent = formatGameTimeOfDay(gameTime);
this.date.textContent = formatGameDate(gameTime); this.date.textContent = formatGameDate(gameTime);
this.weekday.textContent = formatGameWeekday(gameTime); this.weekday.textContent = formatGameWeekday(gameTime);
this.playPauseButton.textContent = running ? '⏸' : '▶'; this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? 'Пауза' : 'Продолжить'; this.playPauseButton.title = running ? t('pause') : t('resume');
this.speedButtons.forEach((button, index) => { this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex); button.classList.toggle('button--active', index === speedIndex);
+39
View File
@@ -0,0 +1,39 @@
import { el } from './dom.ts';
import { LOCALES, getLocale, setLocale, onLocaleChange, type Locale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
const LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
/** Compact RU/EN toggle for the status footer — always on screen, both menus. */
export function localeSwitch(): HTMLElement {
const buttons = new Map<Locale, HTMLButtonElement>();
const group = el('div', { class: 'locale-switch' });
group.setAttribute('role', 'group');
for (const locale of LOCALES) {
const button = el('button', {
class: 'locale-switch__button',
type: 'button',
text: LABELS[locale],
onClick: () => setLocale(locale),
});
button.setAttribute('lang', locale);
buttons.set(locale, button);
group.append(button);
}
const sync = (): void => {
group.setAttribute('aria-label', t('language'));
const active = getLocale();
for (const [locale, button] of buttons) {
button.setAttribute('aria-pressed', locale === active ? 'true' : 'false');
}
};
onLocaleChange(sync);
sync();
return group;
}
+47 -24
View File
@@ -6,6 +6,8 @@ import {
type School, type School,
type SchoolsResponse, type SchoolsResponse,
} from '../net/api.ts'; } from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { schoolWord, t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts'; import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts';
@@ -26,12 +28,12 @@ const REFRESH_INTERVAL_MS = 1000;
export class MainMenu { export class MainMenu {
private readonly root = el('section', { class: 'screen menu' }); private readonly root = el('section', { class: 'screen menu' });
private readonly title = el('h1', { class: 'screen__title' });
private readonly grid = el('div', { class: 'card-grid' }); private readonly grid = el('div', { class: 'card-grid' });
private readonly emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' }); private readonly emptyHint = el('p', { class: 'hint' });
private readonly createButton = el('button', { private readonly createButton = el('button', {
class: 'button button--primary', class: 'button button--primary',
type: 'button', type: 'button',
text: 'Создать школу',
}); });
private readonly limitHint = el('p', { class: 'hint' }); private readonly limitHint = el('p', { class: 'hint' });
@@ -41,6 +43,7 @@ export class MainMenu {
private state: SchoolsResponse | null = null; private state: SchoolsResponse | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null; private refreshTimer: ReturnType<typeof setInterval> | null = null;
private busy = false; private busy = false;
private error: { key: 'loadSchoolsFailed' } | { key: 'deleteFailed'; name: string } | null = null;
constructor(private readonly options: MainMenuOptions) { constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog()); this.createButton.addEventListener('click', () => void this.openCreateDialog());
@@ -50,7 +53,7 @@ export class MainMenu {
el( el(
'header', 'header',
{ class: 'screen__header' }, { class: 'screen__header' },
el('h1', { class: 'screen__title', text: 'Школы' }), this.title,
el('div', { class: 'screen__actions' }, this.createButton), el('div', { class: 'screen__actions' }, this.createButton),
), ),
this.limitHint, this.limitHint,
@@ -58,12 +61,28 @@ export class MainMenu {
this.emptyHint, this.emptyHint,
this.grid, this.grid,
); );
this.localize();
} }
get element(): HTMLElement { get element(): HTMLElement {
return this.root; return this.root;
} }
/** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */
localize(): void {
this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool');
this.emptyHint.textContent = t('emptySchools');
for (const card of this.cards.values()) {
card.localize();
}
this.paintError();
this.render();
}
/** Shows the menu: loads the list once, then keeps it fresh. */ /** Shows the menu: loads the list once, then keeps it fresh. */
start(): void { start(): void {
void this.refresh(); void this.refresh();
@@ -86,16 +105,30 @@ export class MainMenu {
async refresh(): Promise<void> { async refresh(): Promise<void> {
try { try {
this.state = await fetchSchools(); this.state = await fetchSchools();
this.status.hidden = true; this.error = null;
} catch { } catch {
this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.'; this.error = { key: 'loadSchoolsFailed' };
this.status.hidden = false; this.paintError();
return; return;
} }
this.paintError();
this.render(); this.render();
} }
private paintError(): void {
if (this.error === null) {
this.status.hidden = true;
return;
}
this.status.textContent =
this.error.key === 'loadSchoolsFailed'
? t('loadSchoolsFailed')
: t('deleteFailed', { name: this.error.name });
this.status.hidden = false;
}
private render(): void { private render(): void {
const state = this.state; const state = this.state;
if (state === null) { if (state === null) {
@@ -104,11 +137,11 @@ export class MainMenu {
const atLimit = state.schools.length >= state.maxSchools; const atLimit = state.schools.length >= state.maxSchools;
this.createButton.toggleAttribute('disabled', atLimit || this.busy); this.createButton.toggleAttribute('disabled', atLimit || this.busy);
this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : ''; this.createButton.title = atLimit ? t('createDisabledTitle') : '';
this.limitHint.textContent = atLimit this.limitHint.textContent = atLimit
? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.` ? t('schoolLimitReached', { max: state.maxSchools, schoolWord: schoolWord(state.maxSchools) })
: `Школ: ${state.schools.length} из ${state.maxSchools}.`; : t('schoolCount', { current: state.schools.length, max: state.maxSchools });
this.emptyHint.hidden = state.schools.length > 0; this.emptyHint.hidden = state.schools.length > 0;
@@ -141,9 +174,9 @@ export class MainMenu {
private async confirmDelete(school: School): Promise<void> { private async confirmDelete(school: School): Promise<void> {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: 'Удалить школу?', title: t('deleteSchoolTitle'),
message: `«${school.name}» будет удалена без возможности восстановления.`, message: t('deleteSchoolMessage', { name: school.name }),
confirmLabel: 'Удалить', confirmLabel: t('deleteSchool'),
danger: true, danger: true,
}); });
@@ -154,8 +187,7 @@ export class MainMenu {
try { try {
await deleteSchool(school.id); await deleteSchool(school.id);
} catch { } catch {
this.status.textContent = `Не удалось удалить «${school.name}».`; this.error = { key: 'deleteFailed', name: school.name };
this.status.hidden = false;
} }
await this.refresh(); await this.refresh();
@@ -171,7 +203,7 @@ export class MainMenu {
try { try {
await createSchoolDialog({ await createSchoolDialog({
defaultStartDate: new Date(state.defaultStartDate), defaultStartDate: new Date(state.defaultStartDate),
suggestName: fetchRandomName, suggestName: () => fetchRandomName(getLocale()),
create: createSchool, create: createSchool,
}); });
} finally { } finally {
@@ -181,12 +213,3 @@ export class MainMenu {
await this.refresh(); await this.refresh();
} }
} }
function plural(count: number): string {
const remainderTen = count % 10;
const remainderHundred = count % 100;
if (remainderTen === 1 && remainderHundred !== 11) return 'школа';
if (remainderTen >= 2 && remainderTen <= 4 && (remainderHundred < 12 || remainderHundred > 14)) return 'школы';
return 'школ';
}
+20 -15
View File
@@ -1,5 +1,6 @@
import type { School } from '../net/api.ts'; import type { School } from '../net/api.ts';
import { formatGameDateTime } from '../format/gameTime.ts'; import { formatGameDateTime } from '../format/gameTime.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
interface SchoolCardOptions { interface SchoolCardOptions {
@@ -16,7 +17,11 @@ export class SchoolCard {
private readonly title = el('h2', { class: 'card__title' }); private readonly title = el('h2', { class: 'card__title' });
private readonly time = el('p', { class: 'card__time' }); private readonly time = el('p', { class: 'card__time' });
private readonly pausedBadge = el('span', { class: 'card__badge', text: '⏸ на паузе' }); private readonly pausedBadge = el('span', { class: 'card__badge' });
private readonly deleteButton = el('button', {
class: 'button button--danger button--small',
type: 'button',
});
private school: School; private school: School;
@@ -25,23 +30,16 @@ export class SchoolCard {
this.element.dataset['schoolId'] = String(school.id); this.element.dataset['schoolId'] = String(school.id);
this.element.tabIndex = 0; this.element.tabIndex = 0;
this.element.append( this.deleteButton.addEventListener('click', (event) => {
this.title,
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
el(
'div',
{ class: 'card__actions' },
el('button', {
class: 'button button--danger button--small',
type: 'button',
text: 'Удалить',
onClick: (event) => {
// The whole card is clickable, so the delete button must not open the school too. // The whole card is clickable, so the delete button must not open the school too.
event.stopPropagation(); event.stopPropagation();
options.onDelete(this.school); options.onDelete(this.school);
}, });
}),
), this.element.append(
this.title,
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
el('div', { class: 'card__actions' }, this.deleteButton),
); );
this.element.addEventListener('click', () => options.onOpen(this.school)); this.element.addEventListener('click', () => options.onOpen(this.school));
@@ -52,9 +50,16 @@ export class SchoolCard {
} }
}); });
this.localize();
this.update(school); this.update(school);
} }
localize(): void {
this.deleteButton.textContent = t('deleteSchool');
this.pausedBadge.textContent = t('paused');
this.update(this.school);
}
update(school: School): void { update(school: School): void {
this.school = school; this.school = school;
+7 -2
View File
@@ -29,9 +29,9 @@ internal static class SchoolEndpoints
}) })
.WithName("GetSchools"); .WithName("GetSchools");
schools.MapGet("/random-name", async (GameCommandQueue commands, CancellationToken cancellationToken) => schools.MapGet("/random-name", async (string? lang, GameCommandQueue commands, CancellationToken cancellationToken) =>
{ {
var command = new GameCommand.SuggestName(NewCompletion<string>()); var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion<string>());
commands.Enqueue(command); commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
@@ -85,6 +85,11 @@ internal static class SchoolEndpoints
private static TaskCompletionSource<T> NewCompletion<T>() => private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
private static SchoolNameLanguage ParseNameLanguage(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase)
? SchoolNameLanguage.English
: SchoolNameLanguage.Russian;
private static IResult Problem(int statusCode, string code, string detail) => private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?> Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{ {
+3 -1
View File
@@ -1,3 +1,5 @@
using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
/// <summary> /// <summary>
@@ -13,7 +15,7 @@ internal abstract record GameCommand
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
internal sealed record SuggestName(TaskCompletionSource<string> Result) : GameCommand; internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary> /// <summary>A connection starts watching a school; its calendar starts running.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand; internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
+1 -1
View File
@@ -111,7 +111,7 @@ internal sealed class GameLoopService(
break; break;
case GameCommand.SuggestName suggest: case GameCommand.SuggestName suggest:
Complete(suggest.Result, _schools.SuggestName); Complete(suggest.Result, () => _schools.SuggestName(suggest.Language));
break; break;
case GameCommand.OpenSchool open: case GameCommand.OpenSchool open:
+52 -11
View File
@@ -1,5 +1,12 @@
namespace HSchool.Simulation; namespace HSchool.Simulation;
/// <summary>Which word list the random-name button draws from.</summary>
public enum SchoolNameLanguage
{
Russian = 0,
English = 1,
}
/// <summary> /// <summary>
/// Suggestions for the "random name" button. Lives here rather than in the client because the /// Suggestions for the "random name" button. Lives here rather than in the client because the
/// server is the one that knows which names are already taken. /// server is the one that knows which names are already taken.
@@ -8,13 +15,35 @@ public sealed class SchoolNameGenerator(Random? random = null)
{ {
private const int AttemptsBeforeNumbering = 24; private const int AttemptsBeforeNumbering = 24;
private static readonly string[] Kinds = ["Школа", "Гимназия", "Лицей", "Школа-интернат"]; private readonly record struct Catalog(
string[] Kinds,
string[] Epithets,
string NumberSign,
bool NumberSpace,
bool EpithetFirst,
string FallbackKind);
private static readonly string[] Epithets = private static readonly Catalog Russian = new(
["Школа", "Гимназия", "Лицей", "Школа-интернат"],
[ [
"Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная", "Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная",
"Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная", "Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная",
]; ],
"№",
false,
false,
"Школа");
private static readonly Catalog English = new(
["School", "Academy", "Grammar School", "High School"],
[
"Northern", "Seaside", "Riverside", "Hillside", "Maple", "Rowan",
"Sunny", "Meadow", "Quiet", "Clear", "Oak", "Pine",
],
"No.",
true,
true,
"School");
private readonly Random _random = random ?? Random.Shared; private readonly Random _random = random ?? Random.Shared;
@@ -22,13 +51,14 @@ public sealed class SchoolNameGenerator(Random? random = null)
/// A name that is not in <paramref name="taken"/>. Falls back to a numbered name so the /// A name that is not in <paramref name="taken"/>. Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted. /// button always produces something, even when the pool is exhausted.
/// </summary> /// </summary>
public string Next(IEnumerable<string> taken) public string Next(IEnumerable<string> taken, SchoolNameLanguage language = SchoolNameLanguage.Russian)
{ {
var catalog = language == SchoolNameLanguage.English ? English : Russian;
var used = new HashSet<string>(taken, StringComparer.OrdinalIgnoreCase); var used = new HashSet<string>(taken, StringComparer.OrdinalIgnoreCase);
for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++) for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++)
{ {
var candidate = Compose(); var candidate = Compose(catalog);
if (used.Add(candidate)) if (used.Add(candidate))
{ {
return candidate; return candidate;
@@ -37,7 +67,7 @@ public sealed class SchoolNameGenerator(Random? random = null)
for (var number = 1; ; number++) for (var number = 1; ; number++)
{ {
var candidate = $"Школа №{number}"; var candidate = Numbered(catalog, catalog.FallbackKind, number);
if (!used.Contains(candidate)) if (!used.Contains(candidate))
{ {
return candidate; return candidate;
@@ -45,13 +75,24 @@ public sealed class SchoolNameGenerator(Random? random = null)
} }
} }
private string Compose() private string Compose(Catalog catalog)
{ {
var kind = Kinds[_random.Next(Kinds.Length)]; var kind = catalog.Kinds[_random.Next(catalog.Kinds.Length)];
// Half the names are numbered, half are named — both read like a real school. // Half the names are numbered, half are named — both read like a real school.
return _random.Next(2) == 0 if (_random.Next(2) == 0)
? $"{kind} №{_random.Next(1, 100)}" {
: $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»"; return Numbered(catalog, kind, _random.Next(1, 100));
} }
var epithet = catalog.Epithets[_random.Next(catalog.Epithets.Length)];
return catalog.EpithetFirst
? $"{epithet} {kind}"
: $"{kind} «{epithet}»";
}
private static string Numbered(Catalog catalog, string kind, int number) =>
catalog.NumberSpace
? $"{kind} {catalog.NumberSign} {number}"
: $"{kind} {catalog.NumberSign}{number}";
} }
+2 -1
View File
@@ -100,7 +100,8 @@ public sealed class SchoolRegistry : IDisposable
} }
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary> /// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name)); public string SuggestName(SchoolNameLanguage language = SchoolNameLanguage.Russian) =>
NameGenerator.Next(_schools.Select(school => school.Name), language);
/// <summary>Trims, strips control characters and enforces the length limit.</summary> /// <summary>Trims, strips control characters and enforces the length limit.</summary>
public static bool TryNormalizeName(string? name, out string normalized) public static bool TryNormalizeName(string? name, out string normalized)
@@ -159,12 +159,27 @@ public class SchoolRegistryTests
{ {
var generator = new SchoolNameGenerator(new Random(1234)); var generator = new SchoolNameGenerator(new Random(1234));
foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
{
for (var i = 0; i < 200; i++) for (var i = 0; i < 200; i++)
{ {
var name = generator.Next([]); var name = generator.Next([], language);
Assert.InRange(name.Length, 1, School.MaxNameLength); Assert.InRange(name.Length, 1, School.MaxNameLength);
} }
} }
}
[Fact]
public void SuggestedEnglishNames_AreAscii()
{
var generator = new SchoolNameGenerator(new Random(1234));
for (var i = 0; i < 50; i++)
{
var name = generator.Next([], SchoolNameLanguage.English);
Assert.Matches("^[A-Za-z0-9 .]+$", name);
}
}
[Fact] [Fact]
public void Dispose_DropsEverySchool() public void Dispose_DropsEverySchool()