pokemon-showdown/rooms.js
Guangcong Luo 8f72a64a50 Expire battles for inactivity
Now battles will expire after 40 minutes of inactivity even if there
are people in the battle.

You can still keep the window open client-side after the expiration,
but you won't be able to talk in it anymore.
2013-09-30 08:26:25 -07:00

1568 lines
46 KiB
JavaScript

/**
* Rooms
* Pokemon Showdown - http://pokemonshowdown.com/
*
* Every chat room and battle is a room, and what they do is done in
* rooms.js. There's also a global room which every user is in, and
* handles miscellaneous things like welcoming the user.
*
* @license MIT license
*/
const TIMEOUT_EMPTY_DEALLOCATE = 10*60*1000;
const TIMEOUT_INACTIVE_DEALLOCATE = 40*60*1000;
const REPORT_USER_STATS_INTERVAL = 1000*60*10;
var modlog = modlog || fs.createWriteStream('logs/modlog.txt', {flags:'a+'});
var GlobalRoom = (function() {
function GlobalRoom(roomid) {
this.id = roomid;
this.i = {};
// init battle rooms
this.rooms = [];
this.battleCount = 0;
this.searchers = [];
// Never do any other file IO synchronously
// but this is okay to prevent race conditions as we start up PS
this.lastBattle = 0;
try {
this.lastBattle = parseInt(fs.readFileSync('logs/lastbattle.txt')) || 0;
} catch (e) {} // file doesn't exist [yet]
this.chatRoomData = [];
try {
this.chatRoomData = JSON.parse(fs.readFileSync('config/chatrooms.json'));
if (!Array.isArray(this.chatRoomData)) this.chatRoomData = [];
} catch (e) {} // file doesn't exist [yet]
if (!this.chatRoomData.length) {
this.chatRoomData = [{
title: 'Lobby',
autojoin: true
}, {
title: 'Staff',
isPrivate: true,
staffRoom: true,
staffAutojoin: true
}];
}
this.chatRooms = [];
this.autojoin = []; // rooms that users autojoin upon connecting
this.staffAutojoin = []; // rooms that staff autojoin upon connecting
for (var i=0; i<this.chatRoomData.length; i++) {
if (!this.chatRoomData[i] || !this.chatRoomData[i].title) {
console.log('ERROR: Room number ' + i + ' has no data.');
continue;
}
var id = toId(this.chatRoomData[i].title);
console.log("NEW CHATROOM: "+id);
var room = rooms[id] = new ChatRoom(id, this.chatRoomData[i].title, this.chatRoomData[i]);
this.chatRooms.push(room);
if (room.autojoin) this.autojoin.push(id);
if (room.staffAutojoin) this.staffAutojoin.push(id);
}
// this function is complex in order to avoid several race conditions
var self = this;
this.writeNumRooms = (function() {
var writing = false;
var lastBattle; // last lastBattle to be written to file
var finishWriting = function() {
writing = false;
if (lastBattle !== self.lastBattle) {
self.writeNumRooms();
}
};
return function() {
if (writing) return;
lastBattle = self.lastBattle;
writing = true;
fs.writeFile('logs/lastbattle.txt.0', '' + lastBattle, function() {
// rename is atomic on POSIX, but will throw an error on Windows
fs.rename('logs/lastbattle.txt.0', 'logs/lastbattle.txt', function(err) {
if (err) {
// This should only happen on Windows.
fs.writeFile('logs/lastbattle.txt', '' + lastBattle, finishWriting);
return;
}
finishWriting();
});
});
};
})();
this.writeChatRoomData = (function() {
var writing = false;
var writePending = false; // whether or not a new write is pending
var finishWriting = function() {
writing = false;
if (writePending) {
writePending = false;
self.writeChatRoomData();
}
};
return function() {
if (writing) {
writePending = true;
return;
}
writing = true;
var data = JSON.stringify(self.chatRoomData).replace(/\{"title"\:/g, '\n{"title":').replace(/\]$/,'\n]');
fs.writeFile('config/chatrooms.json.0', data, function() {
// rename is atomic on POSIX, but will throw an error on Windows
fs.rename('config/chatrooms.json.0', 'config/chatrooms.json', function(err) {
if (err) {
// This should only happen on Windows.
fs.writeFile('config/chatrooms.json', data, finishWriting);
return;
}
finishWriting();
});
});
};
})();
// init users
this.users = {};
this.userCount = 0; // cache of `Object.size(this.users)`
this.maxUsers = 0;
this.maxUsersDate = 0;
this.reportUserStatsInterval = setInterval(
this.reportUserStats.bind(this),
REPORT_USER_STATS_INTERVAL
);
if (!config.herokuhack) {
this.sweepClosedSocketsInterval = setInterval(
this.sweepClosedSockets.bind(this),
1000 * 60 * 10
);
}
}
GlobalRoom.prototype.type = 'global';
GlobalRoom.prototype.formatListText = '|formats';
GlobalRoom.prototype.reportUserStats = function() {
if (this.maxUsersDate) {
LoginServer.request('updateuserstats', {
date: this.maxUsersDate,
users: this.maxUsers
}, function() {});
this.maxUsersDate = 0;
}
LoginServer.request('updateuserstats', {
date: Date.now(),
users: Object.size(this.users)
}, function() {});
};
// Deal with phantom xhr-streaming connections.
GlobalRoom.prototype.sweepClosedSockets = function() {
for (var i in this.users) {
var user = this.users[i];
user.connections.forEach(function(connection) {
if (connection.socket &&
connection.socket._session &&
connection.socket._session.recv &&
(connection.socket._session.recv.protocol === 'xhr-streaming')) {
connection.socket._session.recv.didClose();
}
});
}
};
GlobalRoom.prototype.getFormatListText = function() {
var formatListText = '|formats';
var curSection = '';
for (var i in Tools.data.Formats) {
var format = Tools.data.Formats[i];
if (!format.challengeShow && !format.searchShow) continue;
var section = format.section;
if (section === undefined) section = format.mod;
if (!section) section = '';
if (section !== curSection) {
curSection = section;
formatListText += '|,' + (format.column || 1) + '|'+section;
}
formatListText += '|'+format.name;
if (!format.challengeShow) formatListText += ',,';
else if (!format.searchShow) formatListText += ',';
if (format.team) formatListText += ',#';
}
return formatListText;
};
GlobalRoom.prototype.getRoomList = function(filter) {
var roomList = {};
var total = 0;
for (var i=this.rooms.length-1; i>=0; i--) {
var room = this.rooms[i];
if (!room || !room.active) continue;
if (filter && filter !== room.format && filter !== true) continue;
var roomData = {};
if (room.active && room.battle) {
if (room.battle.players[0]) roomData.p1 = room.battle.players[0].getIdentity();
if (room.battle.players[1]) roomData.p2 = room.battle.players[1].getIdentity();
}
if (!roomData.p1 || !roomData.p2) continue;
roomList[room.id] = roomData;
total++;
if (total >= 6 && !filter) break;
}
return roomList;
};
GlobalRoom.prototype.getRooms = function() {
var rooms = {official:[], chat:[], userCount: this.userCount, battleCount: this.battleCount};
for (var i=0; i<this.chatRooms.length; i++) {
var room = this.chatRooms[i];
if (!room) continue;
if (room.isPrivate) continue;
(room.isOfficial ? rooms.official : rooms.chat).push({
title: room.title,
desc: room.desc,
userCount: Object.size(room.users)
});
}
return rooms;
};
GlobalRoom.prototype.cancelSearch = function(user) {
var success = false;
user.cancelChallengeTo();
for (var i=0; i<this.searchers.length; i++) {
var search = this.searchers[i];
var searchUser = Users.get(search.userid);
if (!searchUser.connected) {
this.searchers.splice(i,1);
i--;
continue;
}
if (searchUser === user) {
this.searchers.splice(i,1);
i--;
if (!success) {
searchUser.send('|updatesearch|'+JSON.stringify({searching: false}));
success = true;
}
continue;
}
}
return success;
};
GlobalRoom.prototype.searchBattle = function(user, formatid) {
if (!user.connected) return;
formatid = toId(formatid);
if (!user.prepBattle(formatid, 'search')) return;
// tell the user they've started searching
var newSearchData = {
format: formatid
};
user.send('|updatesearch|'+JSON.stringify({searching: newSearchData}));
// get the user's rating before actually starting to search
var newSearch = {
userid: user.userid,
formatid: formatid,
team: user.team,
rating: 1500,
time: new Date().getTime()
};
var self = this;
user.doWithMMR(formatid, function(mmr) {
newSearch.rating = mmr;
self.addSearch(newSearch, user);
});
};
GlobalRoom.prototype.matchmakingOK = function(search1, search2, user1, user2) {
// users must be different
if (user1 === user2) return false;
// users must not have been matched immediately previously
if (user1.lastMatch === user2.userid || user2.lastMatch === user1.userid) return false;
// search must be within range
var searchRange = 250, formatid = search1.formatid, elapsed = Math.abs(search1.time-search2.time);
if (formatid === 'ou' || formatid === 'oucurrent' || formatid === 'randombattle') searchRange = 150;
searchRange += elapsed/300; // +1 every .3 seconds
if (searchRange > 1200) searchRange = 1200;
if (Math.abs(search1.rating - search2.rating) > searchRange) return false;
user1.lastMatch = user2.userid;
user2.lastMatch = user1.userid;
return true;
};
GlobalRoom.prototype.addSearch = function(newSearch, user) {
if (!user.connected) return;
for (var i=0; i<this.searchers.length; i++) {
var search = this.searchers[i];
var searchUser = Users.get(search.userid);
if (!searchUser || !searchUser.connected) {
this.searchers.splice(i,1);
i--;
continue;
}
if (newSearch.formatid === search.formatid && searchUser === user) return; // only one search per format
if (newSearch.formatid === search.formatid && this.matchmakingOK(search, newSearch, searchUser, user)) {
this.cancelSearch(user, true);
this.cancelSearch(searchUser, true);
user.send('|updatesearch|'+JSON.stringify({searching: false}));
this.startBattle(searchUser, user, search.formatid, true, search.team, newSearch.team);
return;
}
}
this.searchers.push(newSearch);
};
GlobalRoom.prototype.send = function(message, user) {
if (user) {
user.sendTo(this, message);
} else {
for (var i in this.users) {
user = this.users[i];
user.sendTo(this, message);
}
}
};
GlobalRoom.prototype.sendAuth = function(message) {
for (var i in this.users) {
var user = this.users[i];
if (user.connected && user.can('receiveauthmessages')) {
user.sendTo(this, message);
}
}
};
GlobalRoom.prototype.updateRooms = function(excludeUser) {
// do nothing
};
GlobalRoom.prototype.add = function(message, noUpdate) {
if (rooms.lobby) rooms.lobby.add(message, noUpdate);
};
GlobalRoom.prototype.addRaw = function(message) {
if (rooms.lobby) rooms.lobby.addRaw(message);
};
GlobalRoom.prototype.addChatRoom = function(title) {
var id = toId(title);
if (rooms[id]) return false;
var chatRoomData = {
title: title
};
var room = rooms[id] = new ChatRoom(id, title, chatRoomData);
this.chatRoomData.push(chatRoomData);
this.chatRooms.push(room);
this.writeChatRoomData();
return true;
};
GlobalRoom.prototype.deregisterChatRoom = function(id) {
var id = toId(id);
var room = rooms[id];
if (!room) return false; // room doesn't exist
if (!room.chatRoomData) return false; // room isn't registered
// deregister from global chatRoomData
// looping from the end is a pretty trivial optimization, but the
// assumption is that more recently added rooms are more likely to
// be deleted
for (var i=this.chatRoomData.length-1; i>=0; i--) {
if (id === toId(this.chatRoomData[i].title)) {
this.chatRoomData.splice(i, 1);
this.writeChatRoomData();
break;
}
}
delete room.chatRoomData;
return true;
};
GlobalRoom.prototype.delistChatRoom = function(id) {
var id = toId(id);
if (!rooms[id]) return false; // room doesn't exist
for (var i=this.chatRooms.length-1; i>=0; i--) {
if (id === this.chatRooms[i].id) {
this.chatRooms.splice(i, 1);
break;
}
}
};
GlobalRoom.prototype.removeChatRoom = function(id) {
var id = toId(id);
var room = rooms[id];
if (!room) return false; // room doesn't exist
room.destroy();
return true;
};
GlobalRoom.prototype.autojoinRooms = function(user, connection) {
// we only autojoin regular rooms if the client requests it with /autojoin
// note that this restriction doesn't apply to staffAutojoin
for (var i=0; i<this.autojoin.length; i++) {
user.joinRoom(this.autojoin[i], connection);
}
};
GlobalRoom.prototype.checkAutojoin = function(user, connection) {
if (user.isStaff) {
for (var i=0; i<this.staffAutojoin.length; i++) {
user.joinRoom(this.staffAutojoin[i], connection);
}
}
};
GlobalRoom.prototype.onJoinConnection = function(user, connection) {
var initdata = '|updateuser|'+user.name+'|'+(user.named?'1':'0')+'|'+user.avatar+'\n';
connection.send(initdata+this.formatListText);
if (this.chatRooms.length > 2) connection.send('|queryresponse|rooms|null'); // should display room list
};
GlobalRoom.prototype.onJoin = function(user, connection, merging) {
if (!user) return false; // ???
if (this.users[user.userid]) return user;
this.users[user.userid] = user;
if (++this.userCount > this.maxUsers) {
this.maxUsers = this.userCount;
this.maxUsersDate = Date.now();
}
if (!merging) {
var initdata = '|updateuser|'+user.name+'|'+(user.named?'1':'0')+'|'+user.avatar+'\n';
connection.send(initdata+this.formatListText);
if (this.chatRooms.length > 2) connection.send('|queryresponse|rooms|null'); // should display room list
}
return user;
};
GlobalRoom.prototype.onRename = function(user, oldid, joining) {
delete this.users[oldid];
this.users[user.userid] = user;
return user;
};
GlobalRoom.prototype.onUpdateIdentity = function() {};
GlobalRoom.prototype.onLeave = function(user) {
if (!user) return; // ...
delete this.users[user.userid];
--this.userCount;
this.cancelSearch(user, true);
};
GlobalRoom.prototype.startBattle = function(p1, p2, format, rated, p1team, p2team) {
var newRoom;
p1 = Users.get(p1);
p2 = Users.get(p2);
if (!p1 || !p2) {
// most likely, a user was banned during the battle start procedure
this.cancelSearch(p1, true);
this.cancelSearch(p2, true);
return;
}
if (p1 === p2) {
this.cancelSearch(p1, true);
this.cancelSearch(p2, true);
p1.popup("You can't battle your own account. Please use something like Private Browsing to battle yourself.");
return;
}
if (this.lockdown) {
this.cancelSearch(p1, true);
this.cancelSearch(p2, true);
p1.popup("The server is shutting down. Battles cannot be started at this time.");
p2.popup("The server is shutting down. Battles cannot be started at this time.");
return;
}
//console.log('BATTLE START BETWEEN: '+p1.userid+' '+p2.userid);
var i = this.lastBattle+1;
var formaturlid = format.toLowerCase().replace(/[^a-z0-9]+/g,'');
while(rooms['battle-'+formaturlid+i]) {
i++;
}
this.lastBattle = i;
newRoom = this.addRoom('battle-'+formaturlid+'-'+i, format, p1, p2, this.id, rated);
p1.joinRoom(newRoom);
p2.joinRoom(newRoom);
newRoom.joinBattle(p1, p1team);
newRoom.joinBattle(p2, p2team);
this.cancelSearch(p1, true);
this.cancelSearch(p2, true);
if (config.reportbattles && rooms.lobby) {
rooms.lobby.add('|b|'+newRoom.id+'|'+p1.getIdentity()+'|'+p2.getIdentity());
}
};
GlobalRoom.prototype.addRoom = function(room, format, p1, p2, parent, rated) {
room = newRoom(room, format, p1, p2, parent, rated);
if (this.id in room.i) return;
room.i[this.id] = this.rooms.length;
this.rooms.push(room);
return room;
};
GlobalRoom.prototype.removeRoom = function(room) {
room = getRoom(room);
if (!room) return;
if (this.id in room.i) {
this.rooms.splice(room.i[this.id],1);
delete room.i[this.id];
for (var i=0; i<this.rooms.length; i++) {
this.rooms[i].i[this.id] = i;
}
}
};
GlobalRoom.prototype.chat = function(user, message, connection) {
if (rooms.lobby) return rooms.lobby.chat(user, message, connection);
message = CommandParser.parse(message, this, user, connection);
if (message) {
connection.sendPopup("You can't send messages directly to the server.");
}
};
return GlobalRoom;
})();
var BattleRoom = (function() {
function BattleRoom(roomid, format, p1, p2, parentid, rated) {
this.id = roomid;
this.title = ""+p1.name+" vs. "+p2.name;
this.i = {};
format = ''+(format||'');
this.users = {};
this.format = format;
//console.log("NEW BATTLE");
var formatid = toId(format);
if (rated && Tools.getFormat(formatid).rated !== false) {
rated = {
p1: p1.userid,
p2: p2.userid,
format: format
};
} else {
rated = false;
}
this.rated = rated;
this.battle = Simulator.create(this.id, format, rated, this);
this.parentid = parentid||'';
this.p1 = p1 || '';
this.p2 = p2 || '';
this.sideTicksLeft = [21, 21];
if (!rated) this.sideTicksLeft = [28,28];
this.sideTurnTicks = [0, 0];
this.disconnectTickDiff = [0, 0];
this.log = [];
}
BattleRoom.prototype.type = 'battle';
BattleRoom.prototype.resetTimer = null;
BattleRoom.prototype.resetUser = '';
BattleRoom.prototype.expireTimer = null;
BattleRoom.prototype.active = false;
BattleRoom.prototype.lastUpdate = 0;
BattleRoom.prototype.push = function(message) {
if (typeof message === 'string') {
this.log.push(message);
} else {
this.log = this.log.concat(message);
}
};
BattleRoom.prototype.win = function(winner) {
if (this.rated) {
var winnerid = toId(winner);
var rated = this.rated;
this.rated = false;
var p1score = 0.5;
if (winnerid === rated.p1) {
p1score = 1;
} else if (winnerid === rated.p2) {
p1score = 0;
}
var p1 = rated.p1;
if (Users.getExact(rated.p1)) p1 = Users.getExact(rated.p1).name;
var p2 = rated.p2;
if (Users.getExact(rated.p2)) p2 = Users.getExact(rated.p2).name;
//update.updates.push('[DEBUG] uri: '+config.loginserver+'action.php?act=ladderupdate&serverid='+config.serverid+'&p1='+encodeURIComponent(p1)+'&p2='+encodeURIComponent(p2)+'&score='+p1score+'&format='+toId(rated.format)+'&servertoken=[token]');
if (!rated.p1 || !rated.p2) {
this.push('|raw|ERROR: Ladder not updated: a player does not exist');
} else {
var winner = Users.get(winnerid);
if (winner && !winner.authenticated) {
this.send('|askreg|' + winner.userid, winner);
}
var p1rating, p2rating;
// update rankings
this.push('|raw|Ladder updating...');
var self = this;
LoginServer.request('ladderupdate', {
p1: p1,
p2: p2,
score: p1score,
format: toId(rated.format)
}, function(data, statusCode, error) {
if (!self.battle) {
console.log('room expired before ladder update was received');
return;
}
if (!data) {
self.addRaw('Ladder (probably) updated, but score could not be retrieved ('+error+').');
self.update();
// log the battle anyway
if (!Tools.getFormat(self.format).noLog) {
self.logBattle(p1score);
}
return;
} else {
try {
p1rating = data.p1rating;
p2rating = data.p2rating;
//self.add("Ladder updated.");
var oldacre = Math.round(data.p1rating.oldacre);
var acre = Math.round(data.p1rating.acre);
var reasons = ''+(acre-oldacre)+' for '+(p1score>.99?'winning':(p1score<.01?'losing':'tying'));
if (reasons.substr(0,1) !== '-') reasons = '+'+reasons;
self.addRaw(sanitize(p1)+'\'s rating: '+oldacre+' &rarr; <strong>'+acre+'</strong><br />('+reasons+')');
var oldacre = Math.round(data.p2rating.oldacre);
var acre = Math.round(data.p2rating.acre);
var reasons = ''+(acre-oldacre)+' for '+(p1score>.99?'losing':(p1score<.01?'winning':'tying'));
if (reasons.substr(0,1) !== '-') reasons = '+'+reasons;
self.addRaw(sanitize(p2)+'\'s rating: '+oldacre+' &rarr; <strong>'+acre+'</strong><br />('+reasons+')');
Users.get(p1).cacheMMR(rated.format, data.p1rating);
Users.get(p2).cacheMMR(rated.format, data.p2rating);
self.update();
} catch(e) {
self.addRaw('There was an error calculating rating changes.');
self.update();
}
if (!Tools.getFormat(self.format).noLog) {
self.logBattle(p1score, p1rating, p2rating);
}
}
});
}
}
rooms.global.battleCount += 0 - (this.active?1:0);
this.active = false;
this.update();
};
// idx = 0, 1 : player log
// idx = 2 : spectator log
// idx = 3 : replay log
BattleRoom.prototype.getLog = function(idx) {
var log = [];
for (var i = 0; i < this.log.length; ++i) {
var line = this.log[i];
if (line === '|split') {
log.push(this.log[i + idx + 1]);
i += 4;
} else {
log.push(line);
}
}
return log;
};
BattleRoom.prototype.getLogForUser = function(user) {
var slot = this.battle.getSlot(user);
if (slot < 0) slot = 2;
return this.getLog(slot);
};
BattleRoom.prototype.update = function(excludeUser) {
if (this.log.length <= this.lastUpdate) return;
var logs = [[], [], []];
var updateLines = this.log.slice(this.lastUpdate);
for (var i = 0; i < updateLines.length;) {
var line = updateLines[i++];
if (line === '|split') {
logs[0].push(updateLines[i++]); // player 0
logs[1].push(updateLines[i++]); // player 1
logs[2].push(updateLines[i++]); // spectators
i++; // replays
} else {
logs[0].push(line);
logs[1].push(line);
logs[2].push(line);
}
}
var roomid = this.id;
var self = this;
logs = logs.map(function(log) {
return log.join('\n');
});
this.lastUpdate = this.log.length;
var hasUsers = false;
for (var i in this.users) {
var user = this.users[i];
hasUsers = true;
if (user === excludeUser) continue;
var slot = this.battle.getSlot(user);
if (slot < 0) slot = 2;
this.send(logs[slot], user);
}
// empty rooms time out after ten minutes
if (!hasUsers) {
if (!this.expireTimer) {
this.expireTimer = setTimeout(this.tryExpire.bind(this), TIMEOUT_EMPTY_DEALLOCATE);
}
} else {
if (this.expireTimer) clearTimeout(this.expireTimer);
this.expireTimer = setTimeout(this.tryExpire.bind(this), TIMEOUT_INACTIVE_DEALLOCATE);
}
};
BattleRoom.prototype.logBattle = function(p1score, p1rating, p2rating) {
var logData = this.battle.logData;
logData.p1rating = p1rating;
logData.p2rating = p2rating;
logData.endType = this.battle.endType;
if (!p1rating) logData.ladderError = true;
logData.log = BattleRoom.prototype.getLog.call(logData, 3); // replay log (exact damage)
var date = new Date();
var logfolder = date.format('{yyyy}-{MM}');
var logsubfolder = date.format('{yyyy}-{MM}-{dd}');
var curpath = 'logs/'+logfolder;
var self = this;
fs.mkdir(curpath, '0755', function() {
var tier = self.format.toLowerCase().replace(/[^a-z0-9]+/g,'');
curpath += '/'+tier;
fs.mkdir(curpath, '0755', function() {
curpath += '/'+logsubfolder;
fs.mkdir(curpath, '0755', function() {
fs.writeFile(curpath+'/'+self.id+'.log.json', JSON.stringify(logData));
});
});
}); // asychronicity
//console.log(JSON.stringify(logData));
rooms.global.writeNumRooms();
};
BattleRoom.prototype.send = function(message, user) {
if (user) {
user.sendTo(this, message);
} else {
for (var i in this.users) {
this.users[i].sendTo(this, message);
}
}
};
BattleRoom.prototype.tryExpire = function() {
this.expire();
};
BattleRoom.prototype.reset = function(reload) {
clearTimeout(this.resetTimer);
this.resetTimer = null;
this.resetUser = '';
if (rooms.global.lockdown) {
this.add('The battle was not restarted because the server is preparing to shut down.');
return;
}
this.add('RESET');
this.update();
rooms.global.battleCount += 0 - (this.active?1:0);
this.active = false;
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
};
BattleRoom.prototype.getInactiveSide = function() {
if (this.battle.players[0] && !this.battle.players[1]) return 1;
if (this.battle.players[1] && !this.battle.players[0]) return 0;
return this.battle.inactiveSide;
};
BattleRoom.prototype.forfeit = function(user, message, side) {
if (!this.battle || this.battle.ended || !this.battle.started) return false;
if (!message) message = ' forfeited.';
if (side === undefined) {
if (user && user.userid === this.battle.playerids[0]) side = 0;
if (user && user.userid === this.battle.playerids[1]) side = 1;
}
if (side === undefined) return false;
var ids = ['p1', 'p2'];
var otherids = ['p2', 'p1'];
var name = 'Player '+(side+1);
if (user) {
name = user.name;
} else if (this.rated) {
name = this.rated[ids[side]];
}
this.addCmd('-message', name+message);
this.battle.endType = 'forfeit';
this.battle.send('win', otherids[side]);
rooms.global.battleCount += (this.battle.active?1:0) - (this.active?1:0);
this.active = this.battle.active;
this.update();
return true;
};
BattleRoom.prototype.kickInactive = function() {
clearTimeout(this.resetTimer);
this.resetTimer = null;
if (!this.battle || this.battle.ended || !this.battle.started) return false;
var inactiveSide = this.getInactiveSide();
var ticksLeft = [0, 0];
if (inactiveSide != 1) {
// side 0 is inactive
this.sideTurnTicks[0]--;
this.sideTicksLeft[0]--;
}
if (inactiveSide != 0) {
// side 1 is inactive
this.sideTurnTicks[1]--;
this.sideTicksLeft[1]--;
}
ticksLeft[0] = Math.min(this.sideTurnTicks[0], this.sideTicksLeft[0]);
ticksLeft[1] = Math.min(this.sideTurnTicks[1], this.sideTicksLeft[1]);
if (ticksLeft[0] && ticksLeft[1]) {
if (inactiveSide == 0 || inactiveSide == 1) {
// one side is inactive
var inactiveTicksLeft = ticksLeft[inactiveSide];
var inactiveUser = this.battle.getPlayer(inactiveSide);
if (inactiveTicksLeft % 3 == 0 || inactiveTicksLeft <= 4) {
this.send('|inactive|'+(inactiveUser?inactiveUser.name:'Player '+(inactiveSide+1))+' has '+(inactiveTicksLeft*10)+' seconds left.');
}
} else {
// both sides are inactive
var inactiveUser0 = this.battle.getPlayer(0);
if (ticksLeft[0] % 3 == 0 || ticksLeft[0] <= 4) {
this.send('|inactive|'+(inactiveUser0?inactiveUser0.name:'Player 1')+' has '+(ticksLeft[0]*10)+' seconds left.', inactiveUser0);
}
var inactiveUser1 = this.battle.getPlayer(1);
if (ticksLeft[1] % 3 == 0 || ticksLeft[1] <= 4) {
this.send('|inactive|'+(inactiveUser1?inactiveUser1.name:'Player 2')+' has '+(ticksLeft[1]*10)+' seconds left.', inactiveUser1);
}
}
this.resetTimer = setTimeout(this.kickInactive.bind(this), 10*1000);
return;
}
if (inactiveSide < 0) {
if (ticksLeft[0]) inactiveSide = 1;
else if (ticksLeft[1]) inactiveSide = 0;
}
this.forfeit(this.battle.getPlayer(inactiveSide),' lost due to inactivity.', inactiveSide);
this.resetUser = '';
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
};
BattleRoom.prototype.requestKickInactive = function(user, force) {
if (this.resetTimer) {
this.send('|inactive|The inactivity timer is already counting down.', user);
return false;
}
if (user) {
if (!force && this.battle.getSlot(user) < 0) return false;
this.resetUser = user.userid;
this.send('|inactive|Battle timer is now ON: inactive players will automatically lose when time\'s up. (requested by '+user.name+')');
}
// a tick is 10 seconds
var maxTicksLeft = 15; // 2 minutes 30 seconds
if (!this.battle.p1 || !this.battle.p2) {
// if a player has left, don't wait longer than 6 ticks (1 minute)
maxTicksLeft = 6;
}
if (!this.rated) maxTicksLeft = 30;
this.sideTurnTicks = [maxTicksLeft, maxTicksLeft];
var inactiveSide = this.getInactiveSide();
if (inactiveSide < 0) {
// add 10 seconds to bank if they're below 160 seconds
if (this.sideTicksLeft[0] < 16) this.sideTicksLeft[0]++;
if (this.sideTicksLeft[1] < 16) this.sideTicksLeft[1]++;
}
this.sideTicksLeft[0]++;
this.sideTicksLeft[1]++;
if (inactiveSide != 1) {
// side 0 is inactive
var ticksLeft0 = Math.min(this.sideTicksLeft[0] + 1, maxTicksLeft);
this.send('|inactive|You have '+(ticksLeft0*10)+' seconds to make your decision.', this.battle.getPlayer(0));
}
if (inactiveSide != 0) {
// side 1 is inactive
var ticksLeft1 = Math.min(this.sideTicksLeft[1] + 1, maxTicksLeft);
this.send('|inactive|You have '+(ticksLeft1*10)+' seconds to make your decision.', this.battle.getPlayer(1));
}
this.resetTimer = setTimeout(this.kickInactive.bind(this), 10*1000);
return true;
};
BattleRoom.prototype.nextInactive = function() {
if (this.resetTimer) {
this.update();
clearTimeout(this.resetTimer);
this.resetTimer = null;
this.requestKickInactive();
}
};
BattleRoom.prototype.stopKickInactive = function(user, force) {
if (!force && user && user.userid !== this.resetUser) return false;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
this.resetTimer = null;
this.send('|inactiveoff|Battle timer is now OFF.');
return true;
}
return false;
};
BattleRoom.prototype.kickInactiveUpdate = function () {
if (!this.rated) return false;
if (this.resetTimer) {
var inactiveSide = this.getInactiveSide();
var changed = false;
if ((!this.battle.p1 || !this.battle.p2) && !this.disconnectTickDiff[0] && !this.disconnectTickDiff[1]) {
if ((!this.battle.p1 && inactiveSide === 0) || (!this.battle.p2 && inactiveSide === 1)) {
var inactiveUser = this.battle.getPlayer(inactiveSide);
if (!this.battle.p1 && inactiveSide === 0 && this.sideTurnTicks[0] > 7) {
this.disconnectTickDiff[0] = this.sideTurnTicks[0] - 7;
this.sideTurnTicks[0] = 7;
changed = true;
} else if (!this.battle.p2 && inactiveSide === 1 && this.sideTurnTicks[1] > 7) {
this.disconnectTickDiff[1] = this.sideTurnTicks[1] - 7;
this.sideTurnTicks[1] = 7;
changed = true;
}
if (changed) {
this.send('|inactive|' + (inactiveUser ? inactiveUser.name : 'Player ' + (inactiveSide + 1)) + ' disconnected and has a minute to reconnect!');
return true;
}
}
} else if (this.battle.p1 && this.battle.p2) {
// Only one of the following conditions should happen, but do
// them both since you never know...
if (this.disconnectTickDiff[0]) {
this.sideTurnTicks[0] = this.sideTurnTicks[0] + this.disconnectTickDiff[0];
this.disconnectTickDiff[0] = 0;
changed = 0;
}
if (this.disconnectTickDiff[1]) {
this.sideTurnTicks[1] = this.sideTurnTicks[1] + this.disconnectTickDiff[1];
this.disconnectTickDiff[1] = 0;
changed = 1;
}
if (changed !== false) {
var user = this.battle.getPlayer(changed);
this.send('|inactive|' + (user ? user.name : 'Player ' + (changed + 1)) + ' reconnected and has ' + (this.sideTurnTicks[changed] * 10) + ' seconds left!');
return true;
}
}
}
return false;
};
BattleRoom.prototype.decision = function(user, choice, data) {
this.battle.sendFor(user, choice, data);
if (this.active !== this.battle.active) {
rooms.global.battleCount += (this.battle.active?1:0) - (this.active?1:0);
this.active = this.battle.active;
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
}
this.update();
};
// This function is only called when the room is not empty.
// Joining an empty room calls this.join() below instead.
BattleRoom.prototype.onJoinConnection = function(user, connection) {
this.send('|init|battle\n|title|'+this.title+'\n'+this.getLogForUser(user).join('\n'), connection);
// this handles joining a battle in which a user is a participant,
// where the user has already identified before attempting to join
// the battle
this.battle.resendRequest(user);
};
BattleRoom.prototype.onJoin = function(user, connection) {
if (!user) return false;
if (this.users[user.userid]) return user;
this.users[user.userid] = user;
if (user.named) {
this.addCmd('join', user.name);
this.update(user);
}
this.send('|init|battle\n|title|'+this.title+'\n'+this.getLogForUser(user).join('\n'), connection);
return user;
};
BattleRoom.prototype.onRename = function(user, oldid, joining) {
if (joining) {
this.addCmd('join', user.name);
}
var resend = joining || !this.battle.playerTable[oldid];
if (this.battle.playerTable[oldid]) {
if (this.rated) {
this.add('|message|'+user.name+' forfeited by changing their name.');
this.battle.lose(oldid);
this.battle.leave(oldid);
resend = false;
} else {
this.battle.rename();
}
}
delete this.users[oldid];
this.users[user.userid] = user;
this.update();
if (resend) {
// this handles a named user renaming themselves into a user in the
// battle (i.e. by using /nick)
this.battle.resendRequest(user);
}
return user;
};
BattleRoom.prototype.onUpdateIdentity = function() {};
BattleRoom.prototype.onLeave = function(user) {
if (!user) return; // ...
if (user.battles[this.id]) {
this.battle.leave(user);
rooms.global.battleCount += (this.battle.active?1:0) - (this.active?1:0);
this.active = this.battle.active;
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
} else if (!user.named) {
delete this.users[user.userid];
return;
}
delete this.users[user.userid];
this.addCmd('leave', user.name);
if (Object.isEmpty(this.users)) {
rooms.global.battleCount += 0 - (this.active?1:0);
this.active = false;
}
this.update();
this.kickInactiveUpdate();
};
BattleRoom.prototype.joinBattle = function(user, team) {
var slot = undefined;
if (this.rated) {
if (this.rated.p1 === user.userid) {
slot = 0;
} else if (this.rated.p2 === user.userid) {
slot = 1;
} else {
return;
}
}
this.battle.join(user, slot, team);
rooms.global.battleCount += (this.battle.active?1:0) - (this.active?1:0);
this.active = this.battle.active;
if (this.active) {
this.title = ""+this.battle.p1+" vs. "+this.battle.p2;
this.send('|title|'+this.title);
}
this.update();
this.kickInactiveUpdate();
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
};
BattleRoom.prototype.leaveBattle = function(user) {
if (!user) return false; // ...
if (user.battles[this.id]) {
this.battle.leave(user);
} else {
return false;
}
rooms.global.battleCount += (this.battle.active?1:0) - (this.active?1:0);
this.active = this.battle.active;
this.update();
this.kickInactiveUpdate();
if (this.parentid) {
getRoom(this.parentid).updateRooms();
}
return true;
};
BattleRoom.prototype.addCmd = function() {
this.log.push('|'+Array.prototype.slice.call(arguments).join('|'));
};
BattleRoom.prototype.add = function(message) {
if (message.rawMessage) {
this.addCmd('raw', message.rawMessage);
} else if (message.name) {
this.addCmd('chat', message.name.substr(1), message.message);
} else {
this.log.push(message);
}
};
BattleRoom.prototype.addRaw = function(message) {
this.addCmd('raw', message);
};
BattleRoom.prototype.chat = function(user, message, connection) {
// Battle actions are actually just text commands that are handled in
// parseCommand(), which in turn often calls Simulator.prototype.sendFor().
// Sometimes the call to sendFor is done indirectly, by calling
// room.decision(), where room.constructor === BattleRoom.
message = CommandParser.parse(message, this, user, connection);
if (message) {
this.battle.chat(user, message);
}
this.update();
};
BattleRoom.prototype.addModCommand = function(result) {
this.add(result);
this.logModCommand(result);
};
BattleRoom.prototype.logModCommand = function(result) {
modlog.write('['+(new Date().toJSON())+'] ('+room.id+') '+result+'\n');
};
BattleRoom.prototype.logEntry = function() {};
BattleRoom.prototype.expire = function() {
this.send('|expire|');
this.destroy();
};
BattleRoom.prototype.destroy = function() {
// deallocate ourself
// remove references to ourself
for (var i in this.users) {
this.users[i].leaveRoom(this);
delete this.users[i];
}
this.users = null;
rooms.global.removeRoom(this.id);
// deallocate children and get rid of references to them
if (this.battle) {
this.battle.destroy();
}
this.battle = null;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
}
this.resetTimer = null;
// get rid of some possibly-circular references
delete rooms[this.id];
};
return BattleRoom;
})();
var ChatRoom = (function() {
function ChatRoom(roomid, title, options) {
if (options) {
this.chatRoomData = options;
Object.merge(this, options);
}
this.id = roomid;
this.title = title||roomid;
this.i = {};
this.log = [];
this.logTimes = [];
this.lastUpdate = 0;
this.users = {};
this.searchers = [];
this.logFile = null;
this.logFilename = '';
this.destroyingLog = false;
this.bannedUsers = {};
this.bannedIps = {};
// `config.loglobby` is a legacy name
if (config.logchat || config.loglobby) {
this.rollLogFile(true);
this.logEntry = function(entry, date) {
var timestamp = (new Date()).format('{HH}:{mm}:{ss} ');
this.logFile.write(timestamp + entry + '\n');
};
this.logEntry('NEW CHATROOM: ' + this.id);
if (config.loguserstats) {
setInterval(this.logUserStats.bind(this), config.loguserstats);
}
}
if (config.reportjoinsperiod) {
this.userList = this.getUserList();
}
this.reportJoinsQueue = [];
this.reportJoinsInterval = setInterval(
this.reportRecentJoins.bind(this), config.reportjoinsperiod
);
}
ChatRoom.prototype.type = 'chat';
ChatRoom.prototype.reportRecentJoins = function() {
if (this.reportJoinsQueue.length === 0) {
// nothing to report
return;
}
if (config.reportjoinsperiod) {
this.userList = this.getUserList();
}
this.send(this.reportJoinsQueue.join('\n'));
this.reportJoinsQueue.length = 0;
};
ChatRoom.prototype.rollLogFile = function(sync) {
var mkdir = sync ? (function(path, mode, callback) {
try {
fs.mkdirSync(path, mode);
} catch (e) {} // directory already exists
callback();
}) : fs.mkdir;
var date = new Date();
var basepath = 'logs/chat/' + this.id + '/';
var self = this;
mkdir(basepath, '0755', function() {
var path = date.format('{yyyy}-{MM}');
mkdir(basepath + path, '0755', function() {
if (self.destroyingLog) return;
path += '/' + date.format('{yyyy}-{MM}-{dd}') + '.txt';
if (path !== self.logFilename) {
self.logFilename = path;
if (self.logFile) self.logFile.destroySoon();
self.logFile = fs.createWriteStream(basepath + path, {flags: 'a'});
// Create a symlink to today's lobby log.
// These operations need to be synchronous, but it's okay
// because this code is only executed once every 24 hours.
var link0 = basepath + 'today.txt.0';
try {
fs.unlinkSync(link0);
} catch (e) {} // file doesn't exist
try {
fs.symlinkSync(path, link0); // `basepath` intentionally not included
try {
fs.renameSync(link0, basepath + 'today.txt');
} catch (e) {} // OS doesn't support atomic rename
} catch (e) {} // OS doesn't support symlinks
}
var timestamp = +date;
date.advance('1 hour').reset('minutes').advance('1 second');
setTimeout(self.rollLogFile.bind(self), +date - timestamp);
});
});
};
ChatRoom.prototype.destroyLog = function(initialCallback, finalCallback) {
this.destroyingLog = true;
initialCallback();
if (this.logFile) {
this.logEntry = function() { };
this.logFile.on('close', finalCallback);
this.logFile.destroySoon();
} else {
finalCallback();
}
};
ChatRoom.prototype.logUserStats = function() {
var total = 0;
var guests = 0;
var groups = {};
config.groupsranking.forEach(function(group) {
groups[group] = 0;
});
for (var i in this.users) {
var user = this.users[i];
++total;
if (!user.named) {
++guests;
}
++groups[user.group];
}
var entry = '|userstats|total:' + total + '|guests:' + guests;
for (var i in groups) {
entry += '|' + i + ':' + groups[i];
}
this.logEntry(entry);
};
ChatRoom.prototype.getUserList = function() {
var buffer = '';
var counter = 0;
for (var i in this.users) {
if (!this.users[i].named) {
continue;
}
counter++;
buffer += ','+this.users[i].getIdentity(this.id);
}
var msg = '|users|'+counter+buffer;
return msg;
};
ChatRoom.prototype.update = function() {
if (this.log.length <= this.lastUpdate) return;
var entries = this.log.slice(this.lastUpdate);
var update = entries.join('\n');
if (this.log.length > 100) {
this.log.splice(0, this.log.length - 100);
this.logTimes.splice(0, this.logTimes.length - 100);
}
this.lastUpdate = this.log.length;
this.send(update);
};
ChatRoom.prototype.send = function(message, user) {
if (user) {
user.sendTo(this, message);
} else {
for (var i in this.users) {
this.users[i].sendTo(this, message);
}
}
};
ChatRoom.prototype.sendAuth = function(message) {
for (var i in this.users) {
var user = this.users[i];
if (user.connected && user.can('receiveauthmessages')) {
user.sendTo(this, message);
}
}
};
ChatRoom.prototype.add = function(message, noUpdate) {
this.logTimes.push(~~(Date.now() / 1000));
this.log.push(message);
this.logEntry(message);
if (!noUpdate) {
this.update();
}
};
ChatRoom.prototype.addRaw = function(message) {
this.add('|raw|'+message);
};
ChatRoom.prototype.logGetLast = function (amount, noTime) {
if (!amount) {
return [];
}
if (amount < 0) {
amount *= -1;
}
var logLength = this.log.length;
if (!logLength) {
return [];
}
var log = [];
var time = ~~(Date.now() / 1000);
for (var i = logLength - amount - 1; i < logLength; i++) {
if (i < 0) {
i = 0;
}
var logText = this.log[i];
if (!logText){
continue;
}
if (!noTime && logText.substr(0, 3) === '|c|') {
// Time is only added when it's a normal chat message
logText = '|tc|' + (time - this.logTimes[i]) + '|' + logText.substr(3);
}
log.push(logText);
}
return log;
};
ChatRoom.prototype.getModchatNote = function (noNewline) {
if (this.modchat) {
var text = !noNewline ? '\n' : '';
text += '|raw|<div class="broadcast-red">';
text += '<b>Moderated chat is currently set to ' + this.modchat + '!</b><br />';
text += 'Only users of rank ' + this.modchat + ' and higher can talk.';
text += '</div>';
return text;
}
return '';
};
ChatRoom.prototype.onJoinConnection = function(user, connection) {
var userList = this.userList ? this.userList : this.getUserList();
var modchat = this.getModchatNote();
this.send('|init|chat\n|title|'+this.title+'\n'+userList+'\n'+this.logGetLast(25).join('\n')+modchat, connection);
};
ChatRoom.prototype.onJoin = function(user, connection, merging) {
if (!user) return false; // ???
if (this.users[user.userid]) return user;
this.users[user.userid] = user;
if (user.named && config.reportjoins) {
this.add('|j|'+user.getIdentity(this.id), true);
this.update(user);
} else if (user.named) {
var entry = '|J|'+user.getIdentity(this.id);
if (config.reportjoinsperiod) {
this.reportJoinsQueue.push(entry);
} else {
this.send(entry);
}
this.logEntry(entry);
}
if (!merging) {
var userList = this.userList ? this.userList : this.getUserList();
var modchat = this.getModchatNote();
this.send('|init|chat\n|title|'+this.title+'\n'+userList+'\n'+this.logGetLast(100).join('\n')+modchat, connection);
}
return user;
};
ChatRoom.prototype.onRename = function(user, oldid, joining) {
delete this.users[oldid];
this.users[user.userid] = user;
var entry;
if (joining) {
if (config.reportjoins) {
entry = '|j|' + user.getIdentity(this.id);
} else {
entry = '|J|' + user.getIdentity(this.id);
}
} else if (!user.named) {
entry = '|L| ' + oldid;
} else {
entry = '|N|' + user.getIdentity(this.id) + '|' + oldid;
}
if (config.reportjoins) {
this.add(entry);
} else {
if (config.reportjoinsperiod) {
this.reportJoinsQueue.push(entry);
} else {
this.send(entry);
}
this.logEntry(entry);
}
return user;
};
/**
* onRename, but without a userid change
*/
ChatRoom.prototype.onUpdateIdentity = function(user) {
if (user && user.connected && user.named) {
var entry = '|N|' + user.getIdentity(this.id) + '|' + user.userid;
if (config.reportjoinsperiod) {
this.reportJoinsQueue.push(entry);
} else {
this.send(entry);
}
}
};
ChatRoom.prototype.onLeave = function(user) {
if (!user) return; // ...
delete this.users[user.userid];
if (config.reportjoins) {
this.add('|l|'+user.getIdentity(this.id));
} else if (user.named) {
var entry = '|L|' + user.getIdentity(this.id);
if (config.reportjoinsperiod) {
this.reportJoinsQueue.push(entry);
} else {
this.send(entry);
}
this.logEntry(entry);
}
};
ChatRoom.prototype.chat = function(user, message, connection) {
message = CommandParser.parse(message, this, user, connection);
if (message) {
this.add('|c|'+user.getIdentity(this.id)+'|'+message, true);
}
this.update();
};
ChatRoom.prototype.addModCommand = function(result) {
this.add(result);
this.logModCommand(result);
};
ChatRoom.prototype.logModCommand = function(result) {
modlog.write('['+(new Date().toJSON())+'] ('+room.id+') '+result+'\n');
};
ChatRoom.prototype.logEntry = function() {};
ChatRoom.prototype.destroy = function() {
// deallocate ourself
// remove references to ourself
for (var i in this.users) {
this.users[i].leaveRoom(this);
delete this.users[i];
}
this.users = null;
rooms.global.deregisterChatRoom(this.id);
rooms.global.delistChatRoom(this.id);
// get rid of some possibly-circular references
delete rooms[this.id];
};
return ChatRoom;
})();
// to make sure you don't get null returned, pass the second argument
var getRoom = function(roomid, fallback) {
if (roomid && roomid.id) return roomid;
if (!roomid) roomid = 'default';
if (!rooms[roomid] && fallback) {
return rooms.global;
}
return rooms[roomid];
};
var newRoom = function(roomid, format, p1, p2, parent, rated) {
if (roomid && roomid.id) return roomid;
if (!p1 || !p2) return false;
if (!roomid) roomid = 'default';
if (!rooms[roomid]) {
// console.log("NEW BATTLE ROOM: "+roomid);
ResourceMonitor.countBattle(p1.latestIp, p1.name);
ResourceMonitor.countBattle(p2.latestIp, p2.name);
rooms[roomid] = new BattleRoom(roomid, format, p1, p2, parent, rated);
}
return rooms[roomid];
};
var rooms = {};
console.log("NEW GLOBAL: global");
rooms.global = new GlobalRoom('global');
exports.GlobalRoom = GlobalRoom;
exports.BattleRoom = BattleRoom;
exports.ChatRoom = ChatRoom;
exports.get = getRoom;
exports.create = newRoom;
exports.rooms = rooms;
exports.global = rooms.global;
exports.lobby = rooms.lobby;