diff --git a/AGENTS.md b/AGENTS.md index 5352566..d28a425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ way; this file is *how to work in them*. | --- | --- | | schools, the game clock, game rules | `src/HSchool.Simulation` | | 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` | | connection handling, workers, saves | `src/HSchool.Server` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` | diff --git a/docs/architecture.md b/docs/architecture.md index 6082c21..1383926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,8 +87,8 @@ produces the same date. **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 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 -thread. +sends one map snapshot labelled in the Hello locale. One school's pause cannot stall another's +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 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 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. -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. 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 -that cannot keep up loses intermediate clock frames instead of stalling a worker. +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. The map +snapshot uses a separate reliable queue so it cannot be dropped for a newer tick. ## 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. - **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. -- **Create editor and the map snapshot**: the catalog and vanilla map exist; the player still - cannot pick mods or see the tree from the server. That work lives in - [`phases/04-create-editor.md`](phases/04-create-editor.md). +- **Create editor and the map snapshot**: done in this slice. Next game verbs (Sit) and the + event log are out of scope here. diff --git a/docs/phases/04-create-editor.md b/docs/phases/04-create-editor.md index f1f6e0e..be05360 100644 --- a/docs/phases/04-create-editor.md +++ b/docs/phases/04-create-editor.md @@ -12,15 +12,15 @@ ## Задачи -- [ ] `GET` списка модов: `core` как обязательный, остальные папки `mods/` -- [ ] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id -- [ ] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке -- [ ] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует -- [ ] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту -- [ ] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц -- [ ] Клиент фильтрует выбранный узел; часы как сейчас -- [ ] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек -- [ ] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок +- [x] `GET` списка модов: `core` как обязательный, остальные папки `mods/` +- [x] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id +- [x] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке +- [x] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует +- [x] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту +- [x] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц +- [x] Клиент фильтрует выбранный узел; часы как сейчас +- [x] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек +- [x] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 5493c12..535e639 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -15,4 +15,4 @@ | [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных | | [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа | | [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки | -| [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии | +| [4. Моды и редактор в create](04-create-editor.md) | ✅ | Выбор модов, карта в POST, снимок при открытии | diff --git a/docs/protocol.md b/docs/protocol.md index 4c0cb88..9cc0668 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,10 +1,12 @@ -# Wire protocol v3 +# Wire protocol v4 The client talks to the server two ways: -- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are - request/response by nature, so they are plain REST. -- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second. +- **HTTP/JSON** for the main menu — listing, creating and deleting schools, listing mods and + loading a catalog for the create editor. Those are request/response by nature, so they are + 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 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 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` -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 | | --- | --- | | `201` | Created; body is the school. | | `400` `invalid-name` | Blank, or longer than 40 characters. | | `400` `invalid-start-date` | Outside 1900–2999. | +| `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. | 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 | | `0x83` | S → C | Clock | | `0x84` | S → C | SchoolGone | +| `0x85` | S → C | MapSnapshot | ## 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. +The locale byte is the same language the catalog HTTP API takes as `?lang=`. | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x01` | | 1 | `u8` | protocol version | +| 2 | `u8` | locale: `0` Russian, `1` English; any other value is treated as Russian | ### `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 -Starts watching a school: clock frames for it begin to arrive. It does not start the calendar — -every school runs on its own from the moment it is created. +Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames. +It does not start the calendar — every school runs on its own from the moment it is created. | Offset | Type | Field | | --- | --- | --- | @@ -182,16 +220,43 @@ client returns to the menu. | 0 | `u8` | `0x84` | | 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 - Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. - 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 older clients within the same protocol version. -- Clock delivery is lossy under back pressure: each connection buffers 32 frames and drops the - oldest, because a stale clock is worthless once a newer one exists. +- Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops + 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 -calendar — the school's ECS world is created but still empty. +Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered +on the client from the snapshot. The school's ECS world is created but still empty. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index d9ab961..c4d76be 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -38,6 +38,22 @@ const ru = { errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.', errorInvalidName: 'Название должно быть от 1 до 40 символов.', errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', + errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.', + errorUnknownMod: 'Выбранный мод не найден.', + errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', + catalogLoadFailed: 'Не удалось загрузить каталог модов.', + + modsTitle: 'Моды', + coreModLocked: '{id} (всегда включён)', + mapEditorTitle: 'Карта', + resetMap: 'Сбросить к умолчанию', + editorSlots: 'Слоты', + editorLinks: 'Проходы', + addLink: 'Связать', + removeLink: 'Убрать', + addRoom: 'Добавить комнату', + removeRoom: 'Удалить комнату', + slotEmpty: '— пусто —', backToMenu: '← В главное меню', pause: 'Пауза', @@ -55,10 +71,6 @@ const ru = { charactersEmpty: 'Никого нет.', activitiesEmpty: 'Ничего не происходит.', positionsEmpty: 'Нет должностей.', - stubTerritory: 'Двор', - stubBuilding: 'Главный корпус', - stubFloor: '1 этаж', - stubRoom: 'Кабинет директора', } as const; type Messages = { [K in keyof typeof ru]: string }; @@ -101,6 +113,22 @@ const en: Messages = { errorSchoolLimit: 'School limit reached — delete one to create another.', errorInvalidName: 'The name must be 1 to 40 characters.', errorInvalidStartDate: 'The start date is outside the allowed range.', + 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', pause: 'Pause', @@ -118,10 +146,6 @@ const en: Messages = { charactersEmpty: 'Nobody here.', activitiesEmpty: 'Nothing is happening.', positionsEmpty: 'No positions.', - stubTerritory: 'Yard', - stubBuilding: 'Main building', - stubFloor: 'Floor 1', - stubRoom: "Principal's office", }; const catalogs: Record = { ru, en }; diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index d3f9908..c83245c 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -1,5 +1,5 @@ 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 { GameScreen } from './ui/gameScreen.ts'; import { localeSwitch } from './ui/localeSwitch.ts'; @@ -44,6 +44,11 @@ function bootstrap(): void { game.update(clock); } }, + onMapSnapshot: (snapshot) => { + if (openSchool?.id === snapshot.schoolId) { + game.applyMap(snapshot.schoolId, snapshot.nodes); + } + }, onSchoolGone: (schoolId) => { // Deleted from another tab while we were inside it. if (openSchool?.id === schoolId) { @@ -54,7 +59,7 @@ function bootstrap(): void { lastPingMs = rttMs; paintChrome(); }, - }); + }, getLocale); function paintChrome(): void { if (statusLabel !== null) { @@ -90,6 +95,7 @@ function bootstrap(): void { paintChrome(); menu.localize(); game.localize(); + connection.reconnect(); }); paintChrome(); diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 178aca2..c090540 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -41,14 +41,87 @@ export async function fetchRandomName(lang: string): Promise { return response.name; } -export async function createSchool(name: string, startDate: Date): Promise { +export interface CreateSchoolOptions { + readonly modIds?: readonly string[]; + readonly map?: MapLayout; +} + +export async function createSchool( + name: string, + startDate: Date, + extras: CreateSchoolOptions = {}, +): Promise { return request('/api/schools', { method: 'POST', 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 { + const response = await request<{ mods: readonly ModInfo[] }>('/api/mods'); + return response.mods; +} + +export async function fetchCatalog(lang: string, extraModIds: readonly string[]): Promise { + const query = new URLSearchParams({ lang }); + if (extraModIds.length > 0) { + query.set('mods', extraModIds.join(',')); + } + + return request(`/api/catalog?${query.toString()}`); +} + export async function deleteSchool(id: number): Promise { await request(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false }); } diff --git a/src/HSchool.Client/src/net/connection.ts b/src/HSchool.Client/src/net/connection.ts index 754171d..cfe2145 100644 --- a/src/HSchool.Client/src/net/connection.ts +++ b/src/HSchool.Client/src/net/connection.ts @@ -8,6 +8,7 @@ import { encodeSetSpeed, ProtocolError, type ClockMessage, + type MapSnapshotMessage, type ServerMessage, type WelcomeMessage, } from './protocol.ts'; @@ -18,6 +19,7 @@ export interface ConnectionHandlers { onStatus?(status: ConnectionStatus): void; onWelcome?(message: WelcomeMessage): void; onClock?(message: ClockMessage): void; + onMapSnapshot?(message: MapSnapshotMessage): void; /** The open school was deleted elsewhere; the UI has to leave it. */ onSchoolGone?(schoolId: number): void; /** Round-trip time in milliseconds. */ @@ -45,6 +47,7 @@ export class GameConnection { constructor( private readonly url: string, private readonly handlers: ConnectionHandlers = {}, + private readonly locale: () => 'ru' | 'en' = () => 'ru', ) {} connect(): void { @@ -57,7 +60,7 @@ export class GameConnection { socket.addEventListener('open', () => { this.reconnectDelay = RECONNECT_MIN_MS; - socket.send(encodeHello()); + socket.send(encodeHello(this.locale())); this.handlers.onStatus?.('connected'); this.startPinging(); @@ -67,10 +70,30 @@ export class GameConnection { }); 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()); } + /** + * 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. */ openSchool(schoolId: number): void { this.openSchoolId = schoolId; @@ -136,6 +159,9 @@ export class GameConnection { case 'clock': this.handlers.onClock?.(message); break; + case 'map-snapshot': + this.handlers.onMapSnapshot?.(message); + break; case 'school-gone': if (this.openSchoolId === message.schoolId) { this.openSchoolId = null; diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 4bed8e1..573957d 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -18,12 +18,13 @@ import { * change, the C# codec and `docs/protocol.md` change with it. */ describe('client encoders', () => { - it('writes a two-byte hello carrying the version', () => { - const view = new DataView(encodeHello()); + it('writes a three-byte hello carrying the version and locale', () => { + 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(1)).toBe(PROTOCOL_VERSION); + expect(view.getUint8(2)).toBe(1); }); it('writes a ping frame carrying the client clock', () => { @@ -123,6 +124,42 @@ describe('decodeServerMessage', () => { 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', () => { const buffer = new Uint8Array([0xf0, 0x00]).buffer; diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index a5d0682..6aa312d 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -5,7 +5,7 @@ * 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 = { ClientHello: 0x01, @@ -18,6 +18,13 @@ export const MessageType = { ServerPong: 0x82, ServerClock: 0x83, 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; /** @@ -55,17 +62,45 @@ export interface SchoolGoneMessage { 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. */ export class ProtocolError extends Error {} -export function encodeHello(): ArrayBuffer { - const buffer = new ArrayBuffer(2); +export function encodeHello(locale: 'ru' | 'en' = 'ru'): ArrayBuffer { + const buffer = new ArrayBuffer(3); const view = new DataView(buffer); view.setUint8(0, MessageType.ClientHello); view.setUint8(1, PROTOCOL_VERSION); + view.setUint8(2, locale === 'en' ? WireLocale.en : WireLocale.ru); return buffer; } @@ -138,6 +173,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null { return decodeClock(view); case MessageType.ServerSchoolGone: return decodeSchoolGone(view); + case MessageType.ServerMapSnapshot: + return decodeMapSnapshot(view); default: return null; } @@ -182,6 +219,61 @@ function decodeSchoolGone(view: DataView): SchoolGoneMessage { 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 { if (view.byteLength < bytes) { throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`); diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 322446a..483911f 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -246,6 +246,12 @@ body { font-size: 13px; } +.panel__list { + margin: 0; + padding-left: 18px; + font-size: 13px; +} + .tree { margin: 0; padding: 0; @@ -363,6 +369,12 @@ body { color: var(--text); } +.dialog--wide { + min-width: 640px; + max-width: 860px; + width: min(860px, calc(100vw - 32px)); +} + .dialog::backdrop { background: rgba(6, 9, 14, 0.7); } @@ -411,3 +423,53 @@ body { display: flex; 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; +} diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index eb05eeb..a6f95d2 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -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 { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; +import { mapEditor } from './mapEditor.ts'; import { Modal } from './modal.ts'; interface CreateSchoolOptions { /** Prefilled start of the school year, straight from the server config. */ readonly defaultStartDate: Date; readonly suggestName: () => Promise; - readonly create: (name: string, startDate: Date) => Promise; + readonly create: (name: string, startDate: Date, extras: CreateExtras) => Promise; } /** - * The creation form: a name (typed or rolled), a start date and a create button. - * Resolves with the created school, or `null` when the player backs out. + * Creation form: mods, a map (edit or reset to the pack default), then name and start date. */ export function createSchoolDialog(options: CreateSchoolOptions): Promise { const modal = new Modal(null); + modal.element.classList.add('dialog--wide'); const defaults = toDateAndTimeInputs(options.defaultStartDate); + const extraModIds = new Set(); + let catalog: CatalogResponse | null = null; + let currentMap: MapLayout | null = null; + let editor: ReturnType | 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' }); nameInput.maxLength = 40; - nameInput.placeholder = t('schoolNamePlaceholder'); nameInput.required = true; const dateInput = el('input', { class: 'input', type: 'date' }); @@ -35,18 +59,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(null) }); + const title = el('h2', { class: 'dialog__title' }); const form = el( 'form', { class: 'form' }, + modsField, + mapField, el( 'label', { class: 'field' }, @@ -60,20 +82,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(null) }), - submitButton, - ), + el('div', { class: 'dialog__actions' }, cancelButton, submitButton), ); let busy = false; const setBusy = (value: boolean): void => { busy = value; - submitButton.toggleAttribute('disabled', value); + submitButton.toggleAttribute('disabled', value || catalog === null); randomButton.toggleAttribute('disabled', value); + resetButton.toggleAttribute('disabled', value || catalog === null); }; const showError = (message: string): void => { @@ -81,6 +99,97 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + 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 => { + try { + applyCatalog(await fetchCatalog(getLocale(), [...extraModIds])); + error.hidden = true; + } catch { + showError(t('catalogLoadFailed')); + } + }; + + const paintMods = async (): Promise => { + 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', () => { if (busy) { return; @@ -99,7 +208,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { event.preventDefault(); - if (busy) { + if (busy || currentMap === null) { return; } @@ -111,7 +220,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(school)) .catch((reason: unknown) => { showError(describe(reason)); @@ -119,7 +231,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise void; @@ -10,37 +10,12 @@ interface GameScreenOptions { 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']; /** - * The inside of a school: calendar controls plus the manager shell. Data for the tree and - * location lists arrives in a later phase; until then the panels stay empty except for stubs - * that prove the tree filters the location pane on the client. + * The inside of a school: calendar controls plus the manager shell. The tree and location + * lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot + * on the client. People, in-place activities and events stay empty in this slice. */ export class GameScreen { 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 itemsHeading = el('h3', { class: 'panel__section-title' }); 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 charactersEmpty = el('p', { class: 'panel__empty' }); private readonly activitiesHeading = el('h3', { class: 'panel__section-title' }); private readonly activitiesEmpty = el('p', { class: 'panel__empty' }); private readonly positionsHeading = el('h3', { class: 'panel__section-title' }); private readonly positionsEmpty = el('p', { class: 'panel__empty' }); + private readonly positionsList = el('ul', { class: 'panel__list' }); private readonly treeButtons = new Map(); - private selectedId = STUB_MAP.id; + private nodes: readonly MapSnapshotNode[] = []; + private selectedId: string | null = null; + private schoolId: number | null = null; private running = false; private lastGameTime: Date | null = null; private lastSpeedIndex = 0; @@ -86,8 +65,6 @@ export class GameScreen { this.backButton.addEventListener('click', options.onLeave); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); - this.buildTree(this.tree, [STUB_MAP], 0); - this.root.append( el('header', { class: 'screen__header' }, this.backButton, this.schoolName), el( @@ -108,10 +85,10 @@ export class GameScreen { { class: 'panel' }, this.locationTitle, 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.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.positionsEmpty.textContent = t('positionsEmpty'); - this.paintTreeLabels(STUB_MAP); this.paintSelection(); 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 { + this.schoolId = school.id; this.schoolName.textContent = school.name; + this.nodes = []; + this.selectedId = null; + this.rebuildTree(); 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 { 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) { const item = el('li', { class: 'tree__node' }); const button = el('button', { class: 'tree__button', type: 'button', + text: node.name, onClick: () => this.select(node.id), }); button.style.paddingLeft = `${8 + depth * 14}px`; this.treeButtons.set(node.id, 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' }); - this.buildTree(nested, node.children, depth + 1); + this.buildTree(nested, nestedNodes, depth + 1); 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 { this.selectedId = id; this.paintSelection(); @@ -201,8 +191,10 @@ export class GameScreen { button.classList.toggle('tree__button--active', id === this.selectedId); } - const labelKey = findStub(STUB_MAP, this.selectedId)?.labelKey; - this.locationName.textContent = labelKey === undefined ? '' : t(labelKey); + const node = this.nodes.find((candidate) => candidate.id === this.selectedId); + 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 { @@ -223,17 +215,16 @@ export class GameScreen { } } -function findStub(node: StubNode, id: string): StubNode | null { - if (node.id === id) { - return node; - } +function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSnapshotNode[] { + return nodes.filter((node) => node.parentId === parentId); +} + +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 ?? []) { - const match = findStub(child, id); - if (match !== null) { - return match; - } + for (const value of values) { + list.append(el('li', { text: value })); } - - return null; } diff --git a/src/HSchool.Client/src/ui/mapEditor.ts b/src/HSchool.Client/src/ui/mapEditor.ts new file mode 100644 index 0000000..5d78873 --- /dev/null +++ b/src/HSchool.Client/src/ui/mapEditor.ts @@ -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 { + 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 { + 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; +} diff --git a/src/HSchool.Content/MapView.cs b/src/HSchool.Content/MapView.cs new file mode 100644 index 0000000..a90de37 --- /dev/null +++ b/src/HSchool.Content/MapView.cs @@ -0,0 +1,133 @@ +namespace HSchool.Content; + +/// Kind of a map-tree node, matching the snapshot wire values. +public enum MapNodeKind : byte +{ + Territory = 0, + Building = 1, + Floor = 2, + Room = 3, +} + +/// One labelled node of a school's map, ready to put on the wire or in a test. +public sealed class MapViewNode +{ + public required MapNodeKind Kind { get; init; } + + public required string Id { get; init; } + + /// Empty for the yard. + public required string ParentId { get; init; } + + public required string Name { get; init; } + + public IReadOnlyList Items { get; init; } = []; + + public IReadOnlyList Positions { get; init; } = []; +} + +/// +/// 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. +/// +public static class MapView +{ + public static IReadOnlyList Build(DefCatalog catalog, MapLayout map, string locale) + { + if (map.Territory is not { } territory) + { + return []; + } + + var nodes = new List + { + 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(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 items, + IReadOnlyList 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 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; + } +} diff --git a/src/HSchool.Protocol/MessageType.cs b/src/HSchool.Protocol/MessageType.cs index 3091444..5520d9f 100644 --- a/src/HSchool.Protocol/MessageType.cs +++ b/src/HSchool.Protocol/MessageType.cs @@ -19,4 +19,5 @@ public enum MessageType : byte ServerPong = 0x82, ServerClock = 0x83, ServerSchoolGone = 0x84, + ServerMapSnapshot = 0x85, } diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index 7d1f823..cb852b2 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -1,7 +1,10 @@ namespace HSchool.Protocol; -/// First frame from the client; carries nothing but the version handshake. -public readonly record struct ClientHelloMessage(byte ProtocolVersion); +/// +/// First frame from the client. is +/// or — the same language the catalog HTTP API uses. +/// +public readonly record struct ClientHelloMessage(byte ProtocolVersion, byte Locale); /// Round-trip probe; the server mirrors back untouched. public readonly record struct ClientPingMessage(long ClientTimeMs); @@ -37,3 +40,21 @@ public readonly record struct ServerClockMessage( /// The open school no longer exists (deleted from another tab); the client returns to the menu. public readonly record struct ServerSchoolGoneMessage(int SchoolId); + +/// +/// Tree node in a map snapshot. Kind is 0 territory, 1 building, 2 floor, 3 room. +/// is empty for the yard. +/// +public sealed record MapSnapshotNode( + byte Kind, + string Id, + string ParentId, + string Name, + IReadOnlyList Items, + IReadOnlyList Positions); + +/// +/// 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. +/// +public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList Nodes); diff --git a/src/HSchool.Protocol/PacketReader.cs b/src/HSchool.Protocol/PacketReader.cs index 2b81c17..63b76db 100644 --- a/src/HSchool.Protocol/PacketReader.cs +++ b/src/HSchool.Protocol/PacketReader.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Text; namespace HSchool.Protocol; @@ -22,6 +23,14 @@ public ref struct PacketReader(ReadOnlySpan buffer) 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() { EnsureAvailable(sizeof(uint)); @@ -30,6 +39,16 @@ public ref struct PacketReader(ReadOnlySpan buffer) return value; } + /// u16 byte length, then UTF-8. Empty string is a zero length. + public string ReadString() + { + var byteCount = ReadUInt16(); + EnsureAvailable(byteCount); + var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount)); + _position += byteCount; + return value; + } + public int ReadInt32() { EnsureAvailable(sizeof(int)); diff --git a/src/HSchool.Protocol/PacketWriter.cs b/src/HSchool.Protocol/PacketWriter.cs index 1550c72..a8395b2 100644 --- a/src/HSchool.Protocol/PacketWriter.cs +++ b/src/HSchool.Protocol/PacketWriter.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Text; namespace HSchool.Protocol; @@ -22,6 +23,13 @@ public ref struct PacketWriter(Span buffer) 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) { EnsureRoom(sizeof(uint)); @@ -29,6 +37,21 @@ public ref struct PacketWriter(Span buffer) _position += sizeof(uint); } + /// u16 byte length, then UTF-8. Empty string is a zero length. + 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) { EnsureRoom(sizeof(int)); diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index 897777f..46e909d 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -7,7 +7,10 @@ namespace HSchool.Protocol; /// public static class ProtocolCodec { - /// Largest frame this codec produces; handlers can size their buffers from it. + /// + /// Largest fixed-size frame this codec produces. Variable map snapshots use + /// instead. + /// public const int MaxFrameSize = 16; public static int WriteHello(Span destination, in ClientHelloMessage message) @@ -15,6 +18,7 @@ public static class ProtocolCodec var writer = new PacketWriter(destination); writer.WriteMessageType(MessageType.ClientHello); writer.WriteByte(message.ProtocolVersion); + writer.WriteByte(message.Locale); return writer.Position; } @@ -95,6 +99,45 @@ public static class ProtocolCodec return writer.Position; } + public static int WriteMapSnapshot(Span 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 source) => source.IsEmpty ? MessageType.None : (MessageType)source[0]; @@ -102,7 +145,9 @@ public static class ProtocolCodec { var reader = new PacketReader(source); 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 source) @@ -170,6 +215,40 @@ public static class ProtocolCodec return new ServerSchoolGoneMessage(reader.ReadInt32()); } + public static ServerMapSnapshotMessage ReadMapSnapshot(ReadOnlySpan 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) { var actual = reader.ReadMessageType(); diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index 1a0e9d7..1455f02 100644 --- a/src/HSchool.Protocol/ProtocolConstants.cs +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -4,8 +4,18 @@ namespace HSchool.Protocol; public static class ProtocolConstants { /// Bumped on every breaking change to the binary layout. - public const byte Version = 3; + public const byte Version = 4; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; + + /// Hello locale byte: Russian. Any other value that is not is treated as this. + public const byte LocaleRussian = 0; + + /// Hello locale byte: English. Same value the catalog HTTP API takes as lang=en. + public const byte LocaleEnglish = 1; + + /// Catalog locale string matching the Hello byte. Unknown bytes fall back to Russian. + public static string CatalogLocale(byte locale) => + locale == LocaleEnglish ? "en" : "ru"; } diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs new file mode 100644 index 0000000..848b38a --- /dev/null +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -0,0 +1,128 @@ +using HSchool.Content; +using HSchool.Server.Game; + +namespace HSchool.Server.Api; + +/// +/// Catalog for the create dialog. Loading defs here is safe: Content is immutable and this never +/// touches a live School or its worker. +/// +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 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 + { + ["code"] = code, + }); +} + +internal sealed record ModsResponse(IReadOnlyList Mods); + +internal sealed record ModInfoResponse(string Id, bool Required); + +internal sealed record CatalogResponse( + IReadOnlyList Territories, + IReadOnlyList Buildings, + IReadOnlyList Floors, + IReadOnlyList Rooms, + IReadOnlyList 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 Placeable(IEnumerable 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 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 Slots, + IReadOnlyList Positions); + +internal sealed record RoomSlotInfo(string Key, string Thing); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index bcae385..4d4be5b 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -1,3 +1,4 @@ +using HSchool.Content; using HSchool.Server.Game; using HSchool.Simulation; @@ -47,6 +48,8 @@ internal static class SchoolEndpoints var command = new GameCommand.CreateSchool( request.Name ?? string.Empty, DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), + request.ModIds, + request.Map, NewCompletion()); 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."), SchoolCreationError.InvalidStartDate => 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."), }; }) @@ -98,7 +107,7 @@ internal static class SchoolEndpoints } /// Body of POST /api/schools. The start date is a game calendar date, not a real one. -internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate); +internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList? ModIds, MapLayout? Map); internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 084becd..39bd383 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -1,3 +1,4 @@ +using HSchool.Content; using HSchool.Simulation; namespace HSchool.Server.Game; @@ -11,6 +12,8 @@ internal abstract record GameCommand internal sealed record CreateSchool( string Name, DateTime StartDate, + IReadOnlyList? ExtraModIds, + MapLayout? Map, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index ffadddf..3579d0a 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -169,10 +169,27 @@ internal sealed class GameLoopService( 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++; 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); worker.Start(); @@ -180,6 +197,13 @@ internal sealed class GameLoopService( { 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 { Untrack(id); @@ -427,6 +451,14 @@ internal sealed class GameLoopService( client.TrySend(frame.AsMemory(0, length)); } + private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) => + ex.InnerException switch + { + MapValidationException => SchoolCreationError.InvalidMap, + ContentLoadException => SchoolCreationError.InvalidCatalog, + _ => SchoolCreationError.InvalidCatalog, + }; + /// Runs work for a waiting request thread without letting an exception kill the supervisor. private static void Complete(TaskCompletionSource completion, Func work) { diff --git a/src/HSchool.Server/Game/ModContent.cs b/src/HSchool.Server/Game/ModContent.cs index aa7cf1e..c3eba4e 100644 --- a/src/HSchool.Server/Game/ModContent.cs +++ b/src/HSchool.Server/Game/ModContent.cs @@ -29,6 +29,50 @@ internal sealed class ModContent public bool PackExists(string packId) => Directory.Exists(PackPath(packId)); + /// + /// Pack folder names are identifiers, not paths. Anything that could escape + /// is rejected before it reaches the disk. + /// + 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 ListPacks() + { + var packs = new List { 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 NormalizePackIds(IReadOnlyList? extraModIds) => CatalogLoader.NormalizePackOrder(extraModIds ?? []); @@ -90,5 +134,9 @@ internal sealed class ModContent return map; } + public DefCatalog LoadCatalog(IReadOnlyList packIds) => LoadCatalog(packIds, _logger); + private string PackPath(string packId) => Path.Combine(Root, packId); } + +internal sealed record ModPackInfo(string Id, bool Required); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index c94b586..b3650f9 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -276,6 +276,7 @@ internal sealed class SchoolWorker { case WorkerCommand.Open open: open.Client.OpenSchoolId = _id; + SendMapSnapshot(open.Client, school); BroadcastClockTo(open.Client, school); 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) { var frame = new byte[ProtocolCodec.MaxFrameSize]; diff --git a/src/HSchool.Server/Net/GameClient.cs b/src/HSchool.Server/Net/GameClient.cs index 9909bc3..a55da36 100644 --- a/src/HSchool.Server/Net/GameClient.cs +++ b/src/HSchool.Server/Net/GameClient.cs @@ -4,9 +4,9 @@ using System.Threading.Channels; namespace HSchool.Server.Net; /// -/// One connected browser. Frames are queued instead of written inline so a slow client can never -/// stall a school worker; when the outbox overflows the oldest frame is dropped, which is right -/// for a clock that is resent 20 times a second. +/// One connected browser. Clock frames go through a 32-slot outbox that drops the oldest under +/// pressure — a stale clock is worthless. One-shot frames (the map snapshot) use a separate +/// reliable channel so they cannot be crowded out by ticks. /// internal sealed class GameClient(uint playerId, WebSocket socket) { @@ -20,8 +20,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket) SingleWriter = false, }); + private readonly Channel> _reliable = + Channel.CreateUnbounded>(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + private bool _ready; private int _openSchoolId; + private int _locale; public uint PlayerId { get; } = playerId; @@ -33,6 +41,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket) /// public bool IsReady => Volatile.Read(ref _ready); + /// + /// Hello locale byte. Workers read this when labelling a map snapshot; unknown values are + /// treated as Russian by . + /// + public byte Locale + { + get => (byte)Volatile.Read(ref _locale); + set => Volatile.Write(ref _locale, value); + } + /// /// School this connection is watching, or null in the menu. Written by the supervisor /// 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); - /// Queues a frame. Returns false once the connection is shutting down. + /// Queues a clock frame. Returns false once the connection is shutting down. public bool TrySend(ReadOnlyMemory frame) => _outbox.Writer.TryWrite(frame); - /// Pumps queued frames to the socket until cancelled or the outbox completes. + /// Queues a frame that must arrive; never dropped for a newer clock. + public bool TrySendReliable(ReadOnlyMemory frame) => _reliable.Writer.TryWrite(frame); + + /// Pumps queued frames to the socket until cancelled or both channels complete. 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 (Socket.State != WebSocketState.Open) + if (reliable.TryRead(out var reliableFrame)) { - break; + if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false)) + { + return; + } + + continue; } - await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) - .ConfigureAwait(false); + 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 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() => _outbox.Writer.TryComplete(); + public void CompleteOutbox() + { + _reliable.Writer.TryComplete(); + _outbox.Writer.TryComplete(); + } + + private async Task SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken) + { + if (Socket.State != WebSocketState.Open) + { + return false; + } + + await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) + .ConfigureAwait(false); + return true; + } } diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index e9c6fe8..3913a07 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -56,6 +56,8 @@ internal sealed class GameSocketHandler( return; } + client.Locale = hello.Locale; + await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false); client.MarkReady(); diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 7132812..a3da41f 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -49,6 +49,7 @@ app.UseWebSockets(new WebSocketOptions }); app.MapSchoolEndpoints(); +app.MapModEndpoints(); app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => { diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs index a55d5fd..00fed0a 100644 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ b/src/HSchool.Simulation/SchoolRegistry.cs @@ -7,6 +7,9 @@ public enum SchoolCreationError LimitReached, InvalidName, InvalidStartDate, + InvalidMap, + UnknownMod, + InvalidCatalog, } /// Outcome of : either the school or the reason there is none. diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index ee2b61c..2f723a1 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -89,6 +89,70 @@ public class GameSocketTests(AppHostFixture fixture) 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] public async Task Pausing_FreezesTheClock() { @@ -280,7 +344,7 @@ public class GameSocketTests(AppHostFixture fixture) await SendAsync(socket, buffer => ProtocolCodec.WriteHello( buffer, - new ClientHelloMessage((byte)(ProtocolConstants.Version + 1)))); + new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), ProtocolConstants.LocaleRussian))); var buffer = new byte[ProtocolConstants.MaxMessageSize]; var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken); @@ -301,19 +365,20 @@ public class GameSocketTests(AppHostFixture fixture) return school; } - private async Task OpenSchoolAsync(int schoolId) + private async Task OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian) { - var socket = await ConnectAsync(); + var socket = await ConnectAsync(locale); 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; } - private async Task ConnectAsync() + private async Task ConnectAsync(byte locale = ProtocolConstants.LocaleRussian) { var socket = await ConnectRawAsync(); await SendAsync(socket, buffer => - ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version))); + ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale))); return socket; } diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index d6b8335..6ce5e04 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -132,6 +132,92 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal(suggestion.Name, created.Name); } + [Fact] + public async Task Mods_ListCoreAsRequired() + { + using var client = fixture.App.CreateHttpClient("server"); + + var response = await client.GetFromJsonAsync("/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("/api/catalog?lang=ru", TestContext.Current.CancellationToken); + var en = await client.GetFromJsonAsync("/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(), + floors = Array.Empty(), + rooms = Array.Empty(), + links = Array.Empty(), + }, + }, + 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] public async Task Status_ReportsTheLoopAndTheLimit() { @@ -188,6 +274,31 @@ public class SchoolApiTests(AppHostFixture fixture) return created; } + internal static async Task 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(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() }, + }, + links = new[] { new { a = "yard", b = "office" } }, + }; + private static Task PostAsync(HttpClient client, string name, DateTime startDate) => client.PostAsJsonAsync( "/api/schools", @@ -213,4 +324,26 @@ public class SchoolApiTests(AppHostFixture fixture) private sealed record ProblemResponse(string? Code); private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections); + + private sealed record ModsResponse(IReadOnlyList Mods); + + private sealed record ModInfoResponse(string Id, bool Required); + + private sealed record CatalogResponse( + IReadOnlyList Territories, + IReadOnlyList Buildings, + IReadOnlyList Floors, + IReadOnlyList Rooms, + IReadOnlyList Things, + MapLayoutResponse DefaultMap); + + private sealed record DefInfoResponse(string DefName, string Label); + + private sealed record RoomInfoResponse(string DefName, string Label, IReadOnlyList Slots, IReadOnlyList Positions); + + private sealed record RoomSlotResponse(string Key, string Thing); + + private sealed record MapLayoutResponse(TerritoryResponse? Territory); + + private sealed record TerritoryResponse(string Id, string Def); } diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs new file mode 100644 index 0000000..9acd00b --- /dev/null +++ b/tests/HSchool.Content.Tests/MapViewTests.cs @@ -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); + } +} diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index d2c5299..1170962 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -8,14 +8,17 @@ namespace HSchool.Protocol.Tests; public class ProtocolCodecTests { [Fact] - public void Hello_RoundTripsAndIsTwoBytes() + public void Hello_RoundTripsAndIsThreeBytes() { - var message = new ClientHelloMessage(ProtocolConstants.Version); + var message = new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleEnglish); Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; 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])); } @@ -130,6 +133,31 @@ public class ProtocolCodecTests 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] public void Numbers_AreLittleEndian() { @@ -141,9 +169,9 @@ public class ProtocolCodecTests } [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 buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));