/** * Browser side of the binary wire format. * * This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be * changed together and documented in `docs/protocol.md`. All numbers are little-endian. */ export const PROTOCOL_VERSION = 8; export const MessageType = { ClientHello: 0x01, ClientPing: 0x02, ClientOpenSchool: 0x03, ClientCloseSchool: 0x04, ClientSetRunning: 0x05, ClientSetSpeed: 0x06, ClientSkipEmpty: 0x07, ServerWelcome: 0x81, ServerPong: 0x82, ServerClock: 0x83, ServerSchoolGone: 0x84, ServerMapSnapshot: 0x85, ServerPresence: 0x86, } as const; /** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */ export const WireLocale = { ru: 0, en: 1, } as const; /** * Speed buttons, in wire order — the index travels, not the multiplier. * Mirror of `ClockSpeed.Multipliers` in `src/HSchool.Simulation/ClockSpeed.cs`. */ export const CLOCK_SPEEDS = [0.5, 1, 2, 3, 4] as const; export const DEFAULT_SPEED_INDEX = 1; export interface WelcomeMessage { readonly type: 'welcome'; readonly protocolVersion: number; readonly tickRate: number; readonly maxSchools: number; } export interface PongMessage { readonly type: 'pong'; readonly clientTimeMs: number; readonly serverTick: number; } export const Precipitation = { None: 0, Rain: 1, Snow: 2, } as const; export interface ClockMessage { readonly type: 'clock'; readonly schoolId: number; /** In-game date as a UTC instant; the game calendar has no time zone. */ readonly gameTime: Date; readonly running: boolean; readonly speedIndex: number; /** Server verdict; the client must not recompute this. */ readonly skipAllowed: boolean; /** UTC instant the skip would land on; null when skip is refused. */ readonly skipTarget: Date | null; /** Outdoor temperature in tenths of a °C. */ readonly temperatureTenths: number; /** `Precipitation.None` / `Rain` / `Snow`. */ readonly precipitation: number; } export interface SchoolGoneMessage { readonly type: 'school-gone'; readonly schoolId: number; } export const MapNodeKind = { Territory: 0, Building: 1, Floor: 2, Room: 3, } as const; export interface MapSnapshotItem { readonly name: string; readonly count: number; } export interface MapSnapshotNode { readonly kind: number; readonly id: string; readonly parentId: string; readonly name: string; readonly pupilSlots: number; readonly items: readonly MapSnapshotItem[]; readonly positions: readonly string[]; } export interface MapSnapshotMessage { readonly type: 'map-snapshot'; readonly schoolId: number; readonly nodes: readonly MapSnapshotNode[]; } export const PresenceState = { Here: 1, Walking: 2, } as const; export interface PresenceNode { readonly id: string; readonly count: number; readonly activitySubject: string; readonly activityClass: string; } export interface PresencePerson { readonly id: string; readonly nodeId: string; readonly state: number; } export interface PresenceMessage { readonly type: 'presence'; readonly schoolId: number; readonly nodes: readonly PresenceNode[]; readonly people: readonly PresencePerson[]; } export type ServerMessage = | WelcomeMessage | PongMessage | ClockMessage | SchoolGoneMessage | MapSnapshotMessage | PresenceMessage; /** Thrown when a frame is truncated or carries an unexpected message id. */ export class ProtocolError extends Error {} 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; } export function encodePing(clientTimeMs: number): ArrayBuffer { const buffer = new ArrayBuffer(9); const view = new DataView(buffer); view.setUint8(0, MessageType.ClientPing); view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true); return buffer; } export function encodeOpenSchool(schoolId: number): ArrayBuffer { const buffer = new ArrayBuffer(5); const view = new DataView(buffer); view.setUint8(0, MessageType.ClientOpenSchool); view.setInt32(1, schoolId, true); return buffer; } export function encodeCloseSchool(): ArrayBuffer { const buffer = new ArrayBuffer(1); new DataView(buffer).setUint8(0, MessageType.ClientCloseSchool); return buffer; } /** * Play/pause and speed are separate frames on purpose: a button that also resent the other field * would clobber it with whatever the client last saw, which is always one tick stale. */ export function encodeSetRunning(running: boolean): ArrayBuffer { const buffer = new ArrayBuffer(2); const view = new DataView(buffer); view.setUint8(0, MessageType.ClientSetRunning); view.setUint8(1, running ? 1 : 0); return buffer; } export function encodeSetSpeed(speedIndex: number): ArrayBuffer { const buffer = new ArrayBuffer(2); const view = new DataView(buffer); view.setUint8(0, MessageType.ClientSetSpeed); view.setUint8(1, speedIndex & 0xff); return buffer; } export function encodeSkipEmpty(): ArrayBuffer { const buffer = new ArrayBuffer(1); new DataView(buffer).setUint8(0, MessageType.ClientSkipEmpty); return buffer; } /** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null { if (data.byteLength === 0) { throw new ProtocolError('Empty frame.'); } const view = new DataView(data); switch (view.getUint8(0)) { case MessageType.ServerWelcome: return decodeWelcome(view); case MessageType.ServerPong: return decodePong(view); case MessageType.ServerClock: return decodeClock(view); case MessageType.ServerSchoolGone: return decodeSchoolGone(view); case MessageType.ServerMapSnapshot: return decodeMapSnapshot(view); case MessageType.ServerPresence: return decodePresence(view); default: return null; } } function decodeWelcome(view: DataView): WelcomeMessage { ensure(view, 4); return { type: 'welcome', protocolVersion: view.getUint8(1), tickRate: view.getUint8(2), maxSchools: view.getUint8(3), }; } function decodePong(view: DataView): PongMessage { ensure(view, 13); return { type: 'pong', clientTimeMs: Number(view.getBigInt64(1, true)), serverTick: view.getUint32(9, true), }; } function decodeClock(view: DataView): ClockMessage { ensure(view, 27); const skipTargetMs = Number(view.getBigInt64(16, true)); return { type: 'clock', schoolId: view.getInt32(1, true), gameTime: new Date(Number(view.getBigInt64(5, true))), running: view.getUint8(13) !== 0, speedIndex: view.getUint8(14), skipAllowed: view.getUint8(15) !== 0, skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs), temperatureTenths: view.getInt16(24, true), precipitation: view.getUint8(26), }; } function decodeSchoolGone(view: DataView): SchoolGoneMessage { ensure(view, 5); 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; ensure(view, offset + 2); const pupilSlots = view.getUint16(offset, true); offset += 2; const itemCount = readU8(view, offset); offset += 1; const items: MapSnapshotItem[] = []; for (let item = 0; item < itemCount; item++) { const value = readString(view, offset); offset = value.next; const count = readU8(view, offset); offset += 1; items.push({ name: value.text, count }); } 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, pupilSlots, items, positions, }); } return { type: 'map-snapshot', schoolId, nodes }; } function decodePresence(view: DataView): PresenceMessage { ensure(view, 7); const schoolId = view.getInt32(1, true); const nodeCount = view.getUint16(5, true); let offset = 7; const nodes: PresenceNode[] = []; for (let i = 0; i < nodeCount; i++) { const id = readString(view, offset); offset = id.next; ensure(view, offset + 2); const count = view.getUint16(offset, true); offset += 2; const hasActivity = readU8(view, offset); offset += 1; let activitySubject = ''; let activityClass = ''; if (hasActivity !== 0) { const subject = readString(view, offset); offset = subject.next; const schoolClass = readString(view, offset); offset = schoolClass.next; activitySubject = subject.text; activityClass = schoolClass.text; } nodes.push({ id: id.text, count, activitySubject, activityClass }); } ensure(view, offset + 2); const personCount = view.getUint16(offset, true); offset += 2; const people: PresencePerson[] = []; for (let i = 0; i < personCount; i++) { const id = readString(view, offset); offset = id.next; const nodeId = readString(view, offset); offset = nodeId.next; const state = readU8(view, offset); offset += 1; people.push({ id: id.text, nodeId: nodeId.text, state }); } return { type: 'presence', schoolId, nodes, people }; } 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}.`); } }