const uuid = require('uuid'); const UrlParser = require('url'); const DEBUG = (process.env.NODE_ENV !== "production"); const STATE = { COUNTDOWN: 'COUNTDOWN', PLAY: 'PLAY', WIN: 'WIN' }; const COLORS = [ '#FF6200', // Orange '#06E746', // Green '#E370FF', // Purple '#06D6C4', // Turq '#ff217c' // Pink ]; const ICONS = [ 'assets/comet.svg', 'assets/moon.svg', 'assets/planet.svg', 'assets/rocket.svg', 'assets/spacesuit.svg', 'assets/spider.svg', 'assets/ufo.svg' ]; const Ricochet = function({ messenger }) { this.messenger = messenger; this.squaresPerSide = 20; // Reference lookups this.robotIds = Array.from(Array(5).keys()).map(_ => uuid.v4()); const shuffledColors = this.shuffle(COLORS); this.colors = this.robotIds.reduce((acc, id, i) => { acc[id] = shuffledColors[i]; return acc; }, {}); const shuffledIcons = this.shuffle(ICONS); this.icons = this.robotIds.reduce((acc, id, i) => { acc[id] = shuffledIcons[i]; return acc; }, {}); // Properties that will be emitted this.players = {}; this.robots = this.freshRobots(); this.state = STATE.PLAY; this.walls = this.freshWalls(); this.objective = this.freshObjective(); this.countdownDuration = 15; this.countdownTimer = null; this.countdownTimestamp = null; this.winningPlayerId = null; this.winningStack = null; }; //===== Connection management Ricochet.prototype.onConnect = function(ws, req) { if (Object.keys(this.players).length >= 10) { this.messenger.messageOne(ws, { type: 'full' }); return; } const url = UrlParser.parse(req.url, true); const query = url.query; const santizedName = (query.name || 'Unknown').replace(/[^\w ]/g, ''); DEBUG && console.log(`Connected: ${santizedName} (${ws.id.substr(0, 8)}) via ${req.url}`); this.addPlayer(ws.id, santizedName); this.messenger.messageAll({ type: 'players', body: this.players }); this.messenger.messageOne(ws, { type: 'connected', body: { player_id: ws.id, player_name: santizedName } }); this.messenger.messageOne(ws, { type: 'robots', body: this.robots}); this.messenger.messageOne(ws, { type: 'objective', body: this.objective}); this.messenger.messageOne(ws, { type: 'state', body: this.state}); this.messenger.messageOne(ws, { type: 'walls', body: this.walls}); if (this.state === STATE.COUNTDOWN) { this.messenger.messageOne(ws, { type: 'countdown', body: this.getCountdownStateBody() }); } if (this.state === STATE.WIN) { this.messenger.messageOne(ws, { type: 'win', body: this.getWinStateBody() }); } }; Ricochet.prototype.onDisconnect = function(ws) { DEBUG && console.log(`Disconnected: ${this.players[ws.id]} (${ws.id.substr(0, 8)})`); this.removePlayer(ws.id); this.messenger.messageAll({ type: 'players', body: this.players }); }; Ricochet.prototype.addPlayer = function(id, name) { if (!this.players[id]) { this.players[id] = name; } }; Ricochet.prototype.removePlayer = function(id) { this.players[id] = undefined; delete this.players[id]; }; //===== State generators Ricochet.prototype.freshRobots = function() { const result = []; for (let k = 0; k < this.robotIds.length; k++) { const id = this.robotIds[k]; const { i, j } = this.randomUnoccupiedSquare(); result.push({ i, j, color: this.colors[id], id, icon: this.icons[id] }); } return result; }; Ricochet.prototype.freshWalls = function() { // Corner count relates quadratically to squares per side. const numberOfCorners = Math.ceil(Math.pow((this.squaresPerSide / 4), 2)); const corners = []; let shortCircuitCounter = 0; while (corners.length < numberOfCorners && shortCircuitCounter < numberOfCorners * 5) { const { i, j } = this.randomSquare(); const isTooClose = corners.reduce((acc, v) => { const delta = Math.abs(v.i - i) + Math.abs(v.j - j); return acc || (delta < 3); }, false); if (isTooClose === false) { corners.push({ i, j }); } } // Edge IDs are of the form [i1-j1-i2-j2]. Top left is 0, 0. const horizontalEdges = corners.map(({ i, j }) => { const isLeftward = i > 0 && i < this.squaresPerSide && (Math.random() < 0.5); const i1 = isLeftward ? i - 1 : i; const i2 = isLeftward ? i : i + 1; return { i1, j1: j, i2, j2: j}; }); const verticalEdges = corners.map(({ i, j }) => { const isUpward = j > 0 && j < this.squaresPerSide && (Math.random() < 0.5); const j1 = isUpward ? j - 1 : j; const j2 = isUpward ? j : j + 1; return { i1: i, j1, i2: i, j2 }; }); const edges = horizontalEdges.concat(verticalEdges); return edges.reduce((acc, { i1, j1, i2, j2 }) => { // Remove walls along all edges const onLeft = (i1 === 0 && i2 === 0); const onRight = (i1 === this.squaresPerSide && i2 === this.squaresPerSide); const onTop = (j1 === 0 && j2 === 0); const onBottom = (j1 === this.squaresPerSide && j2 === this.squaresPerSide); if (onLeft || onRight || onTop || onBottom) { return acc; } // Remove some walls randomly if (Math.random() < 0.3) { return acc; } return acc.concat(`${i1}-${j1}-${i2}-${j2}`); }, []); }; Ricochet.prototype.freshObjective = function() { const getRandomWall = () => { if (!this.walls.length) { this.randomUnoccupiedSquare(); } const wall = this.walls[Math.floor((Math.random() * this.walls.length))]; const [i1, j1, i2, j2] = wall.split('-'); return (Math.random() < 0.5) ? { i: i1, j: j1 } : { i: i2, j: j2 }; }; const rand = Math.floor(Math.random() * this.robotIds.length); const id = this.robotIds[rand]; const { i, j } = getRandomWall(); return { i, j, id, color: this.colors[id], }; }; Ricochet.prototype.onMessage = function(ws, rawBody) { try { const message = JSON.parse(rawBody); DEBUG && console.log('Received message: '); DEBUG && console.log(message); if (!message.type) { DEBUG && console.warn("Unprocessable message: ") DEBUG && console.warn(message); return; } switch (message.type) { case 'newround': this.msgNewRound(); break; case 'objective': this.msgObjective(); break; case 'robots': this.msgRobots(); break; case 'skip': this.msgSkip(); break; case 'solve': this.msgSolve(message.rawBody); break; case 'walls': this.msgWalls(); break; default: console.warn("Unknown message type: ", message.type) } } catch (error) { console.error(error); } }; //===== Message handlers Ricochet.prototype.msgNewRound = function() { this.objective = this.freshObjective(); this.state = STATE.PLAY; const lastPositions = this.winningStack.reduce((acc, v) => { acc[v.id] = v; return acc; }, {}) this.robots = Object.values(lastPositions).map(({ i, j, id }) => ({ i, j, id, color: this.colors[id], icon: this.icons[id] })); this.countdownTimer = null; this.countdownTimestamp = null; this.winningPlayerId = null; this.winningStack = null; this.messenger.messageAll({ type: 'newround' }); this.messenger.messageAll({ type: 'objective', body: this.objective}); this.messenger.messageAll({ type: 'state', body: this.state}); this.messenger.messageAll({ type: 'robots', body: this.robots}); }; Ricochet.prototype.msgObjective = function() { this.objective = this.freshObjective(); this.messenger.messageAll({ type: 'objective', body: this.objective }); }; Ricochet.prototype.msgRobots = function() { this.robots = this.freshRobots(); this.messenger.messageAll({ type: 'robots', body: this.robots}); }; Ricochet.prototype.msgWalls = function() { this.walls = this.freshWalls(); this.messenger.messageAll({ type: 'walls', body: this.walls }); }; Ricochet.prototype.msgSkip = function() { this.onCountdownComplete(); }; Ricochet.prototype.msgSolve = function(message) { clearTimeout(this.countdownTimer); this.countdownTimer = setTimeout(this.onCountdownComplete.bind(this), this.countdownDuration * 1000); this.countdownTimestamp = new Date().getTime(); this.state = STATE.COUNTDOWN; this.winningStack = this.sanitizeStack(message.stack); this.winningPlayerId = this.sanitizeId(message.id); this.messenger.messageAll({ type: 'state', body: this.state }); this.messenger.messageAll({ type: 'countdown', body: this.getCountdownStateBody() }); }; //===== Helper functions Ricochet.prototype.randomSquare = function() { return { i: Math.floor(Math.random() * this.squaresPerSide), j: Math.floor(Math.random() * this.squaresPerSide) }; }; Ricochet.prototype.randomUnoccupiedSquare = function() { let result = this.randomSquare(); let CONTROL_COUNTER = 0; while (this.isSquareOccupied(result) && CONTROL_COUNTER < 20) { CONTROL_COUNTER++; result = this.randomSquare(); } if (CONTROL_COUNTER > 19) { console.log(`==================\n\nCRITICAL ERROR!\n\nrandomUnoccupiedSquare() while() short-circuited!\n\n==================`); } return result; }; Ricochet.prototype.isSquareOccupied = function({ i, j }) { if (i < 0 || i > this.squaresPerSide) { return true; } if (j < 0 || j > this.squaresPerSide) { return true; } if (this.robots) { for (let k = 0; k < this.robots.length; k++) { if (this.robots[k].i === i && this.robots[k].j === j) { return true; } } } if (this.objective && i === this.objective.i && j === this.objective.j) { return true; } return false; }; Ricochet.prototype.sanitizeStack = function(rawMoveStack) { const sanitizedStack = rawMoveStack.map(move => { const sanitizedRobotId = this.sanitizeId(move.id); const sanitizedI = move.i * 1; const sanitizedJ = move.j * 1; return { i: sanitizedI, j: sanitizedJ, id: sanitizedRobotId }; }); return sanitizedStack; }; Ricochet.prototype.sanitizeId = function(id) { return id.replace(/[^0-9a-zA-Z\-]/g, ''); }; Ricochet.prototype.shuffle = function(arr) { const result = []; let CONTROL_COUNTER = 0; while (arr.length && CONTROL_COUNTER < 50) { const x = Math.floor(Math.random() * arr.length); result.push(arr[x]); arr.splice(x, 1); } if (CONTROL_COUNTER > 39) { console.log(`==================\n\nCRITICAL ERROR!\n\shuffle()() while() short-circuited!\n\n==================`); } return result; }; Ricochet.prototype.onCountdownComplete = function() { clearTimeout(this.countdownTimer); this.countdownTimestamp = null; this.state = STATE.WIN; this.messenger.messageAll({ type: 'state', body: this.state }); this.messenger.messageAll({ type: 'win', body: this.getWinStateBody() }); }; Ricochet.prototype.getCountdownStateBody = function() { return { duration: this.countdownDuration, id: this.winningPlayerId, moveCount: this.winningStack.length - this.robots.length, timestamp: this.countdownTimestamp }; }; Ricochet.prototype.getWinStateBody = function() { return { moveCount: this.winningStack.length - this.robots.length, player_id: this.winningPlayerId, stack: this.winningStack }; }; module.exports = Ricochet;