Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ way; this file is *how to work in them*.
| --- | --- | | --- | --- |
| schools, the game clock, game rules | `src/HSchool.Simulation` | | schools, the game clock, game rules | `src/HSchool.Simulation` |
| defs, JSONC catalog, map validation | `src/HSchool.Content` | | defs, JSONC catalog, map validation | `src/HSchool.Content` |
| the menu API (list, create, delete) | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | the menu API (list, create, delete, mods, catalog) | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| 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, workers, saves | `src/HSchool.Server` | | connection handling, workers, saves | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` |
+9 -9
View File
@@ -87,8 +87,8 @@ produces the same date.
**Every school runs on its own.** A new school starts living immediately and keeps going whether **Every school runs on its own.** A new school starts living immediately and keeps going whether
or not anybody is looking at it; only the player's pause button stops one, and that pause sticks or not anybody is looking at it; only the player's pause button stops one, and that pause sticks
until they press play again. Opening a school subscribes the connection to its clock frames and until they press play again. Opening a school subscribes the connection to its clock frames and
nothing more. One school's pause cannot stall another's calendar, because they do not share a sends one map snapshot labelled in the Hello locale. One school's pause cannot stall another's
thread. calendar, because they do not share a thread.
The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is
how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh
@@ -105,14 +105,15 @@ mod folder or a map that no longer validates leaves the file in place and that s
## Connection lifetime ## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id. 1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello`; a version mismatch closes the socket. 2. The client sends `Hello` (version + UI locale); a version mismatch closes the socket.
3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready. 3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready.
4. Opening a school enqueues `OpenSchool`; the worker starts pushing clock frames. 4. Opening a school enqueues `OpenSchool`; the worker sends a map snapshot then clock frames.
5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu. 5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu.
6. On disconnect the client is removed; the school it was watching keeps running. 6. On disconnect the client is removed; the school it was watching keeps running.
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client Clock frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate clock frames instead of stalling a worker. that cannot keep up loses intermediate clock frames instead of stalling a worker. The map
snapshot uses a separate reliable queue so it cannot be dropped for a newer tick.
## Where to add things next ## Where to add things next
@@ -120,6 +121,5 @@ that cannot keep up loses intermediate clock frames instead of stalling a worker
`School.Tick`, and unit-test them against `School` directly — no server needed. `School.Tick`, and unit-test them against `School` directly — no server needed.
- **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from - **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from
the server after every change, so nothing else has to know. the server after every change, so nothing else has to know.
- **Create editor and the map snapshot**: the catalog and vanilla map exist; the player still - **Create editor and the map snapshot**: done in this slice. Next game verbs (Sit) and the
cannot pick mods or see the tree from the server. That work lives in event log are out of scope here.
[`phases/04-create-editor.md`](phases/04-create-editor.md).
+9 -9
View File
@@ -12,15 +12,15 @@
## Задачи ## Задачи
- [ ] `GET` списка модов: `core` как обязательный, остальные папки `mods/` - [x] `GET` списка модов: `core` как обязательный, остальные папки `mods/`
- [ ] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id - [x] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id
- [ ] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке - [x] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке
- [ ] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует - [x] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует
- [ ] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту - [x] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту
- [ ] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц - [x] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц
- [ ] Клиент фильтрует выбранный узел; часы как сейчас - [x] Клиент фильтрует выбранный узел; часы как сейчас
- [ ] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек - [x] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек
- [ ] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок - [x] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -15,4 +15,4 @@
| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных | | [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных |
| [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа | | [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа |
| [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки | | [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки |
| [4. Моды и редактор в create](04-create-editor.md) | | Выбор модов, карта в POST, снимок при открытии | | [4. Моды и редактор в create](04-create-editor.md) | | Выбор модов, карта в POST, снимок при открытии |
+78 -13
View File
@@ -1,10 +1,12 @@
# Wire protocol v3 # Wire protocol v4
The client talks to the server two ways: The client talks to the server two ways:
- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are - **HTTP/JSON** for the main menu — listing, creating and deleting schools, listing mods and
request/response by nature, so they are plain REST. loading a catalog for the create editor. Those are request/response by nature, so they are
- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second. plain REST.
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map
snapshot sent when a school is opened.
This document covers both. One protocol message per WebSocket frame, no framing header beyond the This document covers both. One protocol message per WebSocket frame, no framing header beyond the
message id. **All multi-byte numbers are little-endian.** message id. **All multi-byte numbers are little-endian.**
@@ -48,15 +50,48 @@ Optional `?lang=en` draws from the English word list (`Northern Academy`); any o
none, stays Russian. The client sends the active UI language. Names the player types are not none, stays Russian. The client sends the active UI language. Names the player types are not
translated — they are saved as written. translated — they are saved as written.
### `GET /api/mods`
Folders under the server's `mods/` directory. `core` is always first and `required: true`; other
packs can be switched off in the create dialog.
```json
{ "mods": [{ "id": "core", "required": true }] }
```
### `GET /api/catalog?lang=ru|en&mods=addon1,addon2`
Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/default.jsonc` for
`core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated
list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`.
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
Russian.
### `POST /api/schools` ### `POST /api/schools`
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }` Body:
```json
{
"name": "Гимназия №14",
"startDate": "2012-04-03T06:00:00Z",
"modIds": [],
"map": null
}
```
`modIds` are extras; the server always prepends `core`. Omit `map` (or send `null`) to use that
pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph.
| Status | Meaning | | Status | Meaning |
| --- | --- | | --- | --- |
| `201` | Created; body is the school. | | `201` | Created; body is the school. |
| `400` `invalid-name` | Blank, or longer than 40 characters. | | `400` `invalid-name` | Blank, or longer than 40 characters. |
| `400` `invalid-start-date` | Outside 19002999. | | `400` `invalid-start-date` | Outside 19002999. |
| `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. |
| `400` `unknown-mod` | An extra pack id is missing under `mods/`. |
| `400` `invalid-catalog` | The selected packs could not be loaded. |
| `409` `school-limit-reached` | `maxSchools` schools already exist. | | `409` `school-limit-reached` | `maxSchools` schools already exist. |
Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on. Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
@@ -83,17 +118,20 @@ frame is obvious at a glance.
| `0x82` | S → C | Pong | | `0x82` | S → C | Pong |
| `0x83` | S → C | Clock | | `0x83` | S → C | Clock |
| `0x84` | S → C | SchoolGone | | `0x84` | S → C | SchoolGone |
| `0x85` | S → C | MapSnapshot |
## Client → server ## Client → server
### `0x01` Hello — 2 bytes ### `0x01` Hello — 3 bytes
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds. Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
The locale byte is the same language the catalog HTTP API takes as `?lang=`.
| Offset | Type | Field | | Offset | Type | Field |
| --- | --- | --- | | --- | --- | --- |
| 0 | `u8` | `0x01` | | 0 | `u8` | `0x01` |
| 1 | `u8` | protocol version | | 1 | `u8` | protocol version |
| 2 | `u8` | locale: `0` Russian, `1` English; any other value is treated as Russian |
### `0x02` Ping — 9 bytes ### `0x02` Ping — 9 bytes
@@ -104,8 +142,8 @@ Must be the first frame; the server drops the connection if it does not arrive w
### `0x03` OpenSchool — 5 bytes ### `0x03` OpenSchool — 5 bytes
Starts watching a school: clock frames for it begin to arrive. It does not start the calendar — Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames.
every school runs on its own from the moment it is created. It does not start the calendar — every school runs on its own from the moment it is created.
| Offset | Type | Field | | Offset | Type | Field |
| --- | --- | --- | | --- | --- | --- |
@@ -182,16 +220,43 @@ client returns to the menu.
| 0 | `u8` | `0x84` | | 0 | `u8` | `0x84` |
| 1 | `i32` | school id | | 1 | `i32` | school id |
### `0x85` MapSnapshot — variable
Sent once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on
tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the
client keeps those sections empty.
Strings are `u16` byte length + UTF-8. Empty string is a zero length.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x85` |
| 1 | `i32` | school id |
| 5 | `u16` | node count |
| 7… | | nodes |
Each node:
| Type | Field |
| --- | --- |
| `u8` | kind: `0` territory, `1` building, `2` floor, `3` room |
| string | instance id |
| string | parent id (empty for the yard) |
| string | display name |
| `u8` | item count, then that many strings |
| `u8` | position count, then that many strings |
## Guarantees and limits ## Guarantees and limits
- Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. - Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`.
- A malformed frame closes the connection with `1007 InvalidPayloadData`. - A malformed frame closes the connection with `1007 InvalidPayloadData`.
- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking - Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
older clients within the same protocol version. older clients within the same protocol version.
- Clock delivery is lossy under back pressure: each connection buffers 32 frames and drops the - Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops
oldest, because a stale clock is worthless once a newer one exists. the oldest, because a stale clock is worthless once a newer one exists.
- The map snapshot uses a separate reliable queue so ticks cannot crowd it out.
## Not in v3 yet ## Not in v4 yet
Saving schools to disk (they live in server memory), authentication, and any game state beyond the Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered
calendar — the school's ECS world is created but still empty. on the client from the snapshot. The school's ECS world is created but still empty.
+32 -8
View File
@@ -38,6 +38,22 @@ const ru = {
errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.', errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.',
errorInvalidName: 'Название должно быть от 1 до 40 символов.', errorInvalidName: 'Название должно быть от 1 до 40 символов.',
errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', errorInvalidStartDate: 'Дата начала вне допустимого диапазона.',
errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.',
errorUnknownMod: 'Выбранный мод не найден.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды',
coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта',
resetMap: 'Сбросить к умолчанию',
editorSlots: 'Слоты',
editorLinks: 'Проходы',
addLink: 'Связать',
removeLink: 'Убрать',
addRoom: 'Добавить комнату',
removeRoom: 'Удалить комнату',
slotEmpty: '— пусто —',
backToMenu: '← В главное меню', backToMenu: '← В главное меню',
pause: 'Пауза', pause: 'Пауза',
@@ -55,10 +71,6 @@ const ru = {
charactersEmpty: 'Никого нет.', charactersEmpty: 'Никого нет.',
activitiesEmpty: 'Ничего не происходит.', activitiesEmpty: 'Ничего не происходит.',
positionsEmpty: 'Нет должностей.', positionsEmpty: 'Нет должностей.',
stubTerritory: 'Двор',
stubBuilding: 'Главный корпус',
stubFloor: '1 этаж',
stubRoom: 'Кабинет директора',
} as const; } as const;
type Messages = { [K in keyof typeof ru]: string }; type Messages = { [K in keyof typeof ru]: string };
@@ -101,6 +113,22 @@ const en: Messages = {
errorSchoolLimit: 'School limit reached — delete one to create another.', errorSchoolLimit: 'School limit reached — delete one to create another.',
errorInvalidName: 'The name must be 1 to 40 characters.', errorInvalidName: 'The name must be 1 to 40 characters.',
errorInvalidStartDate: 'The start date is outside the allowed range.', errorInvalidStartDate: 'The start date is outside the allowed range.',
errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.',
errorUnknownMod: 'A selected mod is missing.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods',
coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map',
resetMap: 'Reset to default',
editorSlots: 'Slots',
editorLinks: 'Passages',
addLink: 'Link',
removeLink: 'Remove',
addRoom: 'Add room',
removeRoom: 'Remove room',
slotEmpty: '— empty —',
backToMenu: '← Main menu', backToMenu: '← Main menu',
pause: 'Pause', pause: 'Pause',
@@ -118,10 +146,6 @@ const en: Messages = {
charactersEmpty: 'Nobody here.', charactersEmpty: 'Nobody here.',
activitiesEmpty: 'Nothing is happening.', activitiesEmpty: 'Nothing is happening.',
positionsEmpty: 'No positions.', positionsEmpty: 'No positions.',
stubTerritory: 'Yard',
stubBuilding: 'Main building',
stubFloor: 'Floor 1',
stubRoom: "Principal's office",
}; };
const catalogs: Record<Locale, Messages> = { ru, en }; const catalogs: Record<Locale, Messages> = { ru, en };
+8 -2
View File
@@ -1,5 +1,5 @@
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 { getLocale, onLocaleChange } from './i18n/locale.ts';
import { t, type MessageKey } from './i18n/strings.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 { localeSwitch } from './ui/localeSwitch.ts';
@@ -44,6 +44,11 @@ function bootstrap(): void {
game.update(clock); game.update(clock);
} }
}, },
onMapSnapshot: (snapshot) => {
if (openSchool?.id === snapshot.schoolId) {
game.applyMap(snapshot.schoolId, snapshot.nodes);
}
},
onSchoolGone: (schoolId) => { onSchoolGone: (schoolId) => {
// Deleted from another tab while we were inside it. // Deleted from another tab while we were inside it.
if (openSchool?.id === schoolId) { if (openSchool?.id === schoolId) {
@@ -54,7 +59,7 @@ function bootstrap(): void {
lastPingMs = rttMs; lastPingMs = rttMs;
paintChrome(); paintChrome();
}, },
}); }, getLocale);
function paintChrome(): void { function paintChrome(): void {
if (statusLabel !== null) { if (statusLabel !== null) {
@@ -90,6 +95,7 @@ function bootstrap(): void {
paintChrome(); paintChrome();
menu.localize(); menu.localize();
game.localize(); game.localize();
connection.reconnect();
}); });
paintChrome(); paintChrome();
+75 -2
View File
@@ -41,14 +41,87 @@ export async function fetchRandomName(lang: string): Promise<string> {
return response.name; return response.name;
} }
export async function createSchool(name: string, startDate: Date): Promise<School> { export interface CreateSchoolOptions {
readonly modIds?: readonly string[];
readonly map?: MapLayout;
}
export async function createSchool(
name: string,
startDate: Date,
extras: CreateSchoolOptions = {},
): Promise<School> {
return request<School>('/api/schools', { return request<School>('/api/schools', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, startDate: startDate.toISOString() }), body: JSON.stringify({
name,
startDate: startDate.toISOString(),
modIds: extras.modIds ?? [],
map: extras.map ?? null,
}),
}); });
} }
export interface ModInfo {
readonly id: string;
readonly required: boolean;
}
export interface DefInfo {
readonly defName: string;
readonly label: string;
}
export interface RoomSlotInfo {
readonly key: string;
readonly thing: string;
}
export interface RoomInfo {
readonly defName: string;
readonly label: string;
readonly slots: readonly RoomSlotInfo[];
readonly positions: readonly string[];
}
export interface MapLayout {
territory: { id: string; def: string };
buildings: { id: string; def: string }[];
floors: { id: string; def: string; building: string; label?: string }[];
rooms: {
id: string;
def: string;
building: string;
floor: string;
slots?: { key: string; thing: string }[];
}[];
links: { a: string; b: string }[];
}
export interface CatalogResponse {
readonly territories: readonly DefInfo[];
readonly buildings: readonly DefInfo[];
readonly floors: readonly DefInfo[];
readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
const response = await request<{ mods: readonly ModInfo[] }>('/api/mods');
return response.mods;
}
export async function fetchCatalog(lang: string, extraModIds: readonly string[]): Promise<CatalogResponse> {
const query = new URLSearchParams({ lang });
if (extraModIds.length > 0) {
query.set('mods', extraModIds.join(','));
}
return request<CatalogResponse>(`/api/catalog?${query.toString()}`);
}
export async function deleteSchool(id: number): Promise<void> { export async function deleteSchool(id: number): Promise<void> {
await request<void>(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false }); await request<void>(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false });
} }
+28 -2
View File
@@ -8,6 +8,7 @@ import {
encodeSetSpeed, encodeSetSpeed,
ProtocolError, ProtocolError,
type ClockMessage, type ClockMessage,
type MapSnapshotMessage,
type ServerMessage, type ServerMessage,
type WelcomeMessage, type WelcomeMessage,
} from './protocol.ts'; } from './protocol.ts';
@@ -18,6 +19,7 @@ export interface ConnectionHandlers {
onStatus?(status: ConnectionStatus): void; onStatus?(status: ConnectionStatus): void;
onWelcome?(message: WelcomeMessage): void; onWelcome?(message: WelcomeMessage): void;
onClock?(message: ClockMessage): void; onClock?(message: ClockMessage): void;
onMapSnapshot?(message: MapSnapshotMessage): void;
/** The open school was deleted elsewhere; the UI has to leave it. */ /** The open school was deleted elsewhere; the UI has to leave it. */
onSchoolGone?(schoolId: number): void; onSchoolGone?(schoolId: number): void;
/** Round-trip time in milliseconds. */ /** Round-trip time in milliseconds. */
@@ -45,6 +47,7 @@ export class GameConnection {
constructor( constructor(
private readonly url: string, private readonly url: string,
private readonly handlers: ConnectionHandlers = {}, private readonly handlers: ConnectionHandlers = {},
private readonly locale: () => 'ru' | 'en' = () => 'ru',
) {} ) {}
connect(): void { connect(): void {
@@ -57,7 +60,7 @@ export class GameConnection {
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
this.reconnectDelay = RECONNECT_MIN_MS; this.reconnectDelay = RECONNECT_MIN_MS;
socket.send(encodeHello()); socket.send(encodeHello(this.locale()));
this.handlers.onStatus?.('connected'); this.handlers.onStatus?.('connected');
this.startPinging(); this.startPinging();
@@ -67,10 +70,30 @@ export class GameConnection {
}); });
socket.addEventListener('message', (event) => this.handleMessage(event)); socket.addEventListener('message', (event) => this.handleMessage(event));
socket.addEventListener('close', () => this.handleClose()); socket.addEventListener('close', () => {
if (this.socket !== socket) {
return;
}
this.handleClose();
});
socket.addEventListener('error', () => socket.close()); socket.addEventListener('error', () => socket.close());
} }
/**
* Handshake locale changed: drop this socket and open a new one so Hello (and the next
* OpenSchool snapshot) match the footer language.
*/
reconnect(): void {
this.stopTimers();
this.closedByUs = false;
this.reconnectDelay = RECONNECT_MIN_MS;
const previous = this.socket;
this.socket = null;
previous?.close();
this.connect();
}
/** Starts watching a school; its calendar starts running server-side. */ /** Starts watching a school; its calendar starts running server-side. */
openSchool(schoolId: number): void { openSchool(schoolId: number): void {
this.openSchoolId = schoolId; this.openSchoolId = schoolId;
@@ -136,6 +159,9 @@ export class GameConnection {
case 'clock': case 'clock':
this.handlers.onClock?.(message); this.handlers.onClock?.(message);
break; break;
case 'map-snapshot':
this.handlers.onMapSnapshot?.(message);
break;
case 'school-gone': case 'school-gone':
if (this.openSchoolId === message.schoolId) { if (this.openSchoolId === message.schoolId) {
this.openSchoolId = null; this.openSchoolId = null;
+40 -3
View File
@@ -18,12 +18,13 @@ import {
* change, the C# codec and `docs/protocol.md` change with it. * change, the C# codec and `docs/protocol.md` change with it.
*/ */
describe('client encoders', () => { describe('client encoders', () => {
it('writes a two-byte hello carrying the version', () => { it('writes a three-byte hello carrying the version and locale', () => {
const view = new DataView(encodeHello()); const view = new DataView(encodeHello('en'));
expect(view.byteLength).toBe(2); expect(view.byteLength).toBe(3);
expect(view.getUint8(0)).toBe(MessageType.ClientHello); expect(view.getUint8(0)).toBe(MessageType.ClientHello);
expect(view.getUint8(1)).toBe(PROTOCOL_VERSION); expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
expect(view.getUint8(2)).toBe(1);
}); });
it('writes a ping frame carrying the client clock', () => { it('writes a ping frame carrying the client clock', () => {
@@ -123,6 +124,42 @@ describe('decodeServerMessage', () => {
expect(decodeServerMessage(buffer)).toEqual({ type: 'school-gone', schoolId: 3 }); expect(decodeServerMessage(buffer)).toEqual({ type: 'school-gone', schoolId: 3 });
}); });
it('reads a map snapshot and asserts the header offsets', () => {
const encoder = new TextEncoder();
const id = encoder.encode('yard');
const parent = encoder.encode('');
const name = encoder.encode('Двор');
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 1 + 1);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot);
view.setInt32(1, 7, true);
view.setUint16(5, 1, true);
let offset = 7;
view.setUint8(offset, 0);
offset += 1;
view.setUint16(offset, id.length, true);
offset += 2;
new Uint8Array(buffer).set(id, offset);
offset += id.length;
view.setUint16(offset, parent.length, true);
offset += 2;
view.setUint16(offset, name.length, true);
offset += 2;
new Uint8Array(buffer).set(name, offset);
offset += name.length;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot',
schoolId: 7,
nodes: [
{ kind: 0, id: 'yard', parentId: '', name: 'Двор', items: [], positions: [] },
],
});
});
it('ignores unknown message ids so new ones stay backwards compatible', () => { it('ignores unknown message ids so new ones stay backwards compatible', () => {
const buffer = new Uint8Array([0xf0, 0x00]).buffer; const buffer = new Uint8Array([0xf0, 0x00]).buffer;
+96 -4
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian. * changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/ */
export const PROTOCOL_VERSION = 3; export const PROTOCOL_VERSION = 4;
export const MessageType = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -18,6 +18,13 @@ export const MessageType = {
ServerPong: 0x82, ServerPong: 0x82,
ServerClock: 0x83, ServerClock: 0x83,
ServerSchoolGone: 0x84, ServerSchoolGone: 0x84,
ServerMapSnapshot: 0x85,
} as const;
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
export const WireLocale = {
ru: 0,
en: 1,
} as const; } as const;
/** /**
@@ -55,17 +62,45 @@ export interface SchoolGoneMessage {
readonly schoolId: number; readonly schoolId: number;
} }
export type ServerMessage = WelcomeMessage | PongMessage | ClockMessage | SchoolGoneMessage; export const MapNodeKind = {
Territory: 0,
Building: 1,
Floor: 2,
Room: 3,
} as const;
export interface MapSnapshotNode {
readonly kind: number;
readonly id: string;
readonly parentId: string;
readonly name: string;
readonly items: readonly string[];
readonly positions: readonly string[];
}
export interface MapSnapshotMessage {
readonly type: 'map-snapshot';
readonly schoolId: number;
readonly nodes: readonly MapSnapshotNode[];
}
export type ServerMessage =
| WelcomeMessage
| PongMessage
| ClockMessage
| SchoolGoneMessage
| MapSnapshotMessage;
/** Thrown when a frame is truncated or carries an unexpected message id. */ /** Thrown when a frame is truncated or carries an unexpected message id. */
export class ProtocolError extends Error {} export class ProtocolError extends Error {}
export function encodeHello(): ArrayBuffer { export function encodeHello(locale: 'ru' | 'en' = 'ru'): ArrayBuffer {
const buffer = new ArrayBuffer(2); const buffer = new ArrayBuffer(3);
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientHello); view.setUint8(0, MessageType.ClientHello);
view.setUint8(1, PROTOCOL_VERSION); view.setUint8(1, PROTOCOL_VERSION);
view.setUint8(2, locale === 'en' ? WireLocale.en : WireLocale.ru);
return buffer; return buffer;
} }
@@ -138,6 +173,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
return decodeClock(view); return decodeClock(view);
case MessageType.ServerSchoolGone: case MessageType.ServerSchoolGone:
return decodeSchoolGone(view); return decodeSchoolGone(view);
case MessageType.ServerMapSnapshot:
return decodeMapSnapshot(view);
default: default:
return null; return null;
} }
@@ -182,6 +219,61 @@ function decodeSchoolGone(view: DataView): SchoolGoneMessage {
return { type: 'school-gone', schoolId: view.getInt32(1, true) }; return { type: 'school-gone', schoolId: view.getInt32(1, true) };
} }
function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
ensure(view, 7);
const schoolId = view.getInt32(1, true);
const nodeCount = view.getUint16(5, true);
let offset = 7;
const nodes: MapSnapshotNode[] = [];
for (let i = 0; i < nodeCount; i++) {
const kind = readU8(view, offset);
offset += 1;
const id = readString(view, offset);
offset = id.next;
const parentId = readString(view, offset);
offset = parentId.next;
const name = readString(view, offset);
offset = name.next;
const itemCount = readU8(view, offset);
offset += 1;
const items: string[] = [];
for (let item = 0; item < itemCount; item++) {
const value = readString(view, offset);
items.push(value.text);
offset = value.next;
}
const positionCount = readU8(view, offset);
offset += 1;
const positions: string[] = [];
for (let position = 0; position < positionCount; position++) {
const value = readString(view, offset);
positions.push(value.text);
offset = value.next;
}
nodes.push({ kind, id: id.text, parentId: parentId.text, name: name.text, items, positions });
}
return { type: 'map-snapshot', schoolId, nodes };
}
function readU8(view: DataView, offset: number): number {
ensure(view, offset + 1);
return view.getUint8(offset);
}
function readString(view: DataView, offset: number): { text: string; next: number } {
ensure(view, offset + 2);
const length = view.getUint16(offset, true);
const start = offset + 2;
ensure(view, start + length);
const bytes = new Uint8Array(view.buffer, view.byteOffset + start, length);
return { text: new TextDecoder().decode(bytes), next: start + length };
}
function ensure(view: DataView, bytes: number): void { function ensure(view: DataView, bytes: number): void {
if (view.byteLength < bytes) { if (view.byteLength < bytes) {
throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`); throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
+62
View File
@@ -246,6 +246,12 @@ body {
font-size: 13px; font-size: 13px;
} }
.panel__list {
margin: 0;
padding-left: 18px;
font-size: 13px;
}
.tree { .tree {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -363,6 +369,12 @@ body {
color: var(--text); color: var(--text);
} }
.dialog--wide {
min-width: 640px;
max-width: 860px;
width: min(860px, calc(100vw - 32px));
}
.dialog::backdrop { .dialog::backdrop {
background: rgba(6, 9, 14, 0.7); background: rgba(6, 9, 14, 0.7);
} }
@@ -411,3 +423,53 @@ body {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.mod-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.mod-list__item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.map-editor-host {
max-height: 360px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.map-editor {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr);
gap: 12px;
}
.map-editor__details {
display: flex;
flex-direction: column;
gap: 10px;
}
.map-editor__links {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.map-editor__link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
+142 -23
View File
@@ -1,27 +1,51 @@
import { ApiError, type School } from '../net/api.ts'; import {
ApiError,
fetchCatalog,
fetchMods,
type CatalogResponse,
type CreateSchoolOptions as CreateExtras,
type MapLayout,
type School,
} from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts'; import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { mapEditor } from './mapEditor.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
interface CreateSchoolOptions { interface CreateSchoolOptions {
/** Prefilled start of the school year, straight from the server config. */ /** Prefilled start of the school year, straight from the server config. */
readonly defaultStartDate: Date; readonly defaultStartDate: Date;
readonly suggestName: () => Promise<string>; readonly suggestName: () => Promise<string>;
readonly create: (name: string, startDate: Date) => Promise<School>; readonly create: (name: string, startDate: Date, extras: CreateExtras) => Promise<School>;
} }
/** /**
* The creation form: a name (typed or rolled), a start date and a create button. * Creation form: mods, a map (edit or reset to the pack default), then name and start date.
* Resolves with the created school, or `null` when the player backs out.
*/ */
export function createSchoolDialog(options: CreateSchoolOptions): Promise<School | null> { export function createSchoolDialog(options: CreateSchoolOptions): Promise<School | null> {
const modal = new Modal<School | null>(null); const modal = new Modal<School | null>(null);
modal.element.classList.add('dialog--wide');
const defaults = toDateAndTimeInputs(options.defaultStartDate); const defaults = toDateAndTimeInputs(options.defaultStartDate);
const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let editor: ReturnType<typeof mapEditor> | null = null;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
const modsList = el('div', { class: 'mod-list' });
modsField.append(modsLabel, modsList);
const mapLabel = el('span', { class: 'field__label' });
const resetButton = el('button', { class: 'button', type: 'button' });
const editorHost = el('div', { class: 'map-editor-host' });
const mapField = el('div', { class: 'field' }, mapLabel, el('div', { class: 'field__row' }, resetButton), editorHost);
const nameInput = el('input', { class: 'input', type: 'text' }); const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40; nameInput.maxLength = 40;
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' });
@@ -35,18 +59,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const error = el('p', { class: 'dialog__error' }); const error = el('p', { class: 'dialog__error' });
error.hidden = true; error.hidden = true;
const randomButton = el('button', { const randomButton = el('button', { class: 'button', type: 'button' });
class: 'button', const submitButton = el('button', { class: 'button button--primary', type: 'submit' });
type: 'button', const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
text: t('randomName'), const title = el('h2', { class: 'dialog__title' });
title: t('randomNameTitle'),
});
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: t('create') });
const form = el( const form = el(
'form', 'form',
{ class: 'form' }, { class: 'form' },
modsField,
mapField,
el( el(
'label', 'label',
{ class: 'field' }, { class: 'field' },
@@ -60,20 +82,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('div', { class: 'field__row' }, dateInput, timeInput), el('div', { class: 'field__row' }, dateInput, timeInput),
), ),
error, error,
el( el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
'div',
{ class: 'dialog__actions' },
el('button', { class: 'button', type: 'button', text: t('cancel'), onClick: () => modal.close(null) }),
submitButton,
),
); );
let busy = false; let busy = false;
const setBusy = (value: boolean): void => { const setBusy = (value: boolean): void => {
busy = value; busy = value;
submitButton.toggleAttribute('disabled', value); submitButton.toggleAttribute('disabled', value || catalog === null);
randomButton.toggleAttribute('disabled', value); randomButton.toggleAttribute('disabled', value);
resetButton.toggleAttribute('disabled', value || catalog === null);
}; };
const showError = (message: string): void => { const showError = (message: string): void => {
@@ -81,6 +99,97 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
error.hidden = false; error.hidden = false;
}; };
const applyCatalog = (next: CatalogResponse): void => {
catalog = next;
currentMap = structuredClone(next.defaultMap);
if (editor === null) {
editor = mapEditor({
catalog: next,
map: currentMap,
onChange: (map) => {
currentMap = map;
},
});
editorHost.append(editor.element);
} else {
editor.setCatalog(next, currentMap);
}
setBusy(busy);
};
const reloadCatalog = async (): Promise<void> => {
try {
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
error.hidden = true;
} catch {
showError(t('catalogLoadFailed'));
}
};
const paintMods = async (): Promise<void> => {
let packs;
try {
packs = await fetchMods();
} catch {
showError(t('catalogLoadFailed'));
return;
}
modsList.replaceChildren();
for (const pack of packs) {
const checkbox = el('input', { type: 'checkbox' });
checkbox.checked = pack.required || extraModIds.has(pack.id);
checkbox.disabled = pack.required;
checkbox.addEventListener('change', () => {
if (pack.required) {
return;
}
if (checkbox.checked) {
extraModIds.add(pack.id);
} else {
extraModIds.delete(pack.id);
}
void reloadCatalog();
});
const label = el(
'label',
{ class: 'mod-list__item' },
checkbox,
pack.required ? t('coreModLocked', { id: pack.id }) : pack.id,
);
modsList.append(label);
}
await reloadCatalog();
};
resetButton.addEventListener('click', () => {
if (catalog === null) {
return;
}
applyCatalog(catalog);
});
const localize = (): void => {
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
mapLabel.textContent = t('mapEditorTitle');
resetButton.textContent = t('resetMap');
nameInput.placeholder = t('schoolNamePlaceholder');
randomButton.textContent = t('randomName');
randomButton.title = t('randomNameTitle');
submitButton.textContent = t('create');
cancelButton.textContent = t('cancel');
editor?.localize();
};
localize();
randomButton.addEventListener('click', () => { randomButton.addEventListener('click', () => {
if (busy) { if (busy) {
return; return;
@@ -99,7 +208,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
if (busy) { if (busy || currentMap === null) {
return; return;
} }
@@ -111,7 +220,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
setBusy(true); setBusy(true);
options options
.create(nameInput.value.trim(), startDate) .create(nameInput.value.trim(), startDate, {
modIds: [...extraModIds],
map: currentMap,
})
.then((school) => modal.close(school)) .then((school) => modal.close(school))
.catch((reason: unknown) => { .catch((reason: unknown) => {
showError(describe(reason)); showError(describe(reason));
@@ -119,7 +231,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
}); });
}); });
modal.element.append(el('h2', { class: 'dialog__title', text: t('newSchool') }), form); modal.element.append(title, form);
void paintMods();
return modal.open(nameInput); return modal.open(nameInput);
} }
@@ -136,6 +249,12 @@ function describe(reason: unknown): string {
return t('errorInvalidName'); return t('errorInvalidName');
case 'invalid-start-date': case 'invalid-start-date':
return t('errorInvalidStartDate'); return t('errorInvalidStartDate');
case 'invalid-map':
return t('errorInvalidMap');
case 'unknown-mod':
return t('errorUnknownMod');
case 'invalid-catalog':
return t('errorInvalidCatalog');
default: default:
return reason.message; return reason.message;
} }
+56 -65
View File
@@ -1,8 +1,8 @@
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts'; import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotNode } from '../net/protocol.ts';
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.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 { clear, el } from './dom.ts';
interface GameScreenOptions { interface GameScreenOptions {
readonly onLeave: () => void; readonly onLeave: () => void;
@@ -10,37 +10,12 @@ interface GameScreenOptions {
readonly onSetSpeed: (speedIndex: number) => void; readonly onSetSpeed: (speedIndex: number) => void;
} }
interface StubNode {
readonly id: string;
readonly labelKey: MessageKey;
readonly children?: readonly StubNode[];
}
/** Placeholder tree until the server sends a map snapshot. Clicking only filters the location panel. */
const STUB_MAP: StubNode = {
id: 'territory',
labelKey: 'stubTerritory',
children: [
{
id: 'building',
labelKey: 'stubBuilding',
children: [
{
id: 'floor',
labelKey: 'stubFloor',
children: [{ id: 'room', labelKey: 'stubRoom' }],
},
],
},
],
};
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4']; const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
/** /**
* The inside of a school: calendar controls plus the manager shell. Data for the tree and * The inside of a school: calendar controls plus the manager shell. The tree and location
* location lists arrives in a later phase; until then the panels stay empty except for stubs * lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot
* that prove the tree filters the location pane on the client. * on the client. People, in-place activities and events stay empty in this slice.
*/ */
export class GameScreen { export class GameScreen {
private readonly root = el('section', { class: 'screen game' }); private readonly root = el('section', { class: 'screen game' });
@@ -60,15 +35,19 @@ export class GameScreen {
private readonly locationName = el('p', { class: 'panel__name' }); private readonly locationName = el('p', { class: 'panel__name' });
private readonly itemsHeading = el('h3', { class: 'panel__section-title' }); private readonly itemsHeading = el('h3', { class: 'panel__section-title' });
private readonly itemsEmpty = el('p', { class: 'panel__empty' }); private readonly itemsEmpty = el('p', { class: 'panel__empty' });
private readonly itemsList = el('ul', { class: 'panel__list' });
private readonly charactersHeading = el('h3', { class: 'panel__section-title' }); private readonly charactersHeading = el('h3', { class: 'panel__section-title' });
private readonly charactersEmpty = el('p', { class: 'panel__empty' }); private readonly charactersEmpty = el('p', { class: 'panel__empty' });
private readonly activitiesHeading = el('h3', { class: 'panel__section-title' }); private readonly activitiesHeading = el('h3', { class: 'panel__section-title' });
private readonly activitiesEmpty = el('p', { class: 'panel__empty' }); private readonly activitiesEmpty = el('p', { class: 'panel__empty' });
private readonly positionsHeading = el('h3', { class: 'panel__section-title' }); private readonly positionsHeading = el('h3', { class: 'panel__section-title' });
private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsEmpty = el('p', { class: 'panel__empty' });
private readonly positionsList = el('ul', { class: 'panel__list' });
private readonly treeButtons = new Map<string, HTMLButtonElement>(); private readonly treeButtons = new Map<string, HTMLButtonElement>();
private selectedId = STUB_MAP.id; private nodes: readonly MapSnapshotNode[] = [];
private selectedId: string | null = null;
private schoolId: number | null = null;
private running = false; private running = false;
private lastGameTime: Date | null = null; private lastGameTime: Date | null = null;
private lastSpeedIndex = 0; private lastSpeedIndex = 0;
@@ -86,8 +65,6 @@ export class GameScreen {
this.backButton.addEventListener('click', options.onLeave); this.backButton.addEventListener('click', options.onLeave);
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
this.buildTree(this.tree, [STUB_MAP], 0);
this.root.append( this.root.append(
el('header', { class: 'screen__header' }, this.backButton, this.schoolName), el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el( el(
@@ -108,10 +85,10 @@ export class GameScreen {
{ class: 'panel' }, { class: 'panel' },
this.locationTitle, this.locationTitle,
this.locationName, this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty), el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty), el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
), ),
), ),
); );
@@ -138,7 +115,6 @@ export class GameScreen {
this.positionsHeading.textContent = t('locationPositions'); this.positionsHeading.textContent = t('locationPositions');
this.positionsEmpty.textContent = t('positionsEmpty'); this.positionsEmpty.textContent = t('positionsEmpty');
this.paintTreeLabels(STUB_MAP);
this.paintSelection(); this.paintSelection();
if (this.lastGameTime !== null) { if (this.lastGameTime !== null) {
@@ -148,31 +124,56 @@ export class GameScreen {
} }
} }
/** Called when the screen opens, before the first clock frame arrives. */ /** Called when the screen opens, before the first clock frame and snapshot arrive. */
show(school: School): void { show(school: School): void {
this.schoolId = school.id;
this.schoolName.textContent = school.name; this.schoolName.textContent = school.name;
this.nodes = [];
this.selectedId = null;
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
} }
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
if (this.schoolId !== schoolId) {
return;
}
this.nodes = nodes;
this.selectedId = this.selectedId !== null && nodes.some((node) => node.id === this.selectedId)
? this.selectedId
: (nodes[0]?.id ?? null);
this.rebuildTree();
this.paintSelection();
}
update(clock: ClockMessage): void { update(clock: ClockMessage): void {
this.applyClock(clock.gameTime, clock.running, clock.speedIndex); this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
} }
private buildTree(list: HTMLUListElement, nodes: readonly StubNode[], depth: number): void { private rebuildTree(): void {
clear(this.tree);
this.treeButtons.clear();
this.buildTree(this.tree, childrenOf(this.nodes, ''), 0);
}
private buildTree(list: HTMLUListElement, nodes: readonly MapSnapshotNode[], depth: number): void {
for (const node of nodes) { for (const node of nodes) {
const item = el('li', { class: 'tree__node' }); const item = el('li', { class: 'tree__node' });
const button = el('button', { const button = el('button', {
class: 'tree__button', class: 'tree__button',
type: 'button', type: 'button',
text: node.name,
onClick: () => this.select(node.id), onClick: () => this.select(node.id),
}); });
button.style.paddingLeft = `${8 + depth * 14}px`; button.style.paddingLeft = `${8 + depth * 14}px`;
this.treeButtons.set(node.id, button); this.treeButtons.set(node.id, button);
item.append(button); item.append(button);
if (node.children !== undefined && node.children.length > 0) { const nestedNodes = childrenOf(this.nodes, node.id);
if (nestedNodes.length > 0) {
const nested = el('ul', { class: 'tree' }); const nested = el('ul', { class: 'tree' });
this.buildTree(nested, node.children, depth + 1); this.buildTree(nested, nestedNodes, depth + 1);
item.append(nested); item.append(nested);
} }
@@ -180,17 +181,6 @@ export class GameScreen {
} }
} }
private paintTreeLabels(node: StubNode): void {
const button = this.treeButtons.get(node.id);
if (button !== undefined) {
button.textContent = t(node.labelKey);
}
for (const child of node.children ?? []) {
this.paintTreeLabels(child);
}
}
private select(id: string): void { private select(id: string): void {
this.selectedId = id; this.selectedId = id;
this.paintSelection(); this.paintSelection();
@@ -201,8 +191,10 @@ export class GameScreen {
button.classList.toggle('tree__button--active', id === this.selectedId); button.classList.toggle('tree__button--active', id === this.selectedId);
} }
const labelKey = findStub(STUB_MAP, this.selectedId)?.labelKey; const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
this.locationName.textContent = labelKey === undefined ? '' : t(labelKey); this.locationName.textContent = node?.name ?? '';
paintList(this.itemsList, this.itemsEmpty, node?.items ?? []);
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
} }
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void { private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
@@ -223,17 +215,16 @@ export class GameScreen {
} }
} }
function findStub(node: StubNode, id: string): StubNode | null { function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSnapshotNode[] {
if (node.id === id) { return nodes.filter((node) => node.parentId === parentId);
return node; }
}
function paintList(list: HTMLUListElement, empty: HTMLParagraphElement, values: readonly string[]): void {
clear(list);
empty.hidden = values.length > 0;
list.hidden = values.length === 0;
for (const child of node.children ?? []) { for (const value of values) {
const match = findStub(child, id); list.append(el('li', { text: value }));
if (match !== null) {
return match;
} }
}
return null;
} }
+316
View File
@@ -0,0 +1,316 @@
import type { CatalogResponse, MapLayout, RoomInfo } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
interface MapEditorOptions {
catalog: CatalogResponse;
map: MapLayout;
onChange: (map: MapLayout) => void;
}
/**
* Create-only map editor: tree, slot fills, passage links, add/remove room, reset to the pack default.
* The server still validates the graph on POST.
*/
export function mapEditor(options: MapEditorOptions): {
readonly element: HTMLElement;
setCatalog: (catalog: CatalogResponse, map: MapLayout) => void;
localize: () => void;
} {
let catalog = options.catalog;
let map = cloneMap(options.map);
let selectedId: string | null = map.territory.id;
const tree = el('ul', { class: 'tree map-editor__tree' });
const details = el('div', { class: 'map-editor__details' });
const root = el(
'div',
{ class: 'map-editor' },
el('div', { class: 'map-editor__pane' }, tree),
details,
);
const emit = (): void => {
options.onChange(cloneMap(map));
render();
};
const render = (): void => {
paintTree();
paintDetails();
};
const paintTree = (): void => {
clear(tree);
appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0);
for (const building of map.buildings) {
appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1);
for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) {
const floorName = floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? floor.id);
appendNode(tree, floor.id, floorName, 2);
for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) {
appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3);
}
}
}
};
const appendNode = (list: HTMLUListElement, id: string, name: string, depth: number): void => {
const item = el('li', { class: 'tree__node' });
const button = el('button', {
class: selectedId === id ? 'tree__button tree__button--active' : 'tree__button',
type: 'button',
text: name,
onClick: () => {
selectedId = id;
render();
},
});
button.style.paddingLeft = `${8 + depth * 14}px`;
item.append(button);
list.append(item);
};
const paintDetails = (): void => {
clear(details);
const room = map.rooms.find((candidate) => candidate.id === selectedId);
const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def);
if (room !== undefined && roomDef !== undefined) {
details.append(el('p', { class: 'field__label', text: t('editorSlots') }));
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
select.addEventListener('change', () => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
slots.push({ key: slot.key, thing: select.value });
}
room.slots = slots;
emit();
});
details.append(
el('label', { class: 'field' }, el('span', { class: 'field__label', text: slot.key }), select),
);
}
const remove = el('button', {
class: 'button',
type: 'button',
text: t('removeRoom'),
onClick: () => {
map.rooms = map.rooms.filter((candidate) => candidate.id !== room.id);
map.links = map.links.filter((link) => link.a !== room.id && link.b !== room.id);
selectedId = map.territory.id;
emit();
},
});
details.append(remove);
}
details.append(el('p', { class: 'field__label', text: t('editorLinks') }));
const linksList = el('ul', { class: 'map-editor__links' });
for (const [index, link] of map.links.entries()) {
const row = el('li', { class: 'map-editor__link' });
row.append(
el('span', { text: `${labelOf(catalog, map, link.a)}${labelOf(catalog, map, link.b)}` }),
el('button', {
class: 'button button--small',
type: 'button',
text: t('removeLink'),
onClick: () => {
map.links = map.links.filter((_, current) => current !== index);
emit();
},
}),
);
linksList.append(row);
}
details.append(linksList);
const walkable = walkableIds(map);
const from = selectOf(walkable, catalog, map);
const to = selectOf(walkable, catalog, map);
details.append(
el(
'div',
{ class: 'field__row' },
from,
to,
el('button', {
class: 'button',
type: 'button',
text: t('addLink'),
onClick: () => {
const a = from.value;
const b = to.value;
if (a === b || hasLink(map, a, b)) {
return;
}
map.links.push({ a, b });
emit();
},
}),
),
);
const roomSelect = el('select', { class: 'input' });
for (const def of catalog.rooms) {
const option = el('option', { text: def.label });
option.value = def.defName;
roomSelect.append(option);
}
details.append(
el(
'div',
{ class: 'field__row' },
roomSelect,
el('button', {
class: 'button',
type: 'button',
text: t('addRoom'),
disabled: catalog.rooms.length === 0 || map.buildings.length === 0 || map.floors.length === 0,
onClick: () => {
const def = catalog.rooms.find((candidate) => candidate.defName === roomSelect.value);
const building = map.buildings[0];
const floor = map.floors[0];
if (def === undefined || building === undefined || floor === undefined) {
return;
}
const id = nextId(slug(def.defName), usedIds(map));
map.rooms.push({
id,
def: def.defName,
building: building.id,
floor: floor.id,
slots: defaultSlots(def),
});
if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id });
}
selectedId = id;
emit();
},
}),
),
);
};
render();
return {
element: root,
setCatalog: (nextCatalog, nextMap) => {
catalog = nextCatalog;
map = cloneMap(nextMap);
selectedId = map.territory.id;
render();
},
localize: () => render(),
};
}
function cloneMap(map: MapLayout): MapLayout {
return structuredClone(map);
}
function usedIds(map: MapLayout): Set<string> {
return new Set([
map.territory.id,
...map.buildings.map((node) => node.id),
...map.floors.map((node) => node.id),
...map.rooms.map((node) => node.id),
]);
}
function walkableIds(map: MapLayout): string[] {
return [map.territory.id, ...map.rooms.map((room) => room.id)];
}
function hasLink(map: MapLayout, a: string, b: string): boolean {
return map.links.some(
(link) => (link.a === a && link.b === b) || (link.a === b && link.b === a),
);
}
function defaultSlots(def: RoomInfo): { key: string; thing: string }[] {
return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing }));
}
function nextId(prefix: string, used: Set<string>): string {
let n = 1;
let candidate = `${prefix}-${n}`;
while (used.has(candidate)) {
n += 1;
candidate = `${prefix}-${n}`;
}
return candidate;
}
function slug(defName: string): string {
return defName
.replaceAll(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
}
function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined {
return defs.find((def) => def.defName === defName)?.label;
}
function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string {
if (map.territory.id === id) {
return defLabel(catalog.territories, map.territory.def) ?? id;
}
const building = map.buildings.find((node) => node.id === id);
if (building !== undefined) {
return defLabel(catalog.buildings, building.def) ?? id;
}
const floor = map.floors.find((node) => node.id === id);
if (floor !== undefined) {
return floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? id);
}
const room = map.rooms.find((node) => node.id === id);
if (room !== undefined) {
return defLabel(catalog.rooms, room.def) ?? id;
}
return id;
}
function selectOf(ids: readonly string[], catalog: CatalogResponse, map: MapLayout): HTMLSelectElement {
const select = el('select', { class: 'input' });
for (const id of ids) {
const option = el('option', { text: labelOf(catalog, map, id) });
option.value = id;
select.append(option);
}
return select;
}
+133
View File
@@ -0,0 +1,133 @@
namespace HSchool.Content;
/// <summary>Kind of a map-tree node, matching the snapshot wire values.</summary>
public enum MapNodeKind : byte
{
Territory = 0,
Building = 1,
Floor = 2,
Room = 3,
}
/// <summary>One labelled node of a school's map, ready to put on the wire or in a test.</summary>
public sealed class MapViewNode
{
public required MapNodeKind Kind { get; init; }
public required string Id { get; init; }
/// <summary>Empty for the yard.</summary>
public required string ParentId { get; init; }
public required string Name { get; init; }
public IReadOnlyList<string> Items { get; init; } = [];
public IReadOnlyList<string> Positions { get; init; } = [];
}
/// <summary>
/// Builds the location tree the client draws. Labels come from the frozen catalog in the
/// requested locale; people and in-place activities are not part of this view.
/// </summary>
public static class MapView
{
public static IReadOnlyList<MapViewNode> Build(DefCatalog catalog, MapLayout map, string locale)
{
if (map.Territory is not { } territory)
{
return [];
}
var nodes = new List<MapViewNode>
{
Node(
MapNodeKind.Territory,
territory.Id,
parentId: string.Empty,
LabelOf(catalog, locale, DefKind.Territory, territory.Def),
items: [],
PositionsOf(catalog, locale, DefKind.Territory, territory.Def)),
};
foreach (var building in map.Buildings)
{
nodes.Add(Node(
MapNodeKind.Building,
building.Id,
territory.Id,
LabelOf(catalog, locale, DefKind.Building, building.Def),
[],
PositionsOf(catalog, locale, DefKind.Building, building.Def)));
}
foreach (var floor in map.Floors)
{
var defLabel = LabelOf(catalog, locale, DefKind.Floor, floor.Def);
var name = string.IsNullOrWhiteSpace(floor.Label) ? defLabel : floor.Label;
nodes.Add(Node(
MapNodeKind.Floor,
floor.Id,
floor.Building,
name,
[],
PositionsOf(catalog, locale, DefKind.Floor, floor.Def)));
}
foreach (var room in map.Rooms)
{
var items = new List<string>(room.Slots.Count);
foreach (var fill in room.Slots)
{
items.Add(LabelOf(catalog, locale, DefKind.Thing, fill.Thing));
}
nodes.Add(Node(
MapNodeKind.Room,
room.Id,
room.Floor,
LabelOf(catalog, locale, DefKind.Room, room.Def),
items,
PositionsOf(catalog, locale, DefKind.Room, room.Def)));
}
return nodes;
}
private static MapViewNode Node(
MapNodeKind kind,
string id,
string parentId,
string name,
IReadOnlyList<string> items,
IReadOnlyList<string> positions) =>
new()
{
Kind = kind,
Id = id,
ParentId = parentId,
Name = name,
Items = items,
Positions = positions,
};
private static string LabelOf(DefCatalog catalog, string locale, DefKind kind, string defName) =>
catalog.TryGet(kind, defName, out var def) ? catalog.Label(locale, def) : defName;
private static IReadOnlyList<string> PositionsOf(DefCatalog catalog, string locale, DefKind kind, string defName)
{
var names = catalog.PositionsFor(kind, defName);
if (names.Count == 0)
{
return [];
}
var labels = new string[names.Count];
for (var i = 0; i < names.Count; i++)
{
labels[i] = LabelOf(catalog, locale, DefKind.Position, names[i]);
}
return labels;
}
}
+1
View File
@@ -19,4 +19,5 @@ public enum MessageType : byte
ServerPong = 0x82, ServerPong = 0x82,
ServerClock = 0x83, ServerClock = 0x83,
ServerSchoolGone = 0x84, ServerSchoolGone = 0x84,
ServerMapSnapshot = 0x85,
} }
+23 -2
View File
@@ -1,7 +1,10 @@
namespace HSchool.Protocol; namespace HSchool.Protocol;
/// <summary>First frame from the client; carries nothing but the version handshake.</summary> /// <summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion); /// First frame from the client. <paramref name="Locale"/> is <see cref="ProtocolConstants.LocaleRussian"/>
/// or <see cref="ProtocolConstants.LocaleEnglish"/> — the same language the catalog HTTP API uses.
/// </summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion, byte Locale);
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary> /// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
public readonly record struct ClientPingMessage(long ClientTimeMs); public readonly record struct ClientPingMessage(long ClientTimeMs);
@@ -37,3 +40,21 @@ public readonly record struct ServerClockMessage(
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary> /// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId); public readonly record struct ServerSchoolGoneMessage(int SchoolId);
/// <summary>
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
/// <paramref name="ParentId"/> is empty for the yard.
/// </summary>
public sealed record MapSnapshotNode(
byte Kind,
string Id,
string ParentId,
string Name,
IReadOnlyList<string> Items,
IReadOnlyList<string> Positions);
/// <summary>
/// One school's map, labelled in the Hello locale. Sent once when that school is opened, not every tick.
/// People and in-place activities are omitted — the client keeps those sections empty.
/// </summary>
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
+19
View File
@@ -1,4 +1,5 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol; namespace HSchool.Protocol;
@@ -22,6 +23,14 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
public MessageType ReadMessageType() => (MessageType)ReadByte(); public MessageType ReadMessageType() => (MessageType)ReadByte();
public ushort ReadUInt16()
{
EnsureAvailable(sizeof(ushort));
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
_position += sizeof(ushort);
return value;
}
public uint ReadUInt32() public uint ReadUInt32()
{ {
EnsureAvailable(sizeof(uint)); EnsureAvailable(sizeof(uint));
@@ -30,6 +39,16 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
return value; return value;
} }
/// <summary><c>u16</c> byte length, then UTF-8. Empty string is a zero length.</summary>
public string ReadString()
{
var byteCount = ReadUInt16();
EnsureAvailable(byteCount);
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
_position += byteCount;
return value;
}
public int ReadInt32() public int ReadInt32()
{ {
EnsureAvailable(sizeof(int)); EnsureAvailable(sizeof(int));
+23
View File
@@ -1,4 +1,5 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol; namespace HSchool.Protocol;
@@ -22,6 +23,13 @@ public ref struct PacketWriter(Span<byte> buffer)
public void WriteMessageType(MessageType value) => WriteByte((byte)value); public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt16(ushort value)
{
EnsureRoom(sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
_position += sizeof(ushort);
}
public void WriteUInt32(uint value) public void WriteUInt32(uint value)
{ {
EnsureRoom(sizeof(uint)); EnsureRoom(sizeof(uint));
@@ -29,6 +37,21 @@ public ref struct PacketWriter(Span<byte> buffer)
_position += sizeof(uint); _position += sizeof(uint);
} }
/// <summary><c>u16</c> byte length, then UTF-8. Empty string is a zero length.</summary>
public void WriteString(string value)
{
var byteCount = Encoding.UTF8.GetByteCount(value);
if (byteCount > ushort.MaxValue)
{
throw new ProtocolException($"String is {byteCount} bytes; u16 length cannot hold it.");
}
WriteUInt16((ushort)byteCount);
EnsureRoom(byteCount);
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
_position += byteCount;
}
public void WriteInt32(int value) public void WriteInt32(int value)
{ {
EnsureRoom(sizeof(int)); EnsureRoom(sizeof(int));
+81 -2
View File
@@ -7,7 +7,10 @@ namespace HSchool.Protocol;
/// </summary> /// </summary>
public static class ProtocolCodec public static class ProtocolCodec
{ {
/// <summary>Largest frame this codec produces; handlers can size their buffers from it.</summary> /// <summary>
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots use
/// <see cref="ProtocolConstants.MaxMessageSize"/> instead.
/// </summary>
public const int MaxFrameSize = 16; public const int MaxFrameSize = 16;
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message) public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
@@ -15,6 +18,7 @@ public static class ProtocolCodec
var writer = new PacketWriter(destination); var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello); writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion); writer.WriteByte(message.ProtocolVersion);
writer.WriteByte(message.Locale);
return writer.Position; return writer.Position;
} }
@@ -95,6 +99,45 @@ public static class ProtocolCodec
return writer.Position; return writer.Position;
} }
public static int WriteMapSnapshot(Span<byte> destination, ServerMapSnapshotMessage message)
{
if (message.Nodes.Count > ushort.MaxValue)
{
throw new ProtocolException($"Map snapshot has {message.Nodes.Count} nodes; u16 count cannot hold it.");
}
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerMapSnapshot);
writer.WriteInt32(message.SchoolId);
writer.WriteUInt16((ushort)message.Nodes.Count);
foreach (var node in message.Nodes)
{
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue)
{
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
}
writer.WriteByte(node.Kind);
writer.WriteString(node.Id);
writer.WriteString(node.ParentId);
writer.WriteString(node.Name);
writer.WriteByte((byte)node.Items.Count);
foreach (var item in node.Items)
{
writer.WriteString(item);
}
writer.WriteByte((byte)node.Positions.Count);
foreach (var position in node.Positions)
{
writer.WriteString(position);
}
}
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) => public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0]; source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -102,7 +145,9 @@ public static class ProtocolCodec
{ {
var reader = new PacketReader(source); var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello); Expect(ref reader, MessageType.ClientHello);
return new ClientHelloMessage(reader.ReadByte()); var version = reader.ReadByte();
var locale = reader.ReadByte();
return new ClientHelloMessage(version, locale);
} }
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source) public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
@@ -170,6 +215,40 @@ public static class ProtocolCodec
return new ServerSchoolGoneMessage(reader.ReadInt32()); return new ServerSchoolGoneMessage(reader.ReadInt32());
} }
public static ServerMapSnapshotMessage ReadMapSnapshot(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerMapSnapshot);
var schoolId = reader.ReadInt32();
var nodeCount = reader.ReadUInt16();
var nodes = new MapSnapshotNode[nodeCount];
for (var i = 0; i < nodeCount; i++)
{
var kind = reader.ReadByte();
var id = reader.ReadString();
var parentId = reader.ReadString();
var name = reader.ReadString();
var itemCount = reader.ReadByte();
var items = new string[itemCount];
for (var item = 0; item < itemCount; item++)
{
items[item] = reader.ReadString();
}
var positionCount = reader.ReadByte();
var positions = new string[positionCount];
for (var position = 0; position < positionCount; position++)
{
positions[position] = reader.ReadString();
}
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, items, positions);
}
return new ServerMapSnapshotMessage(schoolId, nodes);
}
private static void Expect(ref PacketReader reader, MessageType expected) private static void Expect(ref PacketReader reader, MessageType expected)
{ {
var actual = reader.ReadMessageType(); var actual = reader.ReadMessageType();
+11 -1
View File
@@ -4,8 +4,18 @@ namespace HSchool.Protocol;
public static class ProtocolConstants public static class ProtocolConstants
{ {
/// <summary>Bumped on every breaking change to the binary layout.</summary> /// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 3; public const byte Version = 4;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary> /// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024; public const int MaxMessageSize = 8 * 1024;
/// <summary>Hello locale byte: Russian. Any other value that is not <see cref="LocaleEnglish"/> is treated as this.</summary>
public const byte LocaleRussian = 0;
/// <summary>Hello locale byte: English. Same value the catalog HTTP API takes as <c>lang=en</c>.</summary>
public const byte LocaleEnglish = 1;
/// <summary>Catalog locale string matching the Hello byte. Unknown bytes fall back to Russian.</summary>
public static string CatalogLocale(byte locale) =>
locale == LocaleEnglish ? "en" : "ru";
} }
+128
View File
@@ -0,0 +1,128 @@
using HSchool.Content;
using HSchool.Server.Game;
namespace HSchool.Server.Api;
/// <summary>
/// Catalog for the create dialog. Loading defs here is safe: Content is immutable and this never
/// touches a live <c>School</c> or its worker.
/// </summary>
internal static class ModEndpoints
{
public static void MapModEndpoints(this IEndpointRouteBuilder builder)
{
builder.MapGet("/api/mods", (ModContent mods) =>
new ModsResponse(mods.ListPacks().Select(pack => new ModInfoResponse(pack.Id, pack.Required)).ToArray()))
.WithName("GetMods");
builder.MapGet("/api/catalog", (string? lang, string? mods, ModContent content) =>
{
var extras = ParseModIds(mods);
foreach (var packId in extras)
{
if (!ModContent.IsSafePackId(packId) || !content.PackExists(packId))
{
return Problem(StatusCodes.Status400BadRequest, "unknown-mod", $"Unknown mod '{packId}'.");
}
}
if (!content.PackExists(CatalogLoader.CorePackId))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The core pack is missing.");
}
var packIds = content.NormalizePackIds(extras);
DefCatalog catalog;
MapLayout map;
try
{
catalog = content.LoadCatalog(packIds);
map = content.LoadMap(packIds, saved: null);
MapValidator.Validate(map, catalog);
}
catch (SchoolContentUnavailableException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message);
}
catch (ContentLoadException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message);
}
catch (MapValidationException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-map", ex.Message);
}
var locale = string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
return Results.Ok(CatalogResponse.From(catalog, map, locale));
})
.WithName("GetCatalog");
}
private static IReadOnlyList<string> ParseModIds(string? mods)
{
if (string.IsNullOrWhiteSpace(mods))
{
return [];
}
return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
internal sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
internal sealed record ModInfoResponse(string Id, bool Required);
internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
MapLayout DefaultMap)
{
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
new(
Placeable(catalog.Territories.Values, catalog, locale),
Placeable(catalog.Buildings.Values, catalog, locale),
Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale),
Placeable(catalog.Things.Values, catalog, locale),
map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
where T : Def =>
defs
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
.ToArray();
private static IReadOnlyList<RoomInfoResponse> PlaceableRooms(DefCatalog catalog, string locale) =>
catalog.Rooms.Values
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new RoomInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing)).ToArray(),
def.Positions.ToArray()))
.ToArray();
}
internal sealed record DefInfoResponse(string DefName, string Label);
internal sealed record RoomInfoResponse(
string DefName,
string Label,
IReadOnlyList<RoomSlotInfo> Slots,
IReadOnlyList<string> Positions);
internal sealed record RoomSlotInfo(string Key, string Thing);
+10 -1
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.Server.Game; using HSchool.Server.Game;
using HSchool.Simulation; using HSchool.Simulation;
@@ -47,6 +48,8 @@ internal static class SchoolEndpoints
var command = new GameCommand.CreateSchool( var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty, request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
NewCompletion<SchoolCreationOutcome>()); NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command); commands.Enqueue(command);
@@ -62,6 +65,12 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."), Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate => SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."), Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
SchoolCreationError.InvalidMap =>
Problem(StatusCodes.Status400BadRequest, "invalid-map", "The map is not a connected yard-and-rooms graph."),
SchoolCreationError.UnknownMod =>
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
_ => Results.Problem("Unknown error."), _ => Results.Problem("Unknown error."),
}; };
}) })
@@ -98,7 +107,7 @@ internal static class SchoolEndpoints
} }
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary> /// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate); internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList<string>? ModIds, MapLayout? Map);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{ {
+3
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.Simulation; using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
@@ -11,6 +12,8 @@ internal abstract record GameCommand
internal sealed record CreateSchool( internal sealed record CreateSchool(
string Name, string Name,
DateTime StartDate, DateTime StartDate,
IReadOnlyList<string>? ExtraModIds,
MapLayout? Map,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand; TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+33 -1
View File
@@ -169,10 +169,27 @@ internal sealed class GameLoopService(
return; return;
} }
var extras = command.ExtraModIds ?? [];
foreach (var packId in extras)
{
if (!ModContent.IsSafePackId(packId) || !mods.PackExists(packId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownMod));
return;
}
}
var packIds = mods.NormalizePackIds(extras);
if (!mods.PackExists(CatalogLoader.CorePackId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
var id = _nextId++; var id = _nextId++;
store.WriteNextId(_nextId); store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null); var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map);
Track(worker); Track(worker);
worker.Start(); worker.Start();
@@ -180,6 +197,13 @@ internal sealed class GameLoopService(
{ {
await worker.Started.ConfigureAwait(false); await worker.Started.ConfigureAwait(false);
} }
catch (SchoolContentUnavailableException ex)
{
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
command.Result.TrySetResult(new SchoolCreationOutcome(null, ContentError(ex)));
return;
}
catch catch
{ {
Untrack(id); Untrack(id);
@@ -427,6 +451,14 @@ internal sealed class GameLoopService(
client.TrySend(frame.AsMemory(0, length)); client.TrySend(frame.AsMemory(0, length));
} }
private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) =>
ex.InnerException switch
{
MapValidationException => SchoolCreationError.InvalidMap,
ContentLoadException => SchoolCreationError.InvalidCatalog,
_ => SchoolCreationError.InvalidCatalog,
};
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary> /// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work) private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{ {
+48
View File
@@ -29,6 +29,50 @@ internal sealed class ModContent
public bool PackExists(string packId) => Directory.Exists(PackPath(packId)); public bool PackExists(string packId) => Directory.Exists(PackPath(packId));
/// <summary>
/// Pack folder names are identifiers, not paths. Anything that could escape <see cref="Root"/>
/// is rejected before it reaches the disk.
/// </summary>
public static bool IsSafePackId(string packId)
{
if (string.IsNullOrWhiteSpace(packId) || packId.Length > 64)
{
return false;
}
foreach (var ch in packId)
{
if (!char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')
{
return false;
}
}
return true;
}
public IReadOnlyList<ModPackInfo> ListPacks()
{
var packs = new List<ModPackInfo> { new(CatalogLoader.CorePackId, Required: true) };
if (!Directory.Exists(Root))
{
return packs;
}
foreach (var directory in Directory.GetDirectories(Root).OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
var id = Path.GetFileName(directory);
if (id.Equals(CatalogLoader.CorePackId, StringComparison.OrdinalIgnoreCase) || !IsSafePackId(id))
{
continue;
}
packs.Add(new ModPackInfo(id, Required: false));
}
return packs;
}
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) => public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
CatalogLoader.NormalizePackOrder(extraModIds ?? []); CatalogLoader.NormalizePackOrder(extraModIds ?? []);
@@ -90,5 +134,9 @@ internal sealed class ModContent
return map; return map;
} }
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds) => LoadCatalog(packIds, _logger);
private string PackPath(string packId) => Path.Combine(Root, packId); private string PackPath(string packId) => Path.Combine(Root, packId);
} }
internal sealed record ModPackInfo(string Id, bool Required);
+28
View File
@@ -276,6 +276,7 @@ internal sealed class SchoolWorker
{ {
case WorkerCommand.Open open: case WorkerCommand.Open open:
open.Client.OpenSchoolId = _id; open.Client.OpenSchoolId = _id;
SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school); BroadcastClockTo(open.Client, school);
break; break;
@@ -364,6 +365,33 @@ internal sealed class SchoolWorker
} }
} }
private void SendMapSnapshot(GameClient client, School school)
{
if (school.Catalog is null || school.Map is null)
{
return;
}
var locale = ProtocolConstants.CatalogLocale(client.Locale);
var view = MapView.Build(school.Catalog, school.Map, locale);
var nodes = new MapSnapshotNode[view.Count];
for (var i = 0; i < view.Count; i++)
{
var node = view[i];
nodes[i] = new MapSnapshotNode(
(byte)node.Kind,
node.Id,
node.ParentId,
node.Name,
node.Items,
node.Positions);
}
var frame = new byte[ProtocolConstants.MaxMessageSize];
var length = ProtocolCodec.WriteMapSnapshot(frame, new ServerMapSnapshotMessage(school.Id, nodes));
client.TrySendReliable(frame.AsMemory(0, length));
}
private static void BroadcastClockTo(GameClient client, School school) private static void BroadcastClockTo(GameClient client, School school)
{ {
var frame = new byte[ProtocolCodec.MaxFrameSize]; var frame = new byte[ProtocolCodec.MaxFrameSize];
+102 -10
View File
@@ -4,9 +4,9 @@ using System.Threading.Channels;
namespace HSchool.Server.Net; namespace HSchool.Server.Net;
/// <summary> /// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client can never /// One connected browser. Clock frames go through a 32-slot outbox that drops the oldest under
/// stall a school worker; when the outbox overflows the oldest frame is dropped, which is right /// pressure — a stale clock is worthless. One-shot frames (the map snapshot) use a separate
/// for a clock that is resent 20 times a second. /// reliable channel so they cannot be crowded out by ticks.
/// </summary> /// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket) internal sealed class GameClient(uint playerId, WebSocket socket)
{ {
@@ -20,8 +20,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
SingleWriter = false, SingleWriter = false,
}); });
private readonly Channel<ReadOnlyMemory<byte>> _reliable =
Channel.CreateUnbounded<ReadOnlyMemory<byte>>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
private bool _ready; private bool _ready;
private int _openSchoolId; private int _openSchoolId;
private int _locale;
public uint PlayerId { get; } = playerId; public uint PlayerId { get; } = playerId;
@@ -33,6 +41,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
/// </summary> /// </summary>
public bool IsReady => Volatile.Read(ref _ready); public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// Hello locale byte. Workers read this when labelling a map snapshot; unknown values are
/// treated as Russian by <see cref="HSchool.Protocol.ProtocolConstants.CatalogLocale"/>.
/// </summary>
public byte Locale
{
get => (byte)Volatile.Read(ref _locale);
set => Volatile.Write(ref _locale, value);
}
/// <summary> /// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor /// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor
/// on open/close, read by the connection thread on disconnect. /// on open/close, read by the connection thread on disconnect.
@@ -50,23 +68,97 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
public void MarkReady() => Volatile.Write(ref _ready, true); public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary> /// <summary>Queues a clock frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame); public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary> /// <summary>Queues a frame that must arrive; never dropped for a newer clock.</summary>
public bool TrySendReliable(ReadOnlyMemory<byte> frame) => _reliable.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or both channels complete.</summary>
public async Task RunSendLoopAsync(CancellationToken cancellationToken) public async Task RunSendLoopAsync(CancellationToken cancellationToken)
{ {
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) var reliable = _reliable.Reader;
var outbox = _outbox.Reader;
while (Socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
if (reliable.TryRead(out var reliableFrame))
{
if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
if (outbox.TryRead(out var clockFrame))
{
if (!await SendAsync(clockFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
var waitReliable = reliable.WaitToReadAsync(cancellationToken).AsTask();
var waitOutbox = outbox.WaitToReadAsync(cancellationToken).AsTask();
Task<bool> finished;
try
{
finished = await Task.WhenAny(waitReliable, waitOutbox).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
bool hasData;
try
{
hasData = await finished.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (hasData)
{
continue;
}
var other = ReferenceEquals(finished, waitReliable) ? waitOutbox : waitReliable;
try
{
if (!await other.ConfigureAwait(false))
{
return;
}
}
catch (OperationCanceledException)
{
return;
}
}
}
public void CompleteOutbox()
{
_reliable.Writer.TryComplete();
_outbox.Writer.TryComplete();
}
private async Task<bool> SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
{ {
if (Socket.State != WebSocketState.Open) if (Socket.State != WebSocketState.Open)
{ {
break; return false;
} }
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return true;
} }
}
public void CompleteOutbox() => _outbox.Writer.TryComplete();
} }
@@ -56,6 +56,8 @@ internal sealed class GameSocketHandler(
return; return;
} }
client.Locale = hello.Locale;
await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false); await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
client.MarkReady(); client.MarkReady();
+1
View File
@@ -49,6 +49,7 @@ app.UseWebSockets(new WebSocketOptions
}); });
app.MapSchoolEndpoints(); app.MapSchoolEndpoints();
app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
{ {
+3
View File
@@ -7,6 +7,9 @@ public enum SchoolCreationError
LimitReached, LimitReached,
InvalidName, InvalidName,
InvalidStartDate, InvalidStartDate,
InvalidMap,
UnknownMod,
InvalidCatalog,
} }
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary> /// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
+71 -6
View File
@@ -89,6 +89,70 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.InRange(elapsed.TotalMinutes, 3, 8); Assert.InRange(elapsed.TotalMinutes, 3, 8);
} }
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal(school.Id, snapshot.SchoolId);
Assert.Contains(snapshot.Nodes, node => node.Id == "yard" && node.Kind == 0);
var office = Assert.Single(snapshot.Nodes, node => node.Id == "principals-office");
Assert.Equal("Кабинет директора", office.Name);
Assert.Equal("floor-1", office.ParentId);
Assert.Contains("Директор", office.Positions);
Assert.NotEmpty(office.Items);
}
[Fact]
public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
using var socket = await OpenSchoolAsync(school.Id, ProtocolConstants.LocaleEnglish);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name);
}
[Fact]
public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
var office = Assert.Single(snapshot.Nodes, node => node.Id == "office");
Assert.Equal("floor-1", office.ParentId);
}
[Fact]
public async Task ReloadFromDisk_RestoresACustomMap()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Contains(snapshot.Nodes, node => node.Id == "office");
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
}
[Fact] [Fact]
public async Task Pausing_FreezesTheClock() public async Task Pausing_FreezesTheClock()
{ {
@@ -280,7 +344,7 @@ public class GameSocketTests(AppHostFixture fixture)
await SendAsync(socket, buffer => ProtocolCodec.WriteHello( await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer, buffer,
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1)))); new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), ProtocolConstants.LocaleRussian)));
var buffer = new byte[ProtocolConstants.MaxMessageSize]; var buffer = new byte[ProtocolConstants.MaxMessageSize];
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken); var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
@@ -301,19 +365,20 @@ public class GameSocketTests(AppHostFixture fixture)
return school; return school;
} }
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId) private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
{ {
var socket = await ConnectAsync(); var socket = await ConnectAsync(locale);
await ReceiveUntilAsync(socket, MessageType.ServerWelcome); await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId))); await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
return socket; return socket;
} }
private async Task<ClientWebSocket> ConnectAsync() private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
{ {
var socket = await ConnectRawAsync(); var socket = await ConnectRawAsync();
await SendAsync(socket, buffer => await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version))); ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket; return socket;
} }
@@ -132,6 +132,92 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal(suggestion.Name, created.Name); Assert.Equal(suggestion.Name, created.Name);
} }
[Fact]
public async Task Mods_ListCoreAsRequired()
{
using var client = fixture.App.CreateHttpClient("server");
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
}
[Fact]
public async Task Catalog_LabelsDefsInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
}
[Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Дырявая",
startDate = ExpectedDefaultStart,
map = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = Array.Empty<object>(),
floors = Array.Empty<object>(),
rooms = Array.Empty<object>(),
links = Array.Empty<object>(),
},
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-map", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужой мод",
startDate = ExpectedDefaultStart,
modIds = new[] { "no-such-mod" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateWithMapAsync(client, "Своя карта", ExpectedDefaultStart, SimpleCustomMap);
Assert.Equal("Своя карта", created.Name);
Assert.Contains((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact] [Fact]
public async Task Status_ReportsTheLoopAndTheLimit() public async Task Status_ReportsTheLoopAndTheLimit()
{ {
@@ -188,6 +274,31 @@ public class SchoolApiTests(AppHostFixture fixture)
return created; return created;
} }
internal static async Task<SchoolResponse> CreateWithMapAsync(HttpClient client, string name, DateTime startDate, object map)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate, map },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
internal static readonly object SimpleCustomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "office", def = "PrincipalsOffice", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "office" } },
};
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) => private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
client.PostAsJsonAsync( client.PostAsJsonAsync(
"/api/schools", "/api/schools",
@@ -213,4 +324,26 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record ProblemResponse(string? Code); private sealed record ProblemResponse(string? Code);
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections); private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(string Id, bool Required);
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(string DefName, string Label, IReadOnlyList<RoomSlotResponse> Slots, IReadOnlyList<string> Positions);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
} }
@@ -0,0 +1,36 @@
namespace HSchool.Content.Tests;
public class MapViewTests
{
[Fact]
public void VanillaMap_LabelsTheTreeInTheRequestedLocale()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
var ru = MapView.Build(catalog, map, "ru");
var en = MapView.Build(catalog, map, "en");
Assert.Equal(["yard", "main", "floor-1", "corridor-1", "principals-office"], ru.Select(node => node.Id));
Assert.Equal(string.Empty, ru[0].ParentId);
Assert.Equal("yard", ru.Single(node => node.Id == "main").ParentId);
Assert.Equal("floor-1", ru.Single(node => node.Id == "principals-office").ParentId);
var officeRu = ru.Single(node => node.Id == "principals-office");
Assert.Equal("Кабинет директора", officeRu.Name);
Assert.Equal(["Кресло директора", "Стол", "Стул"], officeRu.Items);
Assert.Equal(["Директор"], officeRu.Positions);
var officeEn = en.Single(node => node.Id == "principals-office");
Assert.Equal("Principal's office", officeEn.Name);
Assert.Equal(["Principal's chair", "Desk", "Chair"], officeEn.Items);
Assert.Equal(["Principal"], officeEn.Positions);
var corridor = ru.Single(node => node.Id == "corridor-1");
Assert.Empty(corridor.Items);
Assert.Empty(corridor.Positions);
}
}
@@ -8,14 +8,17 @@ namespace HSchool.Protocol.Tests;
public class ProtocolCodecTests public class ProtocolCodecTests
{ {
[Fact] [Fact]
public void Hello_RoundTripsAndIsTwoBytes() public void Hello_RoundTripsAndIsThreeBytes()
{ {
var message = new ClientHelloMessage(ProtocolConstants.Version); var message = new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleEnglish);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteHello(buffer, message); var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(2, length); Assert.Equal(3, length);
Assert.Equal((byte)MessageType.ClientHello, buffer[0]);
Assert.Equal(ProtocolConstants.Version, buffer[1]);
Assert.Equal(ProtocolConstants.LocaleEnglish, buffer[2]);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length])); Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
} }
@@ -130,6 +133,31 @@ public class ProtocolCodecTests
Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length])); Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length]));
} }
[Fact]
public void MapSnapshot_RoundTripsAndWritesHeaderOffsets()
{
var message = new ServerMapSnapshotMessage(7, [
new MapSnapshotNode(0, "yard", "", "Двор", [], []),
new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стул"], ["Директор"]),
]);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
Assert.Equal((byte)MessageType.ServerMapSnapshot, buffer[0]);
Assert.Equal(7, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(2, read.Nodes.Count);
Assert.Equal("yard", read.Nodes[0].Id);
Assert.Equal("", read.Nodes[0].ParentId);
Assert.Equal("Двор", read.Nodes[0].Name);
Assert.Equal(["Стул"], read.Nodes[1].Items);
Assert.Equal(["Директор"], read.Nodes[1].Positions);
}
[Fact] [Fact]
public void Numbers_AreLittleEndian() public void Numbers_AreLittleEndian()
{ {
@@ -141,9 +169,9 @@ public class ProtocolCodecTests
} }
[Fact] [Fact]
public void MaxFrameSize_FitsEveryMessage() public void MaxFrameSize_FitsEveryFixedSizeMessage()
{ {
// The handlers size their buffers from this constant; the clock frame is the largest one. // Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4)); var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));