diff --git a/AGENTS.md b/AGENTS.md
index 1264cef..2e1e2bf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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` |
| connection handling, the loop | `src/HSchool.Server` |
| 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
@@ -85,10 +85,11 @@ say so explicitly in the change description.
- `strict` is on, no `any`, no non-null `!` assertions.
- 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`
- wires them together.
+- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `i18n/`
+ holds the RU/EN dictionaries, `main.ts` wires them together.
- 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**
@@ -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
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
- 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
diff --git a/README.md b/README.md
index 1a7a924..7842668 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,8 @@ rather than guessing.
## What you can do
- **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
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
@@ -81,7 +82,7 @@ dotnet 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
diff --git a/docs/protocol.md b/docs/protocol.md
index 7def5f6..4c0cb88 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -44,6 +44,10 @@ Everything the main menu needs in one request.
`{ "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`
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
diff --git a/src/HSchool.Client/index.html b/src/HSchool.Client/index.html
index e2d4646..0973e5a 100644
--- a/src/HSchool.Client/index.html
+++ b/src/HSchool.Client/index.html
@@ -9,8 +9,8 @@
diff --git a/src/HSchool.Client/src/format/gameTime.test.ts b/src/HSchool.Client/src/format/gameTime.test.ts
index 7f4f51e..2b98588 100644
--- a/src/HSchool.Client/src/format/gameTime.test.ts
+++ b/src/HSchool.Client/src/format/gameTime.test.ts
@@ -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 {
formatGameDate,
formatGameDateTime,
@@ -10,6 +11,10 @@ import {
// 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 initial = getLocale();
+
+beforeEach(() => setLocale('ru'));
+afterEach(() => setLocale(initial));
describe('game time formatting', () => {
it('shows the time of day in 24-hour form', () => {
@@ -30,6 +35,15 @@ describe('game time formatting', () => {
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', () => {
// 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));
diff --git a/src/HSchool.Client/src/format/gameTime.ts b/src/HSchool.Client/src/format/gameTime.ts
index 192818e..d465756 100644
--- a/src/HSchool.Client/src/format/gameTime.ts
+++ b/src/HSchool.Client/src/format/gameTime.ts
@@ -6,53 +6,76 @@
* whatever offset they happen to live in.
*/
-const LOCALE = 'ru-RU';
+import { intlTag, onLocaleChange } from '../i18n/locale.ts';
+
const UTC = 'UTC';
-const dateFormat = new Intl.DateTimeFormat(LOCALE, {
- timeZone: UTC,
- day: 'numeric',
- month: 'long',
- year: 'numeric',
+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,
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }),
+ time: new Intl.DateTimeFormat(tag, {
+ timeZone: UTC,
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }),
+ weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }),
+ short: new Intl.DateTimeFormat(tag, {
+ timeZone: UTC,
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }),
+ };
+
+ cached = { tag, formats: next };
+ return next;
+}
+
+onLocaleChange(() => {
+ cached = null;
});
-const timeFormat = new Intl.DateTimeFormat(LOCALE, {
- timeZone: UTC,
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
-});
-
-const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' });
-
-const shortFormat = new Intl.DateTimeFormat(LOCALE, {
- timeZone: UTC,
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
-});
-
-/** "3 апреля 2012 г." */
+/** "3 апреля 2012 г." / "3 April 2012" */
export function formatGameDate(date: Date): string {
- return dateFormat.format(date);
+ return formats().date.format(date);
}
/** "06:00" */
export function formatGameTimeOfDay(date: Date): string {
- return timeFormat.format(date);
+ return formats().time.format(date);
}
-/** "вторник" */
+/** "вторник" / "Tuesday" */
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 {
- return shortFormat.format(date);
+ return formats().short.format(date);
}
/** Splits an ISO instant into the `` and `` values it needs. */
diff --git a/src/HSchool.Client/src/i18n/locale.ts b/src/HSchool.Client/src/i18n/locale.ts
new file mode 100644
index 0000000..ccd9f45
--- /dev/null
+++ b/src/HSchool.Client/src/i18n/locale.ts
@@ -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);
+}
diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts
new file mode 100644
index 0000000..117e602
--- /dev/null
+++ b/src/HSchool.Client/src/i18n/strings.test.ts
@@ -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');
+ });
+});
diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts
new file mode 100644
index 0000000..aa1140e
--- /dev/null
+++ b/src/HSchool.Client/src/i18n/strings.ts
@@ -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 = { ru, en };
+
+export type MessageKey = keyof Messages;
+
+export function t(key: MessageKey, vars?: Record): 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 = {
+ 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;
+ }
+}
diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts
index dc7f96b..d3f9908 100644
--- a/src/HSchool.Client/src/main.ts
+++ b/src/HSchool.Client/src/main.ts
@@ -1,23 +1,31 @@
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 { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts';
import type { School } from './net/api.ts';
import './style.css';
-const STATUS_LABELS: Record = {
- connecting: 'подключение…',
- connected: 'сервер на связи',
- reconnecting: 'переподключение…',
- closed: 'соединение закрыто',
+const STATUS_KEYS: Record = {
+ connecting: 'statusConnecting',
+ connected: 'statusConnected',
+ reconnecting: 'statusReconnecting',
+ closed: 'statusClosed',
};
/** Wires the two screens to one WebSocket connection. */
function bootstrap(): void {
const app = requireElement('#app');
+ const footer = requireElement('#status');
const statusLabel = document.querySelector('[data-status="connection"]');
const pingLabel = document.querySelector('[data-status="ping"]');
+ footer.prepend(localeSwitch());
+
let openSchool: School | null = null;
+ let connectionStatus: ConnectionStatus = 'connecting';
+ let lastPingMs: number | null = null;
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
const game = new GameScreen({
@@ -28,9 +36,8 @@ function bootstrap(): void {
const connection = new GameConnection(gameSocketUrl(), {
onStatus: (status) => {
- if (statusLabel !== null) {
- statusLabel.textContent = STATUS_LABELS[status];
- }
+ connectionStatus = status;
+ paintChrome();
},
onClock: (clock) => {
if (openSchool?.id === clock.schoolId) {
@@ -44,12 +51,22 @@ function bootstrap(): void {
}
},
onLatency: (rttMs) => {
- if (pingLabel !== null) {
- pingLabel.textContent = `${Math.round(rttMs)} мс`;
- }
+ lastPingMs = 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 {
openSchool = school;
menu.stop();
@@ -69,6 +86,13 @@ function bootstrap(): void {
menu.start();
}
+ onLocaleChange(() => {
+ paintChrome();
+ menu.localize();
+ game.localize();
+ });
+
+ paintChrome();
connection.connect();
showMenu();
diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts
index 6b9450f..178aca2 100644
--- a/src/HSchool.Client/src/net/api.ts
+++ b/src/HSchool.Client/src/net/api.ts
@@ -34,8 +34,10 @@ export async function fetchSchools(): Promise {
return request('/api/schools');
}
-export async function fetchRandomName(): Promise {
- const response = await request<{ name: string }>('/api/schools/random-name');
+export async function fetchRandomName(lang: string): Promise {
+ const response = await request<{ name: string }>(
+ `/api/schools/random-name?lang=${encodeURIComponent(lang)}`,
+ );
return response.name;
}
diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css
index 08fe13e..d824428 100644
--- a/src/HSchool.Client/src/style.css
+++ b/src/HSchool.Client/src/style.css
@@ -34,11 +34,39 @@ body {
right: 16px;
bottom: 12px;
display: flex;
+ align-items: center;
gap: 14px;
font-size: 12px;
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 */
.screen__header {
diff --git a/src/HSchool.Client/src/ui/confirmDialog.ts b/src/HSchool.Client/src/ui/confirmDialog.ts
index e27144e..f92b55d 100644
--- a/src/HSchool.Client/src/ui/confirmDialog.ts
+++ b/src/HSchool.Client/src/ui/confirmDialog.ts
@@ -1,3 +1,4 @@
+import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
@@ -17,7 +18,7 @@ export function confirmDialog(options: ConfirmOptions): Promise {
const confirmButton = el('button', {
class: options.danger === true ? 'button button--danger' : 'button button--primary',
type: 'button',
- text: options.confirmLabel ?? 'Подтвердить',
+ text: options.confirmLabel ?? t('confirm'),
onClick: () => modal.close(true),
});
@@ -30,7 +31,7 @@ export function confirmDialog(options: ConfirmOptions): Promise {
el('button', {
class: 'button',
type: 'button',
- text: options.cancelLabel ?? 'Отмена',
+ text: options.cancelLabel ?? t('cancel'),
onClick: () => modal.close(false),
}),
confirmButton,
diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts
index 50912ba..eb05eeb 100644
--- a/src/HSchool.Client/src/ui/createSchoolDialog.ts
+++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts
@@ -1,5 +1,6 @@
import { ApiError, type School } from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
+import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
@@ -20,7 +21,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(null) }),
+ el('button', { class: 'button', type: 'button', text: t('cancel'), onClick: () => modal.close(null) }),
submitButton,
),
);
@@ -92,7 +93,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise showError('Не удалось получить название с сервера.'))
+ .catch(() => showError(t('randomNameFailed')))
.finally(() => setBusy(false));
});
@@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise
@@ -36,15 +41,11 @@ export class GameScreen {
}),
);
+ this.backButton.addEventListener('click', options.onLeave);
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
this.root.append(
- el(
- 'header',
- { class: 'screen__header' },
- el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }),
- this.schoolName,
- ),
+ el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el(
'div',
{ class: 'clock' },
@@ -53,14 +54,27 @@ export class GameScreen {
this.weekday,
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
),
- el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }),
+ this.emptyHint,
);
+
+ this.localize();
}
get element(): HTMLElement {
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. */
show(school: School): void {
this.schoolName.textContent = school.name;
@@ -73,13 +87,15 @@ export class GameScreen {
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
this.running = running;
+ this.lastGameTime = gameTime;
+ this.lastSpeedIndex = speedIndex;
this.time.textContent = formatGameTimeOfDay(gameTime);
this.date.textContent = formatGameDate(gameTime);
this.weekday.textContent = formatGameWeekday(gameTime);
this.playPauseButton.textContent = running ? '⏸' : '▶';
- this.playPauseButton.title = running ? 'Пауза' : 'Продолжить';
+ this.playPauseButton.title = running ? t('pause') : t('resume');
this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex);
diff --git a/src/HSchool.Client/src/ui/localeSwitch.ts b/src/HSchool.Client/src/ui/localeSwitch.ts
new file mode 100644
index 0000000..7a95fe5
--- /dev/null
+++ b/src/HSchool.Client/src/ui/localeSwitch.ts
@@ -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 = { 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();
+
+ 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;
+}
diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts
index 36acd10..913f758 100644
--- a/src/HSchool.Client/src/ui/mainMenu.ts
+++ b/src/HSchool.Client/src/ui/mainMenu.ts
@@ -6,6 +6,8 @@ import {
type School,
type SchoolsResponse,
} from '../net/api.ts';
+import { getLocale } from '../i18n/locale.ts';
+import { schoolWord, t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts';
@@ -26,12 +28,12 @@ const REFRESH_INTERVAL_MS = 1000;
export class MainMenu {
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 emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' });
+ private readonly emptyHint = el('p', { class: 'hint' });
private readonly createButton = el('button', {
class: 'button button--primary',
type: 'button',
- text: 'Создать школу',
});
private readonly limitHint = el('p', { class: 'hint' });
@@ -41,6 +43,7 @@ export class MainMenu {
private state: SchoolsResponse | null = null;
private refreshTimer: ReturnType | null = null;
private busy = false;
+ private error: { key: 'loadSchoolsFailed' } | { key: 'deleteFailed'; name: string } | null = null;
constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog());
@@ -50,7 +53,7 @@ export class MainMenu {
el(
'header',
{ class: 'screen__header' },
- el('h1', { class: 'screen__title', text: 'Школы' }),
+ this.title,
el('div', { class: 'screen__actions' }, this.createButton),
),
this.limitHint,
@@ -58,12 +61,28 @@ export class MainMenu {
this.emptyHint,
this.grid,
);
+
+ this.localize();
}
get element(): HTMLElement {
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. */
start(): void {
void this.refresh();
@@ -86,16 +105,30 @@ export class MainMenu {
async refresh(): Promise {
try {
this.state = await fetchSchools();
- this.status.hidden = true;
+ this.error = null;
} catch {
- this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.';
- this.status.hidden = false;
+ this.error = { key: 'loadSchoolsFailed' };
+ this.paintError();
return;
}
+ this.paintError();
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 {
const state = this.state;
if (state === null) {
@@ -104,11 +137,11 @@ export class MainMenu {
const atLimit = state.schools.length >= state.maxSchools;
this.createButton.toggleAttribute('disabled', atLimit || this.busy);
- this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : '';
+ this.createButton.title = atLimit ? t('createDisabledTitle') : '';
this.limitHint.textContent = atLimit
- ? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.`
- : `Школ: ${state.schools.length} из ${state.maxSchools}.`;
+ ? t('schoolLimitReached', { max: state.maxSchools, schoolWord: schoolWord(state.maxSchools) })
+ : t('schoolCount', { current: state.schools.length, max: state.maxSchools });
this.emptyHint.hidden = state.schools.length > 0;
@@ -141,9 +174,9 @@ export class MainMenu {
private async confirmDelete(school: School): Promise {
const confirmed = await confirmDialog({
- title: 'Удалить школу?',
- message: `«${school.name}» будет удалена без возможности восстановления.`,
- confirmLabel: 'Удалить',
+ title: t('deleteSchoolTitle'),
+ message: t('deleteSchoolMessage', { name: school.name }),
+ confirmLabel: t('deleteSchool'),
danger: true,
});
@@ -154,8 +187,7 @@ export class MainMenu {
try {
await deleteSchool(school.id);
} catch {
- this.status.textContent = `Не удалось удалить «${school.name}».`;
- this.status.hidden = false;
+ this.error = { key: 'deleteFailed', name: school.name };
}
await this.refresh();
@@ -171,7 +203,7 @@ export class MainMenu {
try {
await createSchoolDialog({
defaultStartDate: new Date(state.defaultStartDate),
- suggestName: fetchRandomName,
+ suggestName: () => fetchRandomName(getLocale()),
create: createSchool,
});
} finally {
@@ -181,12 +213,3 @@ export class MainMenu {
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 'школ';
-}
diff --git a/src/HSchool.Client/src/ui/schoolCard.ts b/src/HSchool.Client/src/ui/schoolCard.ts
index d57c153..072a549 100644
--- a/src/HSchool.Client/src/ui/schoolCard.ts
+++ b/src/HSchool.Client/src/ui/schoolCard.ts
@@ -1,5 +1,6 @@
import type { School } from '../net/api.ts';
import { formatGameDateTime } from '../format/gameTime.ts';
+import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
interface SchoolCardOptions {
@@ -16,7 +17,11 @@ export class SchoolCard {
private readonly title = el('h2', { class: 'card__title' });
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;
@@ -25,23 +30,16 @@ export class SchoolCard {
this.element.dataset['schoolId'] = String(school.id);
this.element.tabIndex = 0;
+ this.deleteButton.addEventListener('click', (event) => {
+ // The whole card is clickable, so the delete button must not open the school too.
+ event.stopPropagation();
+ options.onDelete(this.school);
+ });
+
this.element.append(
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.
- event.stopPropagation();
- options.onDelete(this.school);
- },
- }),
- ),
+ el('div', { class: 'card__actions' }, this.deleteButton),
);
this.element.addEventListener('click', () => options.onOpen(this.school));
@@ -52,9 +50,16 @@ export class SchoolCard {
}
});
+ this.localize();
this.update(school);
}
+ localize(): void {
+ this.deleteButton.textContent = t('deleteSchool');
+ this.pausedBadge.textContent = t('paused');
+ this.update(this.school);
+ }
+
update(school: School): void {
this.school = school;
diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs
index 6166d12..f43c787 100644
--- a/src/HSchool.Server/Api/SchoolEndpoints.cs
+++ b/src/HSchool.Server/Api/SchoolEndpoints.cs
@@ -29,9 +29,9 @@ internal static class SchoolEndpoints
})
.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());
+ var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
@@ -85,6 +85,11 @@ internal static class SchoolEndpoints
private static TaskCompletionSource NewCompletion() =>
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) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary
{
diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs
index 1deb67d..7361854 100644
--- a/src/HSchool.Server/Game/GameCommand.cs
+++ b/src/HSchool.Server/Game/GameCommand.cs
@@ -1,3 +1,5 @@
+using HSchool.Simulation;
+
namespace HSchool.Server.Game;
///
@@ -13,7 +15,7 @@ internal abstract record GameCommand
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand;
- internal sealed record SuggestName(TaskCompletionSource Result) : GameCommand;
+ internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource Result) : GameCommand;
/// A connection starts watching a school; its calendar starts running.
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index f485306..82157f2 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -111,7 +111,7 @@ internal sealed class GameLoopService(
break;
case GameCommand.SuggestName suggest:
- Complete(suggest.Result, _schools.SuggestName);
+ Complete(suggest.Result, () => _schools.SuggestName(suggest.Language));
break;
case GameCommand.OpenSchool open:
diff --git a/src/HSchool.Simulation/SchoolNameGenerator.cs b/src/HSchool.Simulation/SchoolNameGenerator.cs
index 3ed77e9..0e6785d 100644
--- a/src/HSchool.Simulation/SchoolNameGenerator.cs
+++ b/src/HSchool.Simulation/SchoolNameGenerator.cs
@@ -1,5 +1,12 @@
namespace HSchool.Simulation;
+/// Which word list the random-name button draws from.
+public enum SchoolNameLanguage
+{
+ Russian = 0,
+ English = 1,
+}
+
///
/// 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.
@@ -8,13 +15,35 @@ public sealed class SchoolNameGenerator(Random? random = null)
{
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;
@@ -22,13 +51,14 @@ public sealed class SchoolNameGenerator(Random? random = null)
/// A name that is not in . Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted.
///
- public string Next(IEnumerable taken)
+ public string Next(IEnumerable taken, SchoolNameLanguage language = SchoolNameLanguage.Russian)
{
+ var catalog = language == SchoolNameLanguage.English ? English : Russian;
var used = new HashSet(taken, StringComparer.OrdinalIgnoreCase);
for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++)
{
- var candidate = Compose();
+ var candidate = Compose(catalog);
if (used.Add(candidate))
{
return candidate;
@@ -37,7 +67,7 @@ public sealed class SchoolNameGenerator(Random? random = null)
for (var number = 1; ; number++)
{
- var candidate = $"Школа №{number}";
+ var candidate = Numbered(catalog, catalog.FallbackKind, number);
if (!used.Contains(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.
- return _random.Next(2) == 0
- ? $"{kind} №{_random.Next(1, 100)}"
- : $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»";
+ if (_random.Next(2) == 0)
+ {
+ 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}";
}
diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs
index b6ff620..c65d38a 100644
--- a/src/HSchool.Simulation/SchoolRegistry.cs
+++ b/src/HSchool.Simulation/SchoolRegistry.cs
@@ -100,7 +100,8 @@ public sealed class SchoolRegistry : IDisposable
}
/// A name the player has not used yet, for the "random" button in the creation form.
- 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);
/// Trims, strips control characters and enforces the length limit.
public static bool TryNormalizeName(string? name, out string normalized)
diff --git a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs
index a88297f..5f838d0 100644
--- a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs
+++ b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs
@@ -159,10 +159,25 @@ public class SchoolRegistryTests
{
var generator = new SchoolNameGenerator(new Random(1234));
- for (var i = 0; i < 200; i++)
+ foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
{
- var name = generator.Next([]);
- Assert.InRange(name.Length, 1, School.MaxNameLength);
+ for (var i = 0; i < 200; i++)
+ {
+ var name = generator.Next([], language);
+ 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);
}
}