Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -15,25 +15,33 @@ import type {
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
interface ChannelOptions<In> {
interface ChannelOptions<In, Out> {
onEvent?: (event: In) => void;
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
onBinary?: (frame: ArrayBuffer) => void;
onStatus?: (status: ChannelStatus) => void;
/** Ключ безопасного идемпотентного события, которое можно повторить после
* краткого разрыва. null запрещает буферизацию команды. */
queueKey?: (event: Out) => string | null;
}
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 5_000;
const BUFFER_MAX_AGE_MS = 35_000;
const BUFFER_MAX_ITEMS = 100;
interface Buffered<Out> { key: string; event: Out; at: number }
export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null;
private attempt = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private closedByUs = false;
private buffered: Buffered<Out>[] = [];
constructor(
private readonly path: string,
private readonly options: ChannelOptions<In> = {},
private readonly options: ChannelOptions<In, Out> = {},
) {}
connect(): this {
@ -51,6 +59,10 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
socket.onopen = () => {
this.attempt = 0;
this.options.onStatus?.("open");
const now = Date.now();
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
this.buffered = [];
for (const item of pending) socket.send(JSON.stringify(item.event));
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
@ -78,11 +90,32 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
return this.socket?.readyState === WebSocket.OPEN;
}
// Пока соединения нет, события не копятся: истина — на сервере, и после
// переподключения интерфейс получит актуальное состояние, а не очередь старых правок.
send(event: Out): boolean {
if (!this.isOpen) return false;
this.socket!.send(JSON.stringify(event));
if (this.isOpen) {
this.socket!.send(JSON.stringify(event));
return true;
}
const key = this.options.queueKey?.(event) ?? null;
if (!key) return false;
const existing = this.buffered.find((item) => item.key === key);
if (existing) {
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
// но правки разных полей не должны затереть друг друга.
if (existing.event.type === "kio.patch" && event.type === "kio.patch") {
const previousPatch = existing.event as unknown as { fields: Record<string, unknown> };
const nextPatch = event as unknown as { fields: Record<string, unknown> };
existing.event = {
...existing.event,
fields: { ...previousPatch.fields, ...nextPatch.fields },
} as Out;
} else {
existing.event = event;
}
existing.at = Date.now();
} else {
this.buffered.push({ key, event, at: Date.now() });
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
}
return true;
}
@ -96,22 +129,36 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
this.closedByUs = true;
if (this.timer) clearTimeout(this.timer);
this.socket?.close();
this.buffered = [];
}
}
export const callChannel = (sessionId: string, options?: ChannelOptions<ServerToTrainee>) =>
new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, options);
export const callChannel = (
sessionId: string,
options?: ChannelOptions<ServerToTrainee, TraineeToServer>,
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
});
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver, never>) =>
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
export const stationChannel = (
sessionId: string,
role: string,
options?: ChannelOptions<ServerToStation>,
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, options);
options?: ChannelOptions<ServerToStation, StationToServer>,
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, {
...options,
queueKey: (event) => {
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
if (event.type === "crew.select") return "crew.select";
if (event.type === "zone.decision") return "zone.decision";
return null;
},
});
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>