2026-02-03 13:48:56 +01:00
|
|
|
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";
|
2026-02-03 16:35:01 +01:00
|
|
|
import { notifyChat, notifyTransaction } from "./notifications";
|
2026-02-03 13:48:56 +01:00
|
|
|
|
|
|
|
|
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 {
|
2026-02-03 16:35:01 +01:00
|
|
|
const transaction = transfer(session, from.id, to.id, amount, null);
|
|
|
|
|
notifyTransaction(session, transaction);
|
2026-02-03 13:48:56 +01:00
|
|
|
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) {
|
2026-02-03 16:35:01 +01:00
|
|
|
case "chat_send": {
|
|
|
|
|
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
|
|
|
|
notifyChat(session, chat);
|
2026-02-03 13:48:56 +01:00
|
|
|
return;
|
2026-02-03 16:35:01 +01:00
|
|
|
}
|
2026-02-03 13:48:56 +01:00
|
|
|
case "transfer":
|
2026-02-03 16:35:01 +01:00
|
|
|
{
|
|
|
|
|
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);
|
2026-02-03 13:48:56 +01:00
|
|
|
return;
|
2026-02-03 16:35:01 +01:00
|
|
|
}
|
|
|
|
|
case "banker_force_transfer": {
|
|
|
|
|
const transaction = bankerForceTransfer(
|
2026-02-03 13:48:56 +01:00
|
|
|
session,
|
|
|
|
|
message.bankerId,
|
|
|
|
|
message.fromId,
|
|
|
|
|
message.toId,
|
|
|
|
|
message.amount,
|
|
|
|
|
message.note,
|
|
|
|
|
);
|
2026-02-03 16:35:01 +01:00
|
|
|
notifyTransaction(session, transaction);
|
2026-02-03 13:48:56 +01:00
|
|
|
return;
|
2026-02-03 16:35:01 +01:00
|
|
|
}
|
2026-02-03 13:48:56 +01:00
|
|
|
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));
|
|
|
|
|
}
|