From 80243deb2810b4c6ce9bbe6f82b5e9ba42516645 Mon Sep 17 00:00:00 2001 From: Kung Fu Ant Date: Mon, 11 Jan 2016 20:16:39 +0100 Subject: [PATCH 1/5] Update forcepoint in O(1) --- server/game.js | 66 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/server/game.js b/server/game.js index 02c06c8..273ca97 100644 --- a/server/game.js +++ b/server/game.js @@ -23,6 +23,8 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.crashPoint; // when the game crashes, 0 means instant crash self.gameDuration; // how long till the game will crash.. + self.openBet = 0; // how much satoshis is still in action + self.totalWon = 0; // how much satoshis players won (profit) self.forcePoint = null; // The point we force terminate the game self.state = 'ENDED'; // 'STARTING' | 'BLOCKING' | 'IN_PROGRESS' | 'ENDED' @@ -52,6 +54,8 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.state = 'STARTING'; self.crashPoint = info.crashPoint; + self.openBet = 0; + self.totalWon = 0; if (config.CRASH_AT) { assert(!config.PRODUCTION); @@ -320,6 +324,7 @@ Game.prototype.placeBet = function(user, betAmount, autoCashOut, callback) { assert(playId > 0); self.bankroll += betAmount; + self.openBet += betAmount; var index = self.joined.insert({ user: user, bet: betAmount, autoCashOut: autoCashOut, playId: playId, status: 'PLAYING' }); @@ -342,14 +347,16 @@ Game.prototype.doCashOut = function(play, at, callback) { assert(typeof callback === 'function'); var self = this; - var username = play.user.username; - assert(self.players[username].status === 'PLAYING'); - self.players[username].status = 'CASHED_OUT'; - self.players[username].stoppedAt = at; + assert(play === self.players[username]); + assert(play.status === 'PLAYING'); + play.status = 'CASHED_OUT'; + play.stoppedAt = at; - var won = (self.players[username].bet / 100) * at; + var cashed = play.bet * at / 100; + var won = play.bet * (at - 100) / 100; // as in profit + assert(lib.isInt(cashed)); assert(lib.isInt(won)); self.emit('cashed_out', { @@ -357,7 +364,10 @@ Game.prototype.doCashOut = function(play, at, callback) { stopped_at: at }); - db.cashOut(play.user.id, play.playId, won, function(err) { + self.openBet -= play.bet; + self.totalWon += won; + + db.cashOut(play.user.id, play.playId, cashed, function(err) { if (err) { console.log('[INTERNAL_ERROR] could not cash out: ', username, ' at ', at, ' in ', play, ' because: ', err); return callback(err); @@ -399,33 +409,37 @@ Game.prototype.runCashOuts = function(at) { Game.prototype.setForcePoint = function() { var self = this; - var totalBet = 0; // how much satoshis is still in action - var totalCashedOut = 0; // how much satoshis has been lost - - Object.keys(self.players).forEach(function(playerName) { - var play = self.players[playerName]; - - if (play.status === 'CASHED_OUT') { - var amount = play.bet * (play.stoppedAt - 100) / 100; - totalCashedOut += amount; - } else { - assert(play.status == 'PLAYING'); - assert(lib.isInt(play.bet)); - totalBet += play.bet; - } - }); + if (!config.production) { + var openBet = 0; // how much satoshis is still in action + var totalWon = 0; // how much satoshis has been lost + + Object.keys(self.players).forEach(function(playerName) { + var play = self.players[playerName]; + + if (play.status === 'CASHED_OUT') { + var amount = play.bet * (play.stoppedAt - 100) / 100; + totalWon += amount; + } else { + assert(play.status == 'PLAYING'); + assert(lib.isInt(play.bet)); + openBet += play.bet; + } + }); + + assert(self.openBet === openBet); + assert(self.totalWon === totalWon); + } - if (totalBet === 0) { + if (self.openBet === 0) { self.forcePoint = Infinity; // the game can go until it crashes, there's no end. } else { - var left = self.maxWin - totalCashedOut - (totalBet * 0.01); - - var ratio = (left+totalBet) / totalBet; + // TODO: Subtract the bonus of all bets should instead of just the open bets. + var left = self.maxWin - self.totalWon - (self.openBet * 0.01); + var ratio = (left+self.openBet) / self.openBet; // in percent self.forcePoint = Math.max(Math.floor(ratio * 100), 101); } - }; Game.prototype.cashOut = function(user, callback) { From 9e268d25c86a845d9aa6519b7e1eecdc2abda47d Mon Sep 17 00:00:00 2001 From: Kung Fu Ant Date: Mon, 11 Jan 2016 20:33:21 +0100 Subject: [PATCH 2/5] Run auto cashouts in O(1) --- server/game.js | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/server/game.js b/server/game.js index 273ca97..23ce455 100644 --- a/server/game.js +++ b/server/game.js @@ -31,8 +31,15 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.pending = {}; // Set of players pending a joined self.pendingCount = 0; self.joined = new SortedArray(); // A list of joins, before the game is in progress - self.players = {}; // An object of userName -> { playId: ..., autoCashOut: .... } + + // An array that approximates playing users, i.e. the ones that have not yet + // cashed out, for O(1) auto cashouts. Plays are inserted by increasing + // autoCashOut order during game start and only shifted durig the game ticks + // up to the current multiplier. This means, at any point it contains only + // the players that have a higher cashout than the currenty multiplier and + // all other have been cashed out. + self.playing = []; self.gameId = lastGameId; self.gameHistory = gameHistory; @@ -66,6 +73,7 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.gameId++; self.startTime = new Date(Date.now() + restartTime); self.players = {}; // An object of userName -> { user: ..., playId: ..., autoCashOut: ...., status: ... } + self.playing = []; self.gameDuration = Math.ceil(inverseGrowth(self.crashPoint + 1)); // how long till the game will crash.. self.maxWin = Math.round(self.bankroll * 0.03); // Risk 3% per game @@ -99,7 +107,7 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.pendingCount = 0; var bets = {}; - var arr = self.joined.getArray(); + var arr = self.playing = self.joined.getArray(); for (var i = 0; i < arr.length; ++i) { var a = arr[i]; bets[a.user.username] = a.bet; @@ -107,6 +115,9 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { } self.joined.clear(); + self.playing.sort(function(a,b) { + return a.autoCashOut - b.autoCashOut; + }); self.emit('game_started', bets); @@ -379,26 +390,25 @@ Game.prototype.doCashOut = function(play, at, callback) { Game.prototype.runCashOuts = function(at) { var self = this; + var update = false; // Check for auto cashouts - var update = false; - // Check for auto cashouts - - Object.keys(self.players).forEach(function (playerUserName) { - var play = self.players[playerUserName]; - + dropWhile(self.playing, function(play) { + // Strip cashed players from the array if (play.status === 'CASHED_OUT') - return; + return true; assert(play.status === 'PLAYING'); assert(play.autoCashOut); if (play.autoCashOut <= at && play.autoCashOut <= self.crashPoint && play.autoCashOut <= self.forcePoint) { - self.doCashOut(play, play.autoCashOut, function (err) { if (err) - console.log('[INTERNAL_ERROR] could not auto cashout ', playerUserName, ' at ', play.autoCashOut); + console.log('[INTERNAL_ERROR] could not auto cashout ', play.username, ' at ', play.autoCashOut); }); update = true; + return true; // Drop from self.playing + } else { + return false; // Don't drop this one and stop dropping here } }); @@ -615,4 +625,8 @@ function inverseGrowth(result) { return c * Math.log(0.01 * result); } +function dropWhile(arr, pred) { + for (var l = arr.length; l > 0 && pred(arr[0]); --l, arr.shift()) ; +} + module.exports = Game; From 3a0f4e08faac240433b2a317f3163213d4cfd30c Mon Sep 17 00:00:00 2001 From: Kung Fu Ant Date: Tue, 12 Jan 2016 19:25:02 +0100 Subject: [PATCH 3/5] Push cashouts into ticks and crashes This patch drops the cashed_out event and instead batches cashouts together and informs the client about cashouts using 'tick' (new version of 'game_tick') and 'game_crash' events. --- server/game.js | 162 +++++++++++++++++++++++++---------------------- server/socket.js | 9 ++- 2 files changed, 92 insertions(+), 79 deletions(-) diff --git a/server/game.js b/server/game.js index 23ce455..27bf82d 100644 --- a/server/game.js +++ b/server/game.js @@ -46,6 +46,15 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.lastHash = lastHash; self.hash = null; + // Timer to schedule the next regular tick + self.tickTimer = null; + + // An object of username -> stoppedAt + self.cacheCashouts = {}; + // An array of IO tasks to perform for the cached cashouts, e.g. writing the + // cashouts to the database and calling the client callback. + self.cacheCashoutTasks = []; + events.EventEmitter.call(self); function runGame() { @@ -123,40 +132,48 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { self.setForcePoint(); - callTick(0); + scheduleNextTick(0); } - function callTick(elapsed) { + function scheduleNextTick(elapsed) { var left = self.gameDuration - elapsed; var nextTick = Math.max(0, Math.min(left, tickRate)); - setTimeout(runTick, nextTick); + self.tickTimer = setTimeout(self.runTick.bind(self), nextTick); } - - function runTick() { - + /** + * A tick informs clients about the elapsed time since game start and cashouts + * that happened since the last tick. This function gets run either by a timer + * after a tickRate timeout or by some user cashing out. + */ + self.runTick = function() { var elapsed = new Date() - self.startTime; var at = growthFunc(elapsed); self.runCashOuts(at); if (self.forcePoint <= at && self.forcePoint <= self.crashPoint) { - self.cashOutAll(self.forcePoint, function (err) { - console.log('Just forced cashed out everyone at: ', self.forcePoint, ' got err: ', err); + // Max profit got hit so we forcefully end the game. + self.cashOutAll(self.forcePoint); + endGame(true); + } else if (at > self.crashPoint) { + // oh noes, we crashed! + endGame(false); + } else { + // The game must go on. - endGame(true); - }); - return; - } + // Throw the cashouts at the client .. + self.emit('tick', elapsed, self.cacheCashouts); + self.cacheCashouts = {}; - // and run the next + // .. and at the DB. + async.parallel(self.cacheCashoutTasks, function() {}); + self.cacheCashoutTasks = []; - if (at > self.crashPoint) - endGame(false); // oh noes, we crashed! - else - tick(elapsed); - } + scheduleNextTick(elapsed); + }; + }; function endGame(forced) { var gameId = self.gameId; @@ -170,9 +187,7 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { bonuses = calcBonuses(self.players); var givenOut = 0; - Object.keys(self.players).forEach(function(player) { - var record = self.players[player]; - + _.forEach(self.players, function(record) { givenOut += record.bet * 0.01; if (record.status === 'CASHED_OUT') { var given = record.stoppedAt * (record.bet / 100); @@ -196,11 +211,13 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { // oh noes, we crashed! self.emit('game_crash', { forced: forced, + cashouts: self.cacheCashouts, elapsed: self.gameDuration, game_crash: self.crashPoint, // We send 0 to client in instant crash bonuses: bonusJson, hash: self.lastHash }); + self.cacheCashouts = {}; self.gameHistory.addCompletedGame({ game_id: gameId, @@ -220,12 +237,23 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { }, 1000); } - db.endGame(gameId, bonuses, function(err) { - if (err) - console.log('ERROR could not end game id: ', gameId, ' got err: ', err); - clearTimeout(dbTimer); - scheduleNextGame(crashTime); + // Write all the remaining cashouts to the database first. + async.parallelLimit(self.cacheCashoutTasks, 40, function(err, results) { + if (err) { + console.log('ERROR performing cashouts at end of game', id,' got err:', err); + clearTimeout(dbTimer); + scheduleNextGame(crashTime); + } else { + // After cashouts have been written, end the game (write bonus ...) + db.endGame(gameId, bonuses, function(err) { + if (err) + console.log('ERROR could not end game id: ', gameId, ' got err: ', err); + clearTimeout(dbTimer); + scheduleNextGame(crashTime); + }); + } }); + self.cacheCashoutTasks = []; self.state = 'ENDED'; } @@ -254,11 +282,6 @@ function Game(lastGameId, lastHash, bankroll, gameHistory) { console.warn('Game is going to pause'); self.controllerIsRunning = false; }; - - function tick(elapsed) { - self.emit('game_tick', elapsed); - callTick(elapsed); - } } util.inherits(Game, events.EventEmitter); @@ -370,21 +393,24 @@ Game.prototype.doCashOut = function(play, at, callback) { assert(lib.isInt(cashed)); assert(lib.isInt(won)); - self.emit('cashed_out', { - username: username, - stopped_at: at - }); - self.openBet -= play.bet; self.totalWon += won; - db.cashOut(play.user.id, play.playId, cashed, function(err) { - if (err) { - console.log('[INTERNAL_ERROR] could not cash out: ', username, ' at ', at, ' in ', play, ' because: ', err); - return callback(err); - } - - callback(null); + self.cacheCashouts[username] = at; + self.cacheCashoutTasks.push(function(cb) { + db.cashOut(play.user.id, play.playId, cashed, function(err) { + if (err) { + console.log('[INTERNAL_ERROR] could not cash out: ', + username, ' at ', at, ' in ', play, ' because: ', err); + // TODO: In case of a manual cashout, this passes the error message + // from the DB through to the client. Is this intended / ok? + callback(err); + cb(); // Async callback + } else { + callback(null); // Client callback + cb(); // Async callback + } + }); }); }; @@ -470,6 +496,9 @@ Game.prototype.cashOut = function(user, callback) { if (play.autoCashOut <= at) at = play.autoCashOut; + // This is not entirely correct. Auto cashouts should be run first which + // potentially change the forcepoint and then this check should occur. If + // this condition is true it should also cashOutAll other players. if (self.forcePoint <= at) at = self.forcePoint; @@ -480,52 +509,33 @@ Game.prototype.cashOut = function(user, callback) { if (play.status === 'CASHED_OUT') return callback('ALREADY_CASHED_OUT'); + // At this point we accepted the cashout and will report it as a tick to the + // clients. Therefore abort the scheduled tick. Take extra care about this + // before adding any IO to this function as it might allow ticks in between + // or delay ticks until IO finishes.. + clearTimeout(self.tickTimer); + self.doCashOut(play, at, callback); self.setForcePoint(); + self.runTick(); }; -Game.prototype.cashOutAll = function(at, callback) { +Game.prototype.cashOutAll = function(at) { var self = this; - if (this.state !== 'IN_PROGRESS') - return callback(); - console.log('Cashing everyone out at: ', at); + assert(this.state === 'IN_PROGRESS'); assert(at >= 100); + assert(at <= self.crashPoint); self.runCashOuts(at); - if (at > self.crashPoint) - return callback(); // game already crashed, sorry guys - - var tasks = []; - - Object.keys(self.players).forEach(function(playerName) { - var play = self.players[playerName]; - - if (play.status === 'PLAYING') { - tasks.push(function (callback) { - if (play.status === 'PLAYING') - self.doCashOut(play, at, callback); - else - callback(); - }); - } - }); - - console.log('Needing to force cash out: ', tasks.length, ' players'); - - async.parallelLimit(tasks, 4, function (err) { - if (err) { - console.error('[INTERNAL_ERROR] unable to cash out all players in ', self.gameId, ' at ', at); - callback(err); - return; - } - console.log('Emergency cashed out all players in gameId: ', self.gameId); - - callback(); + _.forEach(self.playing, function(play) { + if (play.status === 'PLAYING') + self.doCashOut(play, at, function() {}); }); + self.playing = []; }; /// returns [ {playId: ?, user: ?, amount: ? }, ...] diff --git a/server/socket.js b/server/socket.js index 5dc119e..43d576e 100644 --- a/server/socket.js +++ b/server/socket.js @@ -9,14 +9,17 @@ module.exports = function(server,game) { (function() { function on(event) { - game.on(event, function (data) { - io.to('joined').emit(event, data); + game.on(event, function () { + var room = io.to('joined'); + var args = Array.prototype.slice.call(arguments); + args.unshift(event); + room.emit.apply(room, args); }); } on('game_starting'); on('game_started'); - on('game_tick'); + on('tick'); on('game_crash'); on('cashed_out'); on('player_bet'); From 4bd4001b8fc03a9baa780654b55aec7e51c26177 Mon Sep 17 00:00:00 2001 From: Kung Fu Ant Date: Fri, 15 Jan 2016 12:56:45 +0100 Subject: [PATCH 4/5] Add new 'bets' event replacing 'player_bet' --- server/game.js | 12 +++++++----- server/socket.js | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/server/game.js b/server/game.js index 27bf82d..e743ec4 100644 --- a/server/game.js +++ b/server/game.js @@ -360,13 +360,15 @@ Game.prototype.placeBet = function(user, betAmount, autoCashOut, callback) { self.bankroll += betAmount; self.openBet += betAmount; - var index = self.joined.insert({ user: user, bet: betAmount, autoCashOut: autoCashOut, playId: playId, status: 'PLAYING' }); - - self.emit('player_bet', { - username: user.username, - index: index + var index = self.joined.insert({ + user: user, + bet: betAmount, + autoCashOut: autoCashOut, + playId: playId, + status: 'PLAYING' }); + self.emit('bets', [index, user.username]); callback(null); } }); diff --git a/server/socket.js b/server/socket.js index 43d576e..9f27364 100644 --- a/server/socket.js +++ b/server/socket.js @@ -22,7 +22,7 @@ module.exports = function(server,game) { on('tick'); on('game_crash'); on('cashed_out'); - on('player_bet'); + on('bets'); })(); io.on('connection', onConnection); From ec5c862c361eef1e776acac231f683a26af4a84f Mon Sep 17 00:00:00 2001 From: Kung Fu Ant Date: Fri, 15 Jan 2016 13:20:44 +0100 Subject: [PATCH 5/5] Introduce game api versioning --- server/config.js | 2 ++ server/socket.js | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/server/config.js b/server/config.js index 04741b1..ccca3a9 100644 --- a/server/config.js +++ b/server/config.js @@ -9,6 +9,8 @@ module.exports = { PRODUCTION: process.env.NODE_ENV === 'production', GAME_HISTORY_LENGTH: 50, + GAME_API_VERSION: 1, + //Do not set any of this on production CRASH_AT: process.env.CRASH_AT //Force the crash point diff --git a/server/socket.js b/server/socket.js index 9f27364..17b2b7b 100644 --- a/server/socket.js +++ b/server/socket.js @@ -3,6 +3,7 @@ var _ = require('lodash'); var socketio = require('socket.io'); var database = require('./database'); var lib = require('./lib'); +var config = require('./config'); module.exports = function(server,game) { var io = socketio(server); @@ -36,6 +37,17 @@ module.exports = function(server,game) { if (typeof info !== 'object') return sendError(socket, '[join] Invalid info'); + if (!lib.hasOwnProperty(info, 'api_version')) + return sendError(socket, '[join] No api version given'); + + if (typeof info.api_version !== 'number') + return sendError(socket, '[join] Invalid api version'); + + if (info.api_version < config.GAME_API_VERSION) + return sendError(socket, + '[join] Incompatible api version. Server version: ' + + config.GAME_API_VERSION); + var ott = info.ott; if (ott) { if (!lib.isUUIDv4(ott)) @@ -61,6 +73,7 @@ module.exports = function(server,game) { } var res = game.getInfo(); + res['api_version'] = config.GAME_API_VERSION; res['chat'] = []; // TODO: remove after getting rid of play-old // Strip all player info except for this user. res['table_history'] = game.gameHistory.getHistory().map(function(game) {