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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }`
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<footer id="status">
|
||||
<span data-status="connection">подключение…</span>
|
||||
<span data-status="ping">-- мс</span>
|
||||
<span data-status="connection"></span>
|
||||
<span data-status="ping"></span>
|
||||
</footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 `<input type="date">` and `<input type="time">` values it needs. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectionStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
connected: 'сервер на связи',
|
||||
reconnecting: 'переподключение…',
|
||||
closed: 'соединение закрыто',
|
||||
const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
|
||||
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<HTMLElement>('[data-status="connection"]');
|
||||
const pingLabel = document.querySelector<HTMLElement>('[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();
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ export async function fetchSchools(): Promise<SchoolsResponse> {
|
||||
return request<SchoolsResponse>('/api/schools');
|
||||
}
|
||||
|
||||
export async function fetchRandomName(): Promise<string> {
|
||||
const response = await request<{ name: string }>('/api/schools/random-name');
|
||||
export async function fetchRandomName(lang: string): Promise<string> {
|
||||
const response = await request<{ name: string }>(
|
||||
`/api/schools/random-name?lang=${encodeURIComponent(lang)}`,
|
||||
);
|
||||
return response.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: options.cancelLabel ?? 'Отмена',
|
||||
text: options.cancelLabel ?? t('cancel'),
|
||||
onClick: () => modal.close(false),
|
||||
}),
|
||||
confirmButton,
|
||||
|
||||
@@ -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<School
|
||||
|
||||
const nameInput = el('input', { class: 'input', type: 'text' });
|
||||
nameInput.maxLength = 40;
|
||||
nameInput.placeholder = 'Название школы';
|
||||
nameInput.placeholder = t('schoolNamePlaceholder');
|
||||
nameInput.required = true;
|
||||
|
||||
const dateInput = el('input', { class: 'input', type: 'date' });
|
||||
@@ -37,11 +38,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
const randomButton = el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: 'Случайное',
|
||||
title: 'Придумать название',
|
||||
text: t('randomName'),
|
||||
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(
|
||||
'form',
|
||||
@@ -49,20 +50,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
el(
|
||||
'label',
|
||||
{ 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' },
|
||||
el('span', { class: 'field__label', text: 'Начало игры' }),
|
||||
el('span', { class: 'field__label', text: t('gameStart') }),
|
||||
el('div', { class: 'field__row' }, dateInput, timeInput),
|
||||
),
|
||||
error,
|
||||
el(
|
||||
'div',
|
||||
{ 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,
|
||||
),
|
||||
);
|
||||
@@ -92,7 +93,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nameInput.value = name;
|
||||
error.hidden = true;
|
||||
})
|
||||
.catch(() => showError('Не удалось получить название с сервера.'))
|
||||
.catch(() => showError(t('randomNameFailed')))
|
||||
.finally(() => setBusy(false));
|
||||
});
|
||||
|
||||
@@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
|
||||
const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
|
||||
if (startDate === null) {
|
||||
showError('Укажите дату и время начала.');
|
||||
showError(t('startDateRequired'));
|
||||
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);
|
||||
}
|
||||
|
||||
function describe(reason: unknown): string {
|
||||
if (!(reason instanceof ApiError)) {
|
||||
return 'Сервер недоступен. Попробуйте ещё раз.';
|
||||
return t('serverUnavailable');
|
||||
}
|
||||
|
||||
switch (reason.code) {
|
||||
case 'school-limit-reached':
|
||||
return 'Достигнут лимит школ — удалите одну, чтобы создать новую.';
|
||||
return t('errorSchoolLimit');
|
||||
case 'invalid-name':
|
||||
return 'Название должно быть от 1 до 40 символов.';
|
||||
return t('errorInvalidName');
|
||||
case 'invalid-start-date':
|
||||
return 'Дата начала вне допустимого диапазона.';
|
||||
return t('errorInvalidStartDate');
|
||||
default:
|
||||
return reason.message;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
|
||||
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import type { School } from '../net/api.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
@@ -17,14 +18,18 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
|
||||
*/
|
||||
export class GameScreen {
|
||||
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 time = el('p', { class: 'clock__time', text: '--:--' });
|
||||
private readonly date = el('p', { class: 'clock__date' });
|
||||
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 speedButtons: HTMLButtonElement[];
|
||||
|
||||
private running = false;
|
||||
private lastGameTime: Date | null = null;
|
||||
private lastSpeedIndex = 0;
|
||||
|
||||
constructor(options: GameScreenOptions) {
|
||||
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.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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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 'школ';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string>());
|
||||
var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion<string>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
@@ -85,6 +85,11 @@ internal static class SchoolEndpoints
|
||||
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||
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<string, object?>
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
@@ -13,7 +15,7 @@ internal abstract record 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>
|
||||
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Which word list the random-name button draws from.</summary>
|
||||
public enum SchoolNameLanguage
|
||||
{
|
||||
Russian = 0,
|
||||
English = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="taken"/>. Falls back to a numbered name so the
|
||||
/// button always produces something, even when the pool is exhausted.
|
||||
/// </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);
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
public static bool TryNormalizeName(string? name, out string normalized)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user