Files
h-school/src/HSchool.Client/src/net/protocol.ts
T

403 lines
11 KiB
TypeScript

/**
* 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, 5, 10] 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}.`);
}
}