Harden websocket server against abuse
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
APP_PORT=8787
|
||||
|
||||
ALLOWED_ORIGINS=
|
||||
MAX_CLIENTS=200
|
||||
MAX_CLIENTS_PER_IP=12
|
||||
MAX_MESSAGES_PER_WINDOW=40
|
||||
TRUST_PROXY=false
|
||||
|
||||
@@ -13,4 +13,6 @@ EXPOSE 8787
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.APP_PORT || 8787) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
24
README.md
24
README.md
@@ -53,3 +53,27 @@ $env:APP_PORT=9090; npm start
|
||||
- The server keeps games in memory only.
|
||||
- If an opponent closes their window, the remaining player sees a disconnect error and waits for a new player.
|
||||
- No build step is required.
|
||||
|
||||
## Hardening Knobs
|
||||
|
||||
The server is intentionally small, but it rejects common abuse cases:
|
||||
|
||||
- static files are served from an allowlist only
|
||||
- HTTP responses include basic browser security headers
|
||||
- WebSocket upgrades are origin-checked
|
||||
- clients, clients per IP, message size, and message velocity are capped
|
||||
- moves are validated server-side before being relayed
|
||||
- the Compose container runs as non-root with a read-only filesystem and dropped capabilities
|
||||
|
||||
Optional `.env` settings:
|
||||
|
||||
```text
|
||||
APP_PORT=8787
|
||||
ALLOWED_ORIGINS=https://tic.sketchferret.com
|
||||
MAX_CLIENTS=200
|
||||
MAX_CLIENTS_PER_IP=12
|
||||
MAX_MESSAGES_PER_WINDOW=40
|
||||
TRUST_PROXY=true
|
||||
```
|
||||
|
||||
Use `TRUST_PROXY=true` only when the app is behind a reverse proxy that sets `X-Forwarded-For`.
|
||||
|
||||
11
compose.yaml
11
compose.yaml
@@ -5,7 +5,18 @@ services:
|
||||
image: tictactics:local
|
||||
container_name: tictactics
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 128
|
||||
environment:
|
||||
APP_PORT: ${APP_PORT:-8787}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||
MAX_CLIENTS: ${MAX_CLIENTS:-200}
|
||||
MAX_CLIENTS_PER_IP: ${MAX_CLIENTS_PER_IP:-12}
|
||||
MAX_MESSAGES_PER_WINDOW: ${MAX_MESSAGES_PER_WINDOW:-40}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
ports:
|
||||
- "${APP_PORT:-8787}:${APP_PORT:-8787}"
|
||||
|
||||
453
server.js
453
server.js
@@ -5,39 +5,85 @@ const path = require("path");
|
||||
|
||||
const appPort = Number(process.env.APP_PORT || 8787);
|
||||
const root = __dirname;
|
||||
const boardSize = 5;
|
||||
const winTarget = 4;
|
||||
const empty = "";
|
||||
const maxClients = Number(process.env.MAX_CLIENTS || 200);
|
||||
const maxClientsPerIp = Number(process.env.MAX_CLIENTS_PER_IP || 12);
|
||||
const maxMessageBytes = Number(process.env.MAX_MESSAGE_BYTES || 1024);
|
||||
const messageWindowMs = Number(process.env.MESSAGE_WINDOW_MS || 10_000);
|
||||
const maxMessagesPerWindow = Number(process.env.MAX_MESSAGES_PER_WINDOW || 40);
|
||||
const minActionIntervalMs = Number(process.env.MIN_ACTION_INTERVAL_MS || 180);
|
||||
const idleSocketTimeoutMs = Number(process.env.IDLE_SOCKET_TIMEOUT_MS || 120_000);
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const trustProxy = process.env.TRUST_PROXY === "true";
|
||||
|
||||
const clients = new Map();
|
||||
const rooms = new Map();
|
||||
const clientsByIp = new Map();
|
||||
let waitingClientId = "";
|
||||
let nextClientId = 1;
|
||||
let nextRoomId = 1;
|
||||
|
||||
const staticFiles = new Map([
|
||||
["/", "index.html"],
|
||||
["/index.html", "index.html"],
|
||||
["/styles.css", "styles.css"],
|
||||
["/game.js", "game.js"],
|
||||
]);
|
||||
|
||||
const mimeTypes = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
const directions = [
|
||||
[-1, -1],
|
||||
[-1, 0],
|
||||
[-1, 1],
|
||||
[0, -1],
|
||||
[0, 1],
|
||||
[1, -1],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
const winAxes = [
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
];
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
setSecurityHeaders(response);
|
||||
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
response.writeHead(405, { Allow: "GET, HEAD" });
|
||||
response.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
|
||||
|
||||
if (requestUrl.pathname === "/healthz") {
|
||||
response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
|
||||
const filePath = path.normalize(path.join(root, pathname));
|
||||
|
||||
if (!filePath.startsWith(root)) {
|
||||
response.writeHead(403);
|
||||
response.end("Forbidden");
|
||||
const staticName = staticFiles.get(requestUrl.pathname);
|
||||
if (!staticName) {
|
||||
response.writeHead(404);
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(root, staticName);
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
response.writeHead(404);
|
||||
@@ -45,21 +91,39 @@ const server = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = mimeTypes[path.extname(filePath)] || "application/octet-stream";
|
||||
response.writeHead(200, { "Content-Type": type });
|
||||
response.end(content);
|
||||
response.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": mimeTypes[path.extname(filePath)] || "application/octet-stream",
|
||||
});
|
||||
response.end(request.method === "HEAD" ? undefined : content);
|
||||
});
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket) => {
|
||||
if (request.url !== "/ws") {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 404, "Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(request)) {
|
||||
rejectUpgrade(socket, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (clients.size >= maxClients) {
|
||||
rejectUpgrade(socket, 503, "Server full");
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = clientIp(request);
|
||||
if ((clientsByIp.get(ip) || 0) >= maxClientsPerIp) {
|
||||
rejectUpgrade(socket, 429, "Too many connections");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = request.headers["sec-websocket-key"];
|
||||
if (!key) {
|
||||
socket.destroy();
|
||||
if (!isValidWebSocketKey(key) || request.headers["sec-websocket-version"] !== "13") {
|
||||
rejectUpgrade(socket, 400, "Bad websocket request");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,17 +141,87 @@ server.on("upgrade", (request, socket) => {
|
||||
"",
|
||||
].join("\r\n"));
|
||||
|
||||
addClient(socket);
|
||||
addClient(socket, ip);
|
||||
});
|
||||
|
||||
function addClient(socket) {
|
||||
function setSecurityHeaders(response) {
|
||||
response.setHeader("Content-Security-Policy", "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
||||
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.setHeader("Referrer-Policy", "no-referrer");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("X-Frame-Options", "DENY");
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, status, message) {
|
||||
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function isValidWebSocketKey(key) {
|
||||
if (typeof key !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Buffer.from(key, "base64").length === 16;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedOrigin(request) {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowedOrigins.length > 0) {
|
||||
return allowedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(origin).host === request.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clientIp(request) {
|
||||
const forwardedFor = request.headers["x-forwarded-for"];
|
||||
if (trustProxy && typeof forwardedFor === "string" && forwardedFor.length > 0) {
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return request.socket.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
function addClient(socket, ip) {
|
||||
const id = String(nextClientId);
|
||||
nextClientId += 1;
|
||||
|
||||
const client = { id, socket, roomId: "", player: "" };
|
||||
clients.set(id, client);
|
||||
const client = {
|
||||
id,
|
||||
ip,
|
||||
socket,
|
||||
roomId: "",
|
||||
player: "",
|
||||
buffer: Buffer.alloc(0),
|
||||
messageTimestamps: [],
|
||||
lastActionAt: 0,
|
||||
};
|
||||
|
||||
socket.on("data", (buffer) => handleSocketData(client, buffer));
|
||||
clients.set(id, client);
|
||||
clientsByIp.set(ip, (clientsByIp.get(ip) || 0) + 1);
|
||||
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(idleSocketTimeoutMs, () => closeClient(client, 1001, "Idle timeout"));
|
||||
socket.on("data", (buffer) => {
|
||||
try {
|
||||
handleSocketData(client, buffer);
|
||||
} catch {
|
||||
closeClient(client, 1002, "Protocol error");
|
||||
}
|
||||
});
|
||||
socket.on("close", () => removeClient(client));
|
||||
socket.on("error", () => removeClient(client));
|
||||
|
||||
@@ -110,28 +244,61 @@ function pairClients(xClient, oClient) {
|
||||
oClient.roomId = roomId;
|
||||
oClient.player = "O";
|
||||
|
||||
rooms.set(roomId, { id: roomId, players: [xClient.id, oClient.id] });
|
||||
rooms.set(roomId, {
|
||||
id: roomId,
|
||||
players: [xClient.id, oClient.id],
|
||||
board: createBoard(),
|
||||
captures: { X: 0, O: 0 },
|
||||
currentPlayer: "X",
|
||||
gameOver: false,
|
||||
});
|
||||
|
||||
send(xClient, { type: "paired", player: "X", roomId });
|
||||
send(oClient, { type: "paired", player: "O", roomId });
|
||||
}
|
||||
|
||||
function handleSocketData(client, buffer) {
|
||||
const opcode = buffer[0] & 0x0f;
|
||||
if (opcode === 0x8) {
|
||||
client.socket.write(Buffer.from([0x88, 0x00]));
|
||||
client.socket.end();
|
||||
removeClient(client);
|
||||
if (!clients.has(client.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opcode === 0x9) {
|
||||
if (client.buffer.length + buffer.length > maxMessageBytes) {
|
||||
closeClient(client, 1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
|
||||
client.buffer = Buffer.concat([client.buffer, buffer]);
|
||||
|
||||
while (client.buffer.length > 0) {
|
||||
const frame = readFrame(client.buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.buffer = client.buffer.subarray(frame.consumed);
|
||||
|
||||
if (frame.opcode === 0x8) {
|
||||
closeClient(client, 1000, "Closing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === 0x9) {
|
||||
client.socket.write(Buffer.from([0x8a, 0x00]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode !== 0x1) {
|
||||
closeClient(client, 1003, "Unsupported frame");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = decodeFrame(buffer);
|
||||
if (!message) {
|
||||
handleTextMessage(client, frame.payload.toString("utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextMessage(client, message) {
|
||||
if (!checkMessageRate(client)) {
|
||||
closeClient(client, 1008, "Rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,14 +310,21 @@ function handleSocketData(client, buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.roomId || payload.roomId !== client.roomId) {
|
||||
if (!isObject(payload) || !isSafeAction(client)) {
|
||||
send(client, { type: "error", message: "Slow down." });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = rooms.get(client.roomId);
|
||||
if (!room || payload.roomId !== client.roomId) {
|
||||
send(client, { type: "error", message: "You are not currently paired." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "move") {
|
||||
if (payload.player !== client.player) {
|
||||
send(client, { type: "error", message: "Move rejected for wrong player." });
|
||||
const result = applyMove(room, client, payload);
|
||||
if (!result.ok) {
|
||||
send(client, { type: "error", message: result.message });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,14 +332,68 @@ function handleSocketData(client, buffer) {
|
||||
type: "move",
|
||||
row: payload.row,
|
||||
col: payload.col,
|
||||
player: payload.player,
|
||||
player: client.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "reset") {
|
||||
room.board = createBoard();
|
||||
room.captures = { X: 0, O: 0 };
|
||||
room.currentPlayer = "X";
|
||||
room.gameOver = false;
|
||||
relayToOpponent(client, { type: "reset" });
|
||||
return;
|
||||
}
|
||||
|
||||
send(client, { type: "error", message: "Unsupported message type." });
|
||||
}
|
||||
|
||||
function checkMessageRate(client) {
|
||||
const now = Date.now();
|
||||
client.messageTimestamps = client.messageTimestamps.filter((timestamp) => now - timestamp < messageWindowMs);
|
||||
client.messageTimestamps.push(now);
|
||||
return client.messageTimestamps.length <= maxMessagesPerWindow;
|
||||
}
|
||||
|
||||
function isSafeAction(client) {
|
||||
const now = Date.now();
|
||||
if (now - client.lastActionAt < minActionIntervalMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
client.lastActionAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyMove(room, client, payload) {
|
||||
if (room.gameOver) {
|
||||
return { ok: false, message: "Game is already over." };
|
||||
}
|
||||
|
||||
if (client.player !== room.currentPlayer || payload.player !== client.player) {
|
||||
return { ok: false, message: "Not your turn." };
|
||||
}
|
||||
|
||||
if (!isBoardCoordinate(payload.row) || !isBoardCoordinate(payload.col)) {
|
||||
return { ok: false, message: "Move is out of bounds." };
|
||||
}
|
||||
|
||||
if (room.board[payload.row][payload.col] !== empty) {
|
||||
return { ok: false, message: "Cell is already occupied." };
|
||||
}
|
||||
|
||||
room.board[payload.row][payload.col] = client.player;
|
||||
const captured = captureFrom(room.board, payload.row, payload.col, client.player);
|
||||
room.captures[client.player] += captured.length;
|
||||
|
||||
if (findWin(room.board, client.player).length > 0 || isDraw(room.board)) {
|
||||
room.gameOver = true;
|
||||
} else {
|
||||
room.currentPlayer = opponentOf(client.player);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function removeClient(client) {
|
||||
@@ -174,6 +402,12 @@ function removeClient(client) {
|
||||
}
|
||||
|
||||
clients.delete(client.id);
|
||||
const ipCount = (clientsByIp.get(client.ip) || 1) - 1;
|
||||
if (ipCount <= 0) {
|
||||
clientsByIp.delete(client.ip);
|
||||
} else {
|
||||
clientsByIp.set(client.ip, ipCount);
|
||||
}
|
||||
|
||||
if (waitingClientId === client.id) {
|
||||
waitingClientId = "";
|
||||
@@ -228,39 +462,72 @@ function send(client, payload) {
|
||||
client.socket.write(encodeFrame(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
function decodeFrame(buffer) {
|
||||
function closeClient(client, code, reason) {
|
||||
if (client.socket.destroyed) {
|
||||
removeClient(client);
|
||||
return;
|
||||
}
|
||||
|
||||
client.socket.write(encodeCloseFrame(code, reason));
|
||||
client.socket.end();
|
||||
removeClient(client);
|
||||
}
|
||||
|
||||
function readFrame(buffer) {
|
||||
if (buffer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstByte = buffer[0];
|
||||
const opcode = firstByte & 0x0f;
|
||||
const secondByte = buffer[1];
|
||||
const masked = (secondByte & 0x80) === 0x80;
|
||||
let length = secondByte & 0x7f;
|
||||
let offset = 2;
|
||||
|
||||
if (!masked) {
|
||||
throwAndClose();
|
||||
}
|
||||
|
||||
if (length === 126) {
|
||||
if (buffer.length < offset + 2) {
|
||||
return null;
|
||||
}
|
||||
length = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
} else if (length === 127) {
|
||||
if (buffer.length < offset + 8) {
|
||||
return null;
|
||||
}
|
||||
length = Number(buffer.readBigUInt64BE(offset));
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
let mask;
|
||||
if (masked) {
|
||||
mask = buffer.subarray(offset, offset + 4);
|
||||
offset += 4;
|
||||
if (length > maxMessageBytes) {
|
||||
throwAndClose();
|
||||
}
|
||||
|
||||
const payload = buffer.subarray(offset, offset + length);
|
||||
if (!masked) {
|
||||
return payload.toString("utf8");
|
||||
const maskEnd = offset + 4;
|
||||
const payloadEnd = maskEnd + length;
|
||||
if (buffer.length < payloadEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mask = buffer.subarray(offset, maskEnd);
|
||||
const payload = buffer.subarray(maskEnd, payloadEnd);
|
||||
const decoded = Buffer.alloc(payload.length);
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
decoded[index] = payload[index] ^ mask[index % 4];
|
||||
}
|
||||
|
||||
return decoded.toString("utf8");
|
||||
return {
|
||||
consumed: payloadEnd,
|
||||
opcode: firstByte & 0x0f,
|
||||
payload: decoded,
|
||||
};
|
||||
}
|
||||
|
||||
function throwAndClose() {
|
||||
throw new Error("Invalid websocket frame");
|
||||
}
|
||||
|
||||
function encodeFrame(message) {
|
||||
@@ -271,7 +538,6 @@ function encodeFrame(message) {
|
||||
return Buffer.concat([Buffer.from([0x81, length]), payload]);
|
||||
}
|
||||
|
||||
if (length < 65536) {
|
||||
const header = Buffer.alloc(4);
|
||||
header[0] = 0x81;
|
||||
header[1] = 126;
|
||||
@@ -279,13 +545,100 @@ function encodeFrame(message) {
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(length), 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
function encodeCloseFrame(code, reason) {
|
||||
const reasonBuffer = Buffer.from(reason.slice(0, 80));
|
||||
const payload = Buffer.alloc(2 + reasonBuffer.length);
|
||||
payload.writeUInt16BE(code, 0);
|
||||
reasonBuffer.copy(payload, 2);
|
||||
return Buffer.concat([Buffer.from([0x88, payload.length]), payload]);
|
||||
}
|
||||
|
||||
function createBoard() {
|
||||
return Array.from({ length: boardSize }, () => Array(boardSize).fill(empty));
|
||||
}
|
||||
|
||||
function captureFrom(board, row, col, player) {
|
||||
const opponent = opponentOf(player);
|
||||
const captured = [];
|
||||
|
||||
for (const [rowStep, colStep] of directions) {
|
||||
const candidates = [];
|
||||
let nextRow = row + rowStep;
|
||||
let nextCol = col + colStep;
|
||||
|
||||
while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) {
|
||||
candidates.push([nextRow, nextCol]);
|
||||
nextRow += rowStep;
|
||||
nextCol += colStep;
|
||||
}
|
||||
|
||||
if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) {
|
||||
captured.push(...candidates);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [captureRow, captureCol] of captured) {
|
||||
board[captureRow][captureCol] = empty;
|
||||
}
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
function findWin(board, player) {
|
||||
for (let row = 0; row < boardSize; row += 1) {
|
||||
for (let col = 0; col < boardSize; col += 1) {
|
||||
if (board[row][col] !== player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [rowStep, colStep] of winAxes) {
|
||||
const cells = [[row, col]];
|
||||
|
||||
for (let index = 1; index < winTarget; index += 1) {
|
||||
const nextRow = row + rowStep * index;
|
||||
const nextCol = col + colStep * index;
|
||||
|
||||
if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) {
|
||||
break;
|
||||
}
|
||||
|
||||
cells.push([nextRow, nextCol]);
|
||||
}
|
||||
|
||||
if (cells.length === winTarget) {
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isDraw(board) {
|
||||
return board.every((row) => row.every((cell) => cell !== empty));
|
||||
}
|
||||
|
||||
function opponentOf(player) {
|
||||
return player === "X" ? "O" : "X";
|
||||
}
|
||||
|
||||
function isInside(row, col) {
|
||||
return row >= 0 && row < boardSize && col >= 0 && col < boardSize;
|
||||
}
|
||||
|
||||
function isBoardCoordinate(value) {
|
||||
return Number.isInteger(value) && value >= 0 && value < boardSize;
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
server.on("clientError", (_error, socket) => {
|
||||
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
|
||||
server.listen(appPort, () => {
|
||||
console.log(`Tictactics multiplayer server running at http://localhost:${appPort}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user