"use strict"; const express = require("express"), router = express.Router(), crypto = require("crypto"), { readFile, writeFile } = require("fs").promises, fs = require("fs"), accessSync = fs.accessSync, randomWords = require("random-words"), equal = require("fast-deep-equal"); const debug = { audio: false, get: true, set: true, update: false }; let chatDB; const chats = {}; const audio = {}; require("../db/chats").then(function(db) { chatDB = db; }); const sessionFromColor = (room, color) => { for (let key in room.sessions) { if (room.sessions[key].color === color) { return room.sessions[key]; } } } const newPerson = (color) => { return { status: "Not active", lastActive: 0, color: color, name: "", }; } const getSession = (room, id) => { if (!room.sessions) { room.sessions = {}; } /* If this session is not yet in the chat, add it and set the person's name */ if (!(id in room.sessions)) { room.sessions[id] = { id: `[${id.substring(0, 8)}]`, name: '', color: '', person: undefined, lastActive: Date.now(), live: true }; } const session = room.sessions[id]; session.lastActive = Date.now(); session.live = true; if (session.person) { session.person.live = true; session.person.lastActive = session.lastActive; } /* Expire old unused sessions */ for (let _id in room.sessions) { const _session = room.sessions[_id]; if (_session.color || _session.name || _session.person) { continue; } if (_id === id) { continue; } /* 60 minutes */ const age = Date.now() - _session.lastActive; if (age > 60 * 60 * 1000) { console.log(`${_session.id}: Expiring old session ${_id}: ${age/(60 * 1000)} minutes`); delete room.sessions[_id]; if (_id in room.sessions) { console.log('delete DID NOT WORK!'); } } } return room.sessions[id]; }; const loadChat = async (id) => { if (/^\.|\//.exec(id)) { return undefined; } if (id in chats) { return chats[id]; } let room = await readFile(`chats/${id}`) .catch(() => { return; }); if (room) { try { room = JSON.parse(room); console.log(`${info}: Creating backup of chats/${id}`); await writeFile(`chats/${id}.bk`, JSON.stringify(room)); } catch (error) { console.log(`Load or parse error from chats/${id}:`, error); console.log(`Attempting to load backup from chats/${id}.bk`); room = await readFile(`chats/${id}.bk`) .catch(() => { console.error(error, room); }); if (room) { try { room = JSON.parse(room); console.log(`Saving backup to chats/${id}`); await writeFile(`chats/${id}`, JSON.stringify(room, null, 2)); } catch (error) { console.error(error); room = null; } } } } if (!room) { room = createChat(id); } /* Clear out cached names from person colors and rebuild them * from the information in the saved chat sessions */ for (let color in room.persons) { delete room.persons[color].name; room.persons[color].status = 'Not active'; } /* Reconnect session person colors to the person objects */ room.unselected = []; for (let id in room.sessions) { const session = room.sessions[id]; if (session.name && session.color && session.color in room.persons) { session.person = room.persons[session.color]; session.person.name = session.name; session.person.status = 'Active'; session.person.live = false; } else { session.color = ''; session.person = undefined; } session.live = false; /* Populate the 'unselected' list from the session table */ if (!room.sessions[id].color && room.sessions[id].name) { room.unselected.push(room.sessions[id]); } } chats[id] = room; return room; }; const clearPerson = (person) => { const color = person.color; for (let key in person) { delete person[key]; } Object.assign(person, newPerson(color)); } const setPersonName = (room, session, name) => { if (session.name === name) { return; /* no-op */ } if (session.color) { return `You cannot change your name while you have a color selected.`; } if (!name) { return `You can not set your name to nothing!`; } /* Check to ensure name is not already in use */ let rejoin = false; for (let id in room.sessions) { const tmp = room.sessions[id]; if (tmp === session || !tmp.name) { continue; } if (tmp.name.toLowerCase() === name.toLowerCase()) { if (!tmp.person || (Date.now() - tmp.person.lastActive) > 60000) { rejoin = true; /* Update the session object from tmp, but retain websocket * from active session */ Object.assign(session, tmp, { ws: session.ws, id: session.id }); console.log(`${info}: ${name} has been reallocated to a new session.`); delete room.sessions[id]; } else { return `${name} is already taken and has been active in the last minute.`; } } } let message; if (!session.name) { message = `A new person has entered the lobby as ${name}.`; } else { if (rejoin) { if (session.color) { message = `${name} has reconnected to the room.`; } else { message = `${name} has rejoined the lobby.`; } session.name = name; if (session.ws && (room.id in audio) && session.name in audio[room.id]) { part(audio[room.id], session); } } else { message = `${session.name} has changed their name to ${name}.`; if (session.ws && room.id in audio) { part(audio[room.id], session); } } } session.name = name; session.live = true; if (session.person) { session.color = session.person.color; session.person.name = session.name; session.person.status = `Active`; session.person.lastActive = Date.now(); session.person.name = name; session.person.live = true; } if (session.ws && session.hasAudio) { join(audio[room.id], session, { hasVideo: session.video ? true : false, hasAudio: session.audio ? true : false }); } console.log(`${info}: ${message}`); addChatMessage(room, null, message); /* Rebuild the unselected list */ if (!session.color) { console.log(`${info}: Adding ${session.name} to the unselected`); } room.unselected = []; for (let id in room.sessions) { if (!room.sessions[id].color && room.sessions[id].name) { room.unselected.push(room.sessions[id]); } } sendUpdateToPerson(room, session, { name: session.name, color: session.color, live: session.live, private: session.person }); sendUpdateToPersons(room, { persons: getFilteredPersons(room), unselected: getFilteredUnselected(room), chat: room.chat }); /* Now that a name is set, send the full chat to the person */ sendChatToPerson(room, session); } const colorToWord = (color) => { switch (color) { case 'O': return 'orange'; case 'W': return 'white'; case 'B': return 'blue'; case 'R': return 'red'; default: return ''; } } const getActiveCount = (room) => { let active = 0; for (let color in room.persons) { if (!room.persons[color].name) { continue; } active++; } return active; } const setPersonColor = (room, session, color) => { /* Selecting the same color is a NO-OP */ if (session.color === color) { return; } /* Verify the person has a name set */ if (!session.name) { return `You may only select a person when you have set your name.`; } if (room.state !== 'lobby') { return `You may only select a person when the chat is in the lobby.`; } /* Verify selection is valid */ if (color && !(color in room.persons)) { return `An invalid person selection was attempted.`; } /* Verify selection is not already taken */ if (color && room.persons[color].status !== 'Not active') { return `${room.persons[color].name} already has ${colorToWord(color)}`; } let active = getActiveCount(room); if (session.person) { /* Deselect currently active person for this session */ clearPerson(session.person); session.person = undefined; session.color = ''; active--; /* If the person is not selecting a color, then return */ if (!color) { addChatMessage(room, null, `${session.name} is no longer ${colorToWord(session.color)}.`); room.unselected.push(session); room.active = active; if (active === 1) { addChatMessage(room, null, `There are no longer enough persons to start a room.`); } sendUpdateToPerson(room, session, { name: session.name, color: '', live: session.live, private: session.person }); sendUpdateToPersons(room, { active: room.active, unselected: getFilteredUnselected(room), persons: getFilteredPersons(room), chat: room.chat }); return; } } /* All good -- set this person to requested selection */ active++; session.color = color; session.live = true; session.person = room.persons[color]; session.person.name = session.name; session.person.status = `Active`; session.person.lastActive = Date.now(); session.person.live = true; addChatMessage(room, session, `${session.name} has chosen to play as ${colorToWord(color)}.`); const update = { persons: getFilteredPersons(room), chat: room.chat }; /* Rebuild the unselected list */ const unselected = []; for (let id in room.sessions) { if (!room.sessions[id].color && room.sessions[id].name) { unselected.push(room.sessions[id]); } } if (unselected.length !== room.unselected.length) { room.unselected = unselected; update.unselected = getFilteredUnselected(room); } if (room.active !== active) { if (room.active < 2 && active >= 2) { addChatMessage(room, null, `There are now enough persons to start the room.`); } room.active = active; update.active = room.active; } sendUpdateToPerson(room, session, { name: session.name, color: session.color, live: session.live, private: session.person, }); sendUpdateToPersons(room, update); }; const addActivity = (room, session, message) => { let date = Date.now(); if (room.activities.length && room.activities[room.activities.length - 1].date === date) { date++; } room.activities.push({ color: session ? session.color : '', message, date }); if (room.activities.length > 30) { room.activities.splice(0, room.activities.length - 30); } } const addChatMessage = (room, session, message, isNormalChat) => { let now = Date.now(); let lastTime = 0; if (room.chat.length) { lastTime = room.chat[room.chat.length - 1].date; } if (now <= lastTime) { now = lastTime + 1; } const entry = { date: now, message: message }; if (isNormalChat) { entry.normalChat = true; } if (session && session.name) { entry.from = session.name; } if (session && session.color) { entry.color = session.color; } room.chat.push(entry); if (room.chat.length > 50) { room.chat.splice(0, room.chat.length - 50); } }; const getColorFromName = (room, name) => { for (let id in room.sessions) { if (room.sessions[id].name === name) { return room.sessions[id].color; } } return ''; }; const getLastPersonName = (room) => { let index = room.personOrder.length - 1; for (let id in room.sessions) { if (room.sessions[id].color === room.personOrder[index]) { return room.sessions[id].name; } } return ''; } const getFirstPersonName = (room) => { let index = 0; for (let id in room.sessions) { if (room.sessions[id].color === room.personOrder[index]) { return room.sessions[id].name; } } return ''; } router.put("/:id/:action/:value?", async (req, res) => { const { action, id } = req.params, value = req.params.value ? req.params.value : ""; console.log(`PUT chats/${id}/${action}/${value}`); const chat = await loadChat(id); if (!chat) { const error = `Chat not found and cannot be created: ${id}`; return res.status(404).send(error); } let error = 'Invalid request'; if ('private-token' in req.headers) { if (req.headers['private-token'] !== req.app.get('admin')) { error = `Invalid admin credentials.`; } else { error = adminCommands(room, action, value, req.query); } if (!error) { sendChatToPersons(room); } else { console.log(`admin-action error: ${error}`); } } return res.status(400).send(error); }); const clearTimeNotice= (room, session) => { if (!session.person.turnNotice) { /* benign state; don't alert the user */ //return `You have not been idle.`; } session.person.turnNotice = ""; sendUpdateToPerson(room, session, { private: session.person }); }; const startTurnTimer = (room, session) => { const timeout = 90; if (!session.ws) { console.log(`${session.id}: Aborting turn timer as ${session.name} is disconnected.`); } else { console.log(`${session.id}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`); } if (room.turnTimer) { clearTimeout(room.turnTimer); } if (!session.connected) { room.turnTimer = 0; return; } room.turnTimer = setTimeout(() => { console.log(`${session.id}: Turn timer expired for ${session.name}`); session.person.turnNotice = 'It is still your turn.'; sendUpdateToPerson(room, session, { private: session.person }); resetTurnTimer(room, session); }, timeout * 1000); } const resetTurnTimer = (room, session) => { startTurnTimer(room, session); } const stopTurnTimer = (room) => { if (room.turnTimer) { console.log(`${info}: Stopping turn timer.`); clearTimeout(room.turnTimer); room.turnTimer = 0; } } const ping = (session) => { if (!session.ws) { console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`); return; } session.ping = Date.now(); // console.log(`Sending ping to ${session.name}`); session.ws.send(JSON.stringify({ type: 'ping', ping: session.ping })); if (session.keepAlive) { clearTimeout(session.keepAlive); } session.keepAlive = setTimeout(() => { ping(session); }, 2500); } const wsInactive = (room, req) => { const session = getSession(room, req.cookies.person); if (session && session.ws) { console.log(`Closing WebSocket to ${session.name} due to inactivity.`); session.ws.close(); session.ws = undefined; } /* Prevent future pings */ if (req.keepAlive) { clearTimeout(req.keepAlive); } } const setChatState = (room, session, state) => { if (!state) { return `Invalid state.`; } if (!session.color) { return `You must have an active person to start the room.`; } if (state === room.state) { return; } switch (state) { case "chat-order": if (room.state !== 'lobby') { return `You can only start the chat from the lobby.`; } const active = getActiveCount(room); if (active < 2) { return `You need at least two persons to start the room.`; } /* Delete any non-played colors from the person map; reduces all * code that would otherwise have to filter out persons by checking * the 'Not active' state of person.status */ for (let key in room.persons) { if (room.persons[key].status !== 'Active') { delete room.persons[key]; } } addChatMessage(room, null, `${session.name} requested to start the room.`); room.state = state; sendUpdateToPersons(room, { state: room.state, chat: room.chat }); break; } } const resetDisconnectCheck = (room, req) => { if (req.disconnectCheck) { clearTimeout(req.disconnectCheck); } //req.disconnectCheck = setTimeout(() => { wsInactive(room, req) }, 20000); } const join = (peers, session, { hasVideo, hasAudio }) => { const ws = session.ws; if (!session.name) { console.error(`${session.id}: <- join - No name set yet. Audio not available.`); return; } console.log(`${session.id}: <- join - ${session.name}`); console.log(`${all}: -> addPeer - ${session.name}`); if (session.name in peers) { console.log(`${session.id}:${session.name} - Already joined to Audio.`); return; } for (let peer in peers) { /* Add this caller to all peers */ peers[peer].ws.send(JSON.stringify({ type: 'addPeer', data: { peer_id: session.name, should_create_offer: false, hasAudio, hasVideo } })); /* Add each other peer to the caller */ ws.send(JSON.stringify({ type: 'addPeer', data: { peer_id: peer, should_create_offer: true, hasAudio: peers[peer].hasAudio, hasVideo: peers[peer].hasVideo } })); } /* Add this user as a peer connected to this WebSocket */ peers[session.name] = { ws, hasAudio, hasVideo }; }; const part = (peers, session) => { const ws = session.ws; if (!session.name) { console.error(`${session.id}: <- part - No name set yet. Audio not available.`); return; } if (!(session.name in peers)) { console.log(`${session.id}: <- ${session.name} - Does not exist in chat audio.`); return; } console.log(`${session.id}: <- ${session.name} - Audio part.`); console.log(`${all}: -> removePeer - ${session.name}`); delete peers[session.name]; /* Remove this peer from all other peers, and remove each * peer from this peer */ for (let peer in peers) { peers[peer].ws.send(JSON.stringify({ type: 'removePeer', data: {'peer_id': session.name} })); ws.send(JSON.stringify({ type: 'removePeer', data: {'peer_id': session.name} })); } }; const getName = (session) => { return session ? (session.name ? session.name : session.id) : 'Admin'; } const saveChat = async (room) => { /* Shallow copy chat, filling its sessions with a shallow copy of sessions so we can then * delete the person field from them */ const reducedChat = Object.assign({}, chat, { sessions: {} }), reducedSessions = []; for (let id in room.sessions) { const reduced = Object.assign({}, room.sessions[id]); if (reduced.person) { delete reduced.person; } if (reduced.ws) { delete reduced.ws; } if (reduced.keepAlive) { delete reduced.keepAlive; } reducedroom.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } delete reducedroom.turnTimer; delete reducedroom.unselected; /* Save per turn while debugging... */ room.step = room.step ? room.step : 0; /* await writeFile(`chats/${room.id}.${room.step++}`, JSON.stringify(reducedChat, null, 2)) .catch((error) => { console.error(`${session.id} Unable to write to chats/${room.id}`); console.error(error); }); */ await writeFile(`chats/${room.id}`, JSON.stringify(reducedChat, null, 2)) .catch((error) => { console.error(`${session.id} Unable to write to chats/${room.id}`); console.error(error); }); } const departLobby = (room, session, color) => { const update = {}; update.unselected = getFilteredUnselected(room); if (session.person) { session.person.live = false; update.persons = room.persons; } if (session.name) { if (session.color) { addChatMessage(room, null, `${session.name} has disconnected ` + `from the room.`); } else { addChatMessage(room, null, `${session.name} has left the lobby.`); } update.chat = room.chat; } else { console.log(`${session.id}: departLobby - ${getName(session)} is ` + `being removed from ${room.id}'s sessions.`); for (let id in room.sessions) { if (room.sessions[id] === session) { delete room.sessions[id]; break; } } } sendUpdateToPersons(room, update); } const all = `[ all ]`; const info = `[ info ]`; const todo = `[ todo ]`; const sendChatToPerson = (room, session) => { console.log(`${session.id}: -> sendChatPerson:${getName(session)} - full chat`); if (!session.ws) { console.log(`${session.id}: -> sendChatPerson:: Currently no connection`); return; } let update; /* Only send empty name data to unnamed persons */ if (!session.name) { console.log(`${session.id}: -> sendChatPerson:${getName(session)} - only sending empty name`); update = { name: "" }; } else { update = getFilteredChatForPerson(room, session); } session.ws.send(JSON.stringify({ type: 'chat-update', update: update })); }; const sendChatToPersons = (room) => { console.log(`${all}: -> sendChatPersons - full chat`); for (let key in room.sessions) { sendChatToPerson(room, room.sessions[key]); } }; const sendUpdateToPersons = async (room, update) => { /* Ensure clearing of a field actually gets sent by setting * undefined to 'false' */ for (let key in update) { if (update[key] === undefined) { update[key] = false; } } calculatePoints(room, update); if (debug.update) { console.log(`[ all ]: -> sendUpdateToPersons - `, update); } else { const keys = Object.getOwnPropertyNames(update); console.log(`[ all ]: -> sendUpdateToPersons - ${keys.join(',')}`); } const message = JSON.stringify({ type: 'chat-update', update }); for (let key in room.sessions) { const session = room.sessions[key]; /* Only send person and chat data to named persons */ if (!session.name) { console.log(`${session.id}: -> sendUpdateToPersons:` + `${getName(session)} - only sending empty name`); if (session.ws) { session.ws.send(JSON.stringify({ type: 'chat-update', update: { name: "" } })); } continue; } if (!session.ws) { console.log(`${session.id}: -> sendUpdateToPersons: ` + `Currently no connection.`); } else { session.ws.send(message); } } } const sendUpdateToPerson = async (room, session, update) => { /* If this person does not have a name, *ONLY* send the name, regardless * of what is requested */ if (!session.name) { console.log(`${session.id}: -> sendUpdateToPerson:${getName(session)} - only sending empty name`); update = { name: "" }; } /* Ensure clearing of a field actually gets sent by setting * undefined to 'false' */ for (let key in update) { if (update[key] === undefined) { update[key] = false; } } calculatePoints(room, update); if (debug.update) { console.log(`${session.id}: -> sendUpdateToPerson:${getName(session)} - `, update); } else { const keys = Object.getOwnPropertyNames(update); console.log(`${session.id}: -> sendUpdateToPerson:${getName(session)} - ${keys.join(',')}`); } const message = JSON.stringify({ type: 'chat-update', update }); if (!session.ws) { console.log(`${session.id}: -> sendUpdateToPerson: ` + `Currently no connection.`); } else { session.ws.send(message); } } const getFilteredUnselected = (room) => { if (!room.unselected) { return []; } return room.unselected .filter(session => session.live) .map(session => session.name); } const parseChatCommands = (room, message) => { /* Chat messages can set chat flags and fields */ const parts = message.match(/^set +([^ ]*) +(.*)$/i); if (!parts || parts.length !== 3) { return; } switch (parts[1].toLowerCase()) { case 'chat': if (parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) { setBeginnerChat(room); addChatMessage(room, session, `${session.name} set chat board to the Beginner's Layout.`); break; } const signature = parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i); if (signature) { if (setChatFromSignature(room, signature[1], signature[2], signature[3])) { room.signature = parts[2]; addChatMessage(room, session, `${session.name} set chat board to ${parts[2]}.`); } else { addChatMessage(room, session, `${session.name} requested an invalid chat board.`); } } break; } }; const sendError = (session, error) => { session.ws.send(JSON.stringify({ type: 'error', error })); } const sendWarning = (session, warning) => { session.ws.send(JSON.stringify({ type: 'warning', warning })); } const getFilteredPersons = (room) => { const filtered = {}; for (let color in room.persons) { const person = Object.assign({}, room.persons[color]); filtered[color] = person; if (person.status === 'Not active') { if (room.state !== 'lobby') { delete filtered[color]; } continue; } person.resources = 0; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { person.resources += person[resource]; delete person[resource]; }); delete person.development; } return filtered; }; const calculatePoints = (room, update) => { if (room.state === 'winner') { return; } /* Calculate points and determine if there is a winner */ for (let key in room.persons) { const person = room.persons[key]; if (person.status === 'Not active') { continue; } const currentPoints = person.points; person.points = 0; if (key === room.longestRoad) { person.points += 2; } if (key === room.largestArmy) { person.points += 2; } if (key === room.mostPorts) { person.points += 2; } if (key === room.mostDeveloped) { person.points += 2; } person.points += MAX_SETTLEMENTS - person.settlements; person.points += 2 * (MAX_CITIES - person.cities); person.unplayed = 0; person.potential = 0; person.development.forEach(card => { if (card.type === 'vp') { if (card.played) { person.points++; } else { person.potential++; } } if (!card.played) { person.unplayed++; } }); if (person.points === currentPoints) { continue; } if (person.points < getVictoryPointRule(room)) { update.persons = getFilteredPersons(room); continue; } /* This person has enough points! Check if they are the current * person and if so, declare victory! */ console.log(`${info}: Whoa! ${person.name} has ${person.points}!`); for (let key in room.sessions) { if (room.sessions[key].color !== person.color || room.sessions[key].status === 'Not active') { continue; } const message = `Wahoo! ${person.name} has ${person.points} ` + `points on their turn and has won!`; addChatMessage(room, null, message) console.log(`${info}: ${message}`); update.winner = Object.assign({}, person, { state: 'winner', stolen: room.stolen, chat: room.chat, turns: room.turns, persons: room.persons, elapsedTime: Date.now() - room.startTime }); room.winner = update.winner; room.state = 'winner'; room.waiting = []; stopTurnTimer(room); sendUpdateToPersons(room, { state: room.state, winner: room.winner, persons: room.persons /* unfiltered */ }); } } /* If the chat isn't in a win state, do not share development card information * with other persons */ if (room.state !== 'winner') { for (let key in room.persons) { const person = room.persons[key]; if (person.status === 'Not active') { continue; } delete person.potential; } } } const clearChat = (room, session) => { resetChat(room); addChatMessage(room, null, `The chat has been reset. You can play again with this board, or ` + `click 'New board' to mix things up a bit.`); sendChatToPersons(room); }; const gotoLobby = (room, session) => { if (!room.waiting) { room.waiting = []; } const already = room.waiting.indexOf(session.name) !== -1; const waitingFor = []; for (let key in room.sessions) { if (room.sessions[key] === session) { continue; } if (room.sessions[key].person && room.waiting.indexOf(room.sessions[key].name) == -1) { waitingFor.push(room.sessions[key].name); } } if (!already) { room.waiting.push(session.name); addChatMessage(room, null, `${session.name} has gone to the lobby.`); } else if (waitingFor.length !== 0) { return `You are already waiting in the lobby. ` + `${waitingFor.join(',')} still needs to go to the lobby.`; } if (waitingFor.length === 0) { resetChat(room); addChatMessage(room, null, `All persons are back to the lobby.`); addChatMessage(room, null, `The chat has been reset. You can play again with this board, or `+ `click 'New board' to mix things up a bit.`); sendChatToPersons(room); return; } addChatMessage(room, null, `Waiting for ${waitingFor.join(',')} to go to lobby.`); sendUpdateToPersons(room, { chat: room.chat }); } router.ws("/ws/:id", async (ws, req) => { console.log(`WebSocket`); if (!req.cookies || !req.cookies.person) { ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` })); return; } const { id } = req.params; const roomId = id; const short = `[${req.cookies.person.substring(0, 8)}]`; ws.id = short; console.log(`${short}: Chat ${roomId} - New connection from client.`); if (!(id in audio)) { audio[id] = {}; /* List of peer sockets using session.name as index. */ console.log(`${short}: Chat ${id} - New Chat Audio`); } else { console.log(`${short}: Chat ${id} - Already has Audio`); } /* Setup WebSocket event handlers prior to performing any async calls or * we may miss the first messages from clients */ ws.on('error', async (event) => { console.error(`WebSocket error: `, event.message); const chat = await loadChat(roomId); if (!chat) { return; } const session = getSession(room, req.cookies.person); session.live = false; if (session.ws) { session.ws.close(); session.ws = undefined; } departLobby(room, session); }); ws.on('close', async (event) => { console.log(`${short} - closed connection`); const chat = await loadChat(roomId); if (!chat) { return; } const session = getSession(room, req.cookies.person); if (session.person) { session.person.live = false; } session.live = false; if (session.ws) { /* Cleanup any voice channels */ if (id in audio) { part(audio[id], session); } session.ws.close(); session.ws = undefined; console.log(`${short}:WebSocket closed for ${getName(session)}`); } departLobby(room, session); /* Check for a chat in the Winner state with no more connections * and remove it */ if (room.state === 'lobby') { let dead = true; for (let id in room.sessions) { if (room.sessions[id].live && room.sessions[id].name) { dead = false; } } if (dead) { console.log(`${session.id}: No more persons in ${room.id}. ` + `Removing.`); addChatMessage(room, null, `No more active persons in room. ` + `It is being removed from the server.`); sendUpdateToPersons(room, { chat: room.chat }); for (let id in room.sessions) { if (room.sessions[id].ws) { room.sessions[id].ws.close(); delete room.sessions[id]; } } delete audio[id]; delete chats[id]; try { fs.unlinkSync(`chats/${id}`); } catch (error) { console.error(`${session.id}: Unable to remove chats/${id}`); } } } }); ws.on('message', async (message) => { let data; try { data = JSON.parse(message); } catch (error) { console.error(`${all}: parse error`, message); return; } const chat = await loadChat(roomId); const session = getSession(room, req.cookies.person); if (!session.ws) { session.ws = ws; } if (session.person) { session.person.live = true; } session.live = true; session.lastActive = Date.now(); let error, warning, update, processed = true; switch (data.type) { case 'join': join(audio[id], session, data.config); break; case 'part': part(audio[id], session); break; case 'relayICECandidate': { if (!(id in audio)) { console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); return; } const { peer_id, candidate } = data.config; if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate); message = JSON.stringify({ type: 'iceCandidate', data: {'peer_id': getName(session), 'candidate': candidate } }); if (peer_id in audio[id]) { audio[id][peer_id].ws.send(message); } } break; case 'relaySessionDescription': { if (!(id in audio)) { console.error(`${id} - relaySessionDescription - Does not have Audio`); return; } const { peer_id, session_description } = data.config; if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description); message = JSON.stringify({ type: 'sessionDescription', data: {'peer_id': getName(session), 'session_description': session_description } }); if (peer_id in audio[id]) { audio[id][peer_id].ws.send(message); } } break; case 'pong': resetDisconnectCheck(room, req); break; case 'chat-update': console.log(`${short}: <- chat-update ${getName(session)} - full chat update.`); sendChatToPerson(room, session); break; case 'person-name': console.log(`${short}: <- person-name:${getName(session)} - setPersonName - ${data.name}`) error = setPersonName(room, session, data.name); if (error) { sendError(session, error); }else { saveChat(room); } break; case 'set': console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`); switch (data.field) { case 'state': warning = setChatState(room, session, data.value); if (warning) { sendWarning(session, warning); } else { saveChat(room); } break; case 'color': warning = setPersonColor(room, session, data.value); if (warning) { sendWarning(session, warning); } else { saveChat(room); } break; default: console.warn(`WARNING: Requested SET unsupported field: ${data.field}`); break; } break; case 'get': console.log(`${short}: <- get:${getName(session)} ${data.fields.join(',')}`); update = {}; data.fields.forEach((field) => { switch (field) { case 'person': sendWarning(session, `'person' is not a valid item. use 'private' instead`); update.person = undefined; break; case 'id': case 'chat': case 'activities': update[field] = room[field]; break; case 'name': update.name = session.name; break; case 'unselected': update.unselected = getFilteredUnselected(room); break; case 'private': update.private = session.person; break; case 'persons': update.persons = getFilteredPersons(room); break; case 'color': console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`); update.color = session.color; break; case 'timestamp': update.timestamp = Date.now(); break; default: if (field in room) { console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`); update[field] = room[field]; } else { if (field in session) { console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`); update[field] = session[field]; } else { console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`); } } break; } }); sendUpdateToPerson(room, session, update); break; case 'chat': console.log(`${short}:${id} - ${data.type} - ${data.message}`) addChatMessage(room, session, `${session.name}: ${data.message}`, true); parseChatCommands(room, data.message); sendUpdateToPersons(room, { chat: room.chat }); saveChat(room); break; case 'media-status': console.log(`${short}: <- media-status - `, data.audio, data.video); session.video = data.video; session.audio = data.audio; break; default: processed = false; break; } if (processed) { /* saveChat(room); -- do not save here; only save on changes */ return; } /* The rest of the actions and commands require an active chat * participant */ if (!session.person) { error = `Person must have an active color.`; sendError(session, error); return; } processed = true; const priorSession = session; switch (data.type) { case 'clear-chat': console.log(`${short}: <- clear-chat:${getName(session)}`); warning = clearChat(room, session); if (warning) { sendWarning(session, warning); } break; case 'goto-lobby': console.log(`${short}: <- goto-lobby:${getName(session)}`); warning = gotoLobby(room, session); if (warning) { sendWarning(session, warning); } break; default: console.warn(`Unsupported request: ${data.type}`); processed = false; break; } /* If action was taken, persist the chat */ if (processed) { saveChat(room); } /* If the current person took an action, reset the session timer */ if (processed && session.color === room.turn.color && room.state !== 'winner') { resetTurnTimer(room, session); } }); /* This will result in the node tick moving forward; if we haven't already * setup the event handlers, a 'message' could come through prior to this * completing */ const room = await loadChat(roomId); if (!room) { console.error(`Unable to load/create new chat for WS request.`); return; } const session = getSession(room, req.cookies.person); session.ws = ws; if (session.person) { session.person.live = true; } session.live = true; session.lastActive = Date.now(); if (session.name) { sendUpdateToPersons(room, { persons: getFilteredPersons(room), unselected: getFilteredUnselected(room) }); } /* If the current turn person just rejoined, set their turn timer */ if (room.turn && room.turn.color === session.color && room.state !== 'winner') { resetTurnTimer(room, session); } if (session.name) { if (session.color) { addChatMessage(room, null, `${session.name} has reconnected to the room.`); } else { addChatMessage(room, null, `${session.name} has rejoined the lobby.`); } sendUpdateToPersons(room, { chat: room.chat }); } resetDisconnectCheck(room, req); console.log(`${short}: Chat ${id} - WebSocket connect from ${getName(session)}`); /* Send initial ping to initiate communication with client */ if (!session.keepAlive) { console.log(`${short}: Sending initial ping`); ping(session); } else { clearTimeout(session.keepAlive); session.keepAlive = setTimeout(() => { ping(session); }, 2500); } }); const getFilteredChatForPerson = (room, session) => { /* Shallow copy chat, filling its sessions with a shallow copy of * sessions so we can then delete the person field from them */ const reducedChat = Object.assign({}, chat, { sessions: {} }), reducedSessions = []; for (let id in room.sessions) { const reduced = Object.assign({}, room.sessions[id]); if (reduced.person) { delete reduced.person; } if (reduced.ws) { delete reduced.ws; } if (reduced.keepAlive) { delete reduced.keepAlive; } reducedroom.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } const person = session.person ? session.person : undefined; /* Strip out data that should not be shared with persons */ delete reducedroom.developmentCards; /* Delete the chat timer */ delete reducedroom.turnTimer; reducedroom.unselected = getFilteredUnselected(room); return Object.assign(reducedChat, { live: true, timestamp: Date.now(), status: session.error ? session.error : "success", name: session.name, color: session.color, order: (session.color in room.persons) ? room.persons[session.color].order : 0, private: person, sessions: reducedSessions, layout: layout, persons: getFilteredPersons(room), }); } const resetChat = (room) => { Object.assign(room, { startTime: Date.now(), state: 'lobby', chat: [], activities: [], persons: room.persons, active: 0 }); stopTurnTimer(room); /* Populate the chat corner and road placement data as cleared */ for (let i = 0; i < layout.corners.length; i++) { room.placements.corners[i] = { color: undefined, type: undefined }; } /* Reset all person data, and add in any missing colors */ [ 'R', 'B', 'W', 'O' ].forEach(color => { if (color in room.persons) { clearPerson(room.persons[color]); } else { room.persons[color] = newPerson(color); } }); /* Ensure sessions are connected to person objects */ for (let key in room.sessions) { const session = room.sessions[key]; if (session.color) { room.active++; session.person = room.persons[session.color]; session.person.status = 'Active'; session.person.lastActive = Date.now(); session.person.live = session.live; session.person.name = session.name; session.person.color = session.color; } } } const createChat = (id) => { /* Look for a new chat with random words that does not already exist */ while (!id) { id = randomWords(4).join('-'); try { /* If file can be read, it already exists so look for a new name */ accessSync(`chats/${id}`, fs.F_OK); id = ''; } catch (error) { break; } } console.log(`${info}: creating ${id}`); const room = { id: id, persons: { O: newPerson('O'), R: newPerson('R'), B: newPerson('B'), W: newPerson('W') }, sessions: {}, unselected: [], chat: [], active: 0, }; addChatMessage(room, null, `New chat created: ${room.id}`); chats[room.id] = room; audio[room.id] = {}; return room; }; /* Simple NO-OP to set session cookie so person-id can use it as the * index */ router.get("/", (req, res/*, next*/) => { let personId; if (!req.cookies.person) { personId = crypto.randomBytes(16).toString('hex'); res.cookie('person', personId); } else { personId = req.cookies.person; } console.log(`[${personId.substring(0, 8)}]: Browser hand-shake achieved.`); return res.status(200).send({ person: personId }); }); router.post("/:id?", async (req, res/*, next*/) => { const { id } = req.params; let personId; if (!req.cookies.person) { personId = crypto.randomBytes(16).toString('hex'); res.cookie('person', personId); } else { personId = req.cookies.person; } if (id) { console.log(`[${personId.substring(0,8)}]: Attempting load of ${id}`); } else { console.log(`[${personId.substring(0,8)}]: Creating new room.`); } const room = await loadChat(id); /* will create chat if it doesn't exist */ console.log(`[${personId.substring(0,8)}]: ${room.id} loaded.`); return res.status(200).send({ id: room.id }); }); module.exports = router;