You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

408 lines
11 KiB

const uuid = require('node-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;
}, {});
this.icons = this.robotIds.reduce((acc, id, i) => {
acc[id] = ICONS[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: ws.id});
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() {
// Edge IDs are of the form [i1-j1-i2-j2]. Top left is 0, 0.
// Leave here for testing.
// return [
// "1-9-1-10",
// "9-1-10-1",
// "9-19-10-19",
// "19-9-19-10"
// ];
// console.log("Generating walls.");
// Squares per side has quadratic relationship with wall/corner requirements.
const numberOfCorners = Math.ceil(Math.pow((this.squaresPerSide / 10), 2));
const numberOfWalls = Math.ceil(Math.pow((this.squaresPerSide / 5), 2));
const edges = [];
// DO NUMBER OF CORNERS FIRST AFTER TESTING
for (let n = 0; n < numberOfWalls; n++) {
const { i: ri, j: rj } = this.randomSquare();
const isHorizontal = Math.random() < 0.5;
const isBackward = Math.random() < 0.5;
let i1, j1, i2, j2;
if (isHorizontal) {
i1 = isBackward ? ri - 1 : ri;
i2 = isBackward ? ri : ri + 1;
j1 = rj;
j2 = rj;
} else {
i1 = ri;
i2 = ri;
j1 = isBackward ? rj - 1 : rj;
j2 = isBackward ? rj : rj + 1;
}
const edge = `${i1}-${j1}-${i2}-${j2}`;
if (edges.includes(edge)) {
n--;
} else {
edges.push(edge);
}
}
return edges;
};
Ricochet.prototype.freshObjective = function() {
const rand = Math.floor(Math.random() * this.robotIds.length);
const id = this.robotIds[rand];
const { i, j } = this.randomUnoccupiedSquare();
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: '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;