CoompanionApp/server/websocket.ts

307 lines
7.8 KiB
TypeScript

import type { ServerMessage, ClientMessage } from "./protocol";
import type { Session } from "./types";
import { DomainError } from "./errors";
import {
addChatMessage,
approveTakeover,
bankerAdjust,
bankerForceTransfer,
createChatGroup,
createDummyPlayer,
disconnectPlayer,
requestTakeover,
setBlackout,
snapshotSession,
startSession,
endSession,
transfer,
} from "./domain";
import { getSession, removeSession } from "./store";
import { now } from "./util";
import { notifyChat, notifyTransaction } from "./notifications";
const socketsBySession = new Map<string, Set<WebSocket>>();
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function scheduleTestTransfer(sessionId: string): void {
const delay = randomInt(3000, 7000);
const timer = setTimeout(() => {
testTimers.delete(sessionId);
runTestTransfer(sessionId);
}, delay);
testTimers.set(sessionId, timer);
}
function runTestTransfer(sessionId: string): void {
const session = getSession(sessionId);
if (!session || !session.isTest) {
return;
}
if (session.status !== "active") {
return;
}
const players = Array.from(session.players.values()).filter(
(player) => player.role === "player",
);
if (players.length < 2) {
scheduleTestTransfer(sessionId);
return;
}
let attempts = 0;
while (attempts < 5) {
const from = players[randomInt(0, players.length - 1)];
let to = players[randomInt(0, players.length - 1)];
if (to.id === from.id) {
attempts += 1;
continue;
}
const amount = randomInt(10, 250);
if (from.balance < amount) {
attempts += 1;
continue;
}
try {
const transaction = transfer(session, from.id, to.id, amount, null);
notifyTransaction(session, transaction);
sendStateToSession(session);
break;
} catch {
attempts += 1;
}
}
scheduleTestTransfer(sessionId);
}
export function startTestSimulation(sessionId: string): void {
if (testTimers.has(sessionId)) return;
scheduleTestTransfer(sessionId);
}
export function stopTestSimulation(sessionId: string): void {
const timer = testTimers.get(sessionId);
if (timer) {
clearTimeout(timer);
}
testTimers.delete(sessionId);
}
function getSessionSockets(sessionId: string): Set<WebSocket> {
let set = socketsBySession.get(sessionId);
if (!set) {
set = new Set();
socketsBySession.set(sessionId, set);
}
return set;
}
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
const session = getSession(sessionId);
if (!session) {
ws.close(1008, "Session not found");
return;
}
const player = session.players.get(playerId);
if (!player) {
ws.close(1008, "Player not found");
return;
}
player.connected = true;
player.isDummy = false;
player.lastActiveAt = now();
metaBySocket.set(ws, { sessionId, playerId });
getSessionSockets(sessionId).add(ws);
sendStateToSession(session);
}
export function unregisterSocket(ws: WebSocket): void {
const meta = metaBySocket.get(ws);
if (!meta) {
return;
}
const { sessionId, playerId } = meta;
const session = getSession(sessionId);
if (session) {
disconnectPlayer(session, playerId);
sendStateToSession(session);
}
const set = socketsBySession.get(sessionId);
if (set) {
set.delete(ws);
if (set.size === 0) {
socketsBySession.delete(sessionId);
if (session?.isTest) {
stopTestSimulation(sessionId);
removeSession(sessionId);
}
}
}
metaBySocket.delete(ws);
}
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
let parsed: ClientMessage;
try {
parsed = JSON.parse(messageText) as ClientMessage;
} catch (error) {
send(ws, { type: "error", message: "Invalid message" });
return;
}
const session = getSession(parsed.sessionId);
if (!session) {
send(ws, { type: "error", message: "Session not found" });
return;
}
try {
handleMessage(session, parsed);
} catch (error) {
const message =
error instanceof DomainError
? error.message
: "Unexpected error while processing request";
send(ws, { type: "error", message });
return;
}
sendStateToSession(session);
}
function handleMessage(session: Session, message: ClientMessage): void {
switch (message.type) {
case "chat_send": {
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
notifyChat(session, chat);
return;
}
case "transfer":
{
const transaction = transfer(
session,
message.playerId,
message.toPlayerId,
message.amount,
message.note,
);
notifyTransaction(session, transaction);
return;
}
case "banker_adjust": {
const transaction = bankerAdjust(
session,
message.bankerId,
message.targetId,
message.amount,
message.note,
);
notifyTransaction(session, transaction);
return;
}
case "banker_force_transfer": {
const transaction = bankerForceTransfer(
session,
message.bankerId,
message.fromId,
message.toId,
message.amount,
message.note,
);
notifyTransaction(session, transaction);
return;
}
case "banker_blackout":
setBlackout(session, message.bankerId, message.active, message.reason);
return;
case "banker_start":
startSession(session, message.bankerId);
return;
case "banker_end":
endSession(session, message.bankerId);
return;
case "banker_create_dummy":
createDummyPlayer(session, message.bankerId, message.name, message.balance);
return;
case "group_create":
createChatGroup(session, message.playerId, message.name, message.memberIds);
return;
case "takeover_request":
requestTakeover(session, message.playerId, message.dummyId);
return;
case "banker_takeover_approve": {
const assignedId = approveTakeover(
session,
message.bankerId,
message.dummyId,
message.requesterId,
);
notifyTakeoverApproval(session.id, message.requesterId, assignedId);
return;
}
case "ping":
touchPlayer(session, message.playerId);
return;
default:
return;
}
}
function touchPlayer(session: Session, playerId: string): void {
const player = session.players.get(playerId);
if (player) {
player.lastActiveAt = now();
}
}
function sendStateToSession(session: Session): void {
const sockets = socketsBySession.get(session.id);
if (!sockets) {
return;
}
const stateMessage: ServerMessage = {
type: "state",
session: snapshotSession(session),
};
sockets.forEach((socket) => send(socket, stateMessage));
}
export function broadcastSessionState(sessionId: string): void {
const session = getSession(sessionId);
if (!session) return;
sendStateToSession(session);
}
function notifyTakeoverApproval(
sessionId: string,
requesterId: string,
assignedId: string,
): void {
const sockets = socketsBySession.get(sessionId);
if (!sockets) {
return;
}
sockets.forEach((socket) => {
const meta = metaBySocket.get(socket);
if (!meta) {
return;
}
if (meta.playerId === requesterId) {
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
meta.playerId = assignedId;
}
});
}
function send(ws: WebSocket, message: ServerMessage): void {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(message));
}