1
0
James Ketrenos 269081b565 It is running,b ut not yet letting you chat
Signed-off-by: James Ketrenos <james@ketrenos.com>
2023-03-30 18:29:34 -07:00

1665 lines
44 KiB
JavaScript
Executable File

"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;