- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure. - Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states. - Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution. - Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream. - Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity. - Updated documentation to reflect the new protocol features and changes in presence management. - Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
391 lines
11 KiB
TypeScript
391 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 = 7;
|
|
|
|
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 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;
|
|
}
|
|
|
|
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, 24);
|
|
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),
|
|
};
|
|
}
|
|
|
|
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}.`);
|
|
}
|
|
}
|