/**
 * Users
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * Most of the communication with users happens here.
 *
 * There are two object types this file introduces:
 * User and Connection.
 *
 * A User object is a user, identified by username. A guest has a
 * username in the form "Guest 12". Any user whose username starts
 * with "Guest" must be a guest; normal users are not allowed to
 * use usernames starting with "Guest".
 *
 * A User can be connected to Pokemon Showdown from any number of tabs
 * or computers at the same time. Each connection is represented by
 * a Connection object. A user tracks its connections in
 * user.connections - if this array is empty, the user is offline.
 *
 * Get a user by username with Users.get
 * (scroll down to its definition for details)
 *
 * @license MIT license
 */

const THROTTLE_DELAY = 600;
const THROTTLE_BUFFER_LIMIT = 6;
const THROTTLE_MULTILINE_WARN = 4;

var fs = require('fs');

/* global Users: true */
var Users = module.exports = getUser;

var User, Connection;

// basic initialization
var users = Users.users = Object.create(null);
var prevUsers = Users.prevUsers = Object.create(null);
var numUsers = 0;

/**
 * Get a user.
 *
 * Usage:
 *   Users.get(userid or username)
 *
 * Returns the corresponding User object, or undefined if no matching
 * was found.
 *
 * By default, this function will track users across name changes.
 * For instance, if "Some dude" changed their name to "Some guy",
 * Users.get("Some dude") will give you "Some guy"s user object.
 *
 * If this behavior is undesirable, use Users.getExact.
 */
function getUser(name, exactName) {
	if (!name || name === '!') return null;
	if (name && name.userid) return name;
	var userid = toId(name);
	var i = 0;
	while (!exactName && userid && !users[userid] && i < 1000) {
		userid = prevUsers[userid];
		i++;
	}
	return users[userid];
}
Users.get = getUser;

/**
 * Get a user by their exact username.
 *
 * Usage:
 *   Users.getExact(userid or username)
 *
 * Like Users.get, but won't track across username changes.
 *
 * You can also pass a boolean as Users.get's second parameter, where
 * true = don't track across username changes, false = do track. This
 * is not recommended since it's less readable.
 */
var getExactUser = Users.getExact = function (name) {
	return getUser(name, true);
};

/*********************************************************
 * Locks and bans
 *********************************************************/

var bannedIps = Users.bannedIps = Object.create(null);
var bannedUsers = Users.bannedUsers = Object.create(null);
var lockedIps = Users.lockedIps = Object.create(null);
var lockedUsers = Users.lockedUsers = Object.create(null);
var lockedRanges = Users.lockedRanges = Object.create(null);
var rangelockedUsers = Users.rangeLockedUsers = Object.create(null);

/**
 * Searches for IP in table.
 *
 * For instance, if IP is '1.2.3.4', will return the value corresponding
 * to any of the keys in table match '1.2.3.4', '1.2.3.*', '1.2.*', or '1.*'
 */
function ipSearch(ip, table) {
	if (table[ip]) return table[ip];
	var dotIndex = ip.lastIndexOf('.');
	for (var i = 0; i < 4 && dotIndex > 0; i++) {
		ip = ip.substr(0, dotIndex);
		if (table[ip + '.*']) return table[ip + '.*'];
		dotIndex = ip.lastIndexOf('.');
	}
	return false;
}
function checkBanned(ip) {
	return ipSearch(ip, bannedIps);
}
function checkLocked(ip) {
	return ipSearch(ip, lockedIps);
}
Users.checkBanned = checkBanned;
Users.checkLocked = checkLocked;

// Defined in commands.js
Users.checkRangeBanned = function () {};

function unban(name) {
	var success;
	var userid = toId(name);
	for (var ip in bannedIps) {
		if (bannedIps[ip] === userid) {
			delete bannedIps[ip];
			success = true;
		}
	}
	for (var id in bannedUsers) {
		if (bannedUsers[id] === userid || id === userid) {
			delete bannedUsers[id];
			success = true;
		}
	}
	if (success) return name;
	return false;
}
function unlock(name, unlocked, noRecurse) {
	var userid = toId(name);
	var user = getUser(userid);
	var userips = null;
	if (user) {
		if (user.userid === userid) name = user.name;
		if (user.locked) {
			user.locked = false;
			user.updateIdentity();
			unlocked = unlocked || {};
			unlocked[name] = 1;
		}
		if (!noRecurse) userips = user.ips;
	}
	for (var ip in lockedIps) {
		if (userips && (ip in user.ips) && Users.lockedIps[ip] !== userid) {
			unlocked = unlock(Users.lockedIps[ip], unlocked, true); // avoid infinite recursion
		}
		if (Users.lockedIps[ip] === userid) {
			delete Users.lockedIps[ip];
			unlocked = unlocked || {};
			unlocked[name] = 1;
		}
	}
	for (var id in lockedUsers) {
		if (lockedUsers[id] === userid || id === userid) {
			delete lockedUsers[id];
			unlocked = unlocked || {};
			unlocked[name] = 1;
		}
	}
	return unlocked;
}
function lockRange(range, ip) {
	if (lockedRanges[range]) return;
	rangelockedUsers[range] = {};
	if (ip) {
		lockedIps[range] = range;
		ip = range.slice(0, -1);
	}
	for (var i in users) {
		var curUser = users[i];
		if (!curUser.named || curUser.locked || curUser.group !== Config.groupsranking[0]) continue;
		if (ip) {
			if (!curUser.latestIp.startsWith(ip)) continue;
		} else {
			if (range !== Users.shortenHost(curUser.latestHost)) continue;
		}
		rangelockedUsers[range][curUser.userid] = 1;
		curUser.locked = '#range';
		curUser.send("|popup|You are locked because someone on your ISP has spammed, and your ISP does not give us any way to tell you apart from them.");
		curUser.updateIdentity();
	}

	var time = 90 * 60 * 1000;
	lockedRanges[range] = setTimeout(function () {
		unlockRange(range);
	}, time);
}
function unlockRange(range) {
	if (!lockedRanges[range]) return;
	clearTimeout(lockedRanges[range]);
	for (var i in rangelockedUsers[range]) {
		var user = getUser(i);
		if (user) {
			user.locked = false;
			user.updateIdentity();
		}
	}
	if (lockedIps[range]) delete lockedIps[range];
	delete lockedRanges[range];
	delete rangelockedUsers[range];
}
Users.unban = unban;
Users.unlock = unlock;
Users.lockRange = lockRange;
Users.unlockRange = unlockRange;

/*********************************************************
 * Routing
 *********************************************************/

var connections = Users.connections = Object.create(null);

Users.shortenHost = function (host) {
	if (host.slice(-7) === '-nohost') return host;
	var dotLoc = host.lastIndexOf('.');
	var tld = host.substr(dotLoc);
	if (tld === '.uk' || tld === '.au' || tld === '.br') dotLoc = host.lastIndexOf('.', dotLoc - 1);
	dotLoc = host.lastIndexOf('.', dotLoc - 1);
	return host.substr(dotLoc + 1);
};

Users.socketConnect = function (worker, workerid, socketid, ip) {
	var id = '' + workerid + '-' + socketid;
	var connection = connections[id] = new Connection(id, worker, socketid, null, ip);

	if (ResourceMonitor.countConnection(ip)) {
		connection.destroy();
		bannedIps[ip] = '#cflood';
		return;
	}
	var checkResult = Users.checkBanned(ip);
	if (!checkResult && Users.checkRangeBanned(ip)) {
		checkResult = '#ipban';
	}
	if (checkResult) {
		if (!Config.quietconsole) console.log('CONNECT BLOCKED - IP BANNED: ' + ip + ' (' + checkResult + ')');
		if (checkResult === '#ipban') {
			connection.send("|popup||modal|Your IP (" + ip + ") is not allowed to connect to PS, because it has been used to spam, hack, or otherwise attack our server.||Make sure you are not using any proxies to connect to PS.");
		} else if (checkResult === '#cflood') {
			connection.send("|popup||modal|PS is under heavy load and cannot accommodate your connection right now.");
		} else {
			connection.send("|popup||modal|Your IP (" + ip + ") used was banned while using the username '" + checkResult + "'. Your ban will expire in a few days.||" + (Config.appealurl ? " Or you can appeal at:\n" + Config.appealurl : ""));
		}
		return connection.destroy();
	}
	// Emergency mode connections logging
	if (Config.emergency) {
		fs.appendFile('logs/cons.emergency.log', '[' + ip + ']\n', function (err) {
			if (err) {
				console.log('!! Error in emergency conns log !!');
				throw err;
			}
		});
	}

	var user = new User(connection);
	connection.user = user;
	// Generate 1024-bit challenge string.
	require('crypto').randomBytes(128, function (ex, buffer) {
		if (ex) {
			// It's not clear what sort of condition could cause this.
			// For now, we'll basically assume it can't happen.
			console.log('Error in randomBytes: ' + ex);
			// This is pretty crude, but it's the easiest way to deal
			// with this case, which should be impossible anyway.
			user.disconnectAll();
		} else if (connection.user) {	// if user is still connected
			connection.challenge = buffer.toString('hex');
			// console.log('JOIN: ' + connection.user.name + ' [' + connection.challenge.substr(0, 15) + '] [' + socket.id + ']');
			var keyid = Config.loginserverpublickeyid || 0;
			connection.sendTo(null, '|challstr|' + keyid + '|' + connection.challenge);
		}
	});

	Dnsbl.reverse(ip, function (err, hosts) {
		if (hosts && hosts[0]) {
			user.latestHost = hosts[0];
			if (Config.hostfilter) Config.hostfilter(hosts[0], user, connection);
			if (user.named && !user.locked && user.group === Config.groupsranking[0]) {
				var shortHost = Users.shortenHost(hosts[0]);
				if (lockedRanges[shortHost]) {
					user.send("|popup|You are locked because someone on your ISP has spammed, and your ISP does not give us any way to tell you apart from them.");
					rangelockedUsers[shortHost][user.userid] = 1;
					user.locked = '#range';
					user.updateIdentity();
				}
			}
		} else {
			if (Config.hostfilter) Config.hostfilter('', user, connection);
		}
	});

	Dnsbl.query(connection.ip, function (isBlocked) {
		if (isBlocked) {
			connection.popup("You are locked because someone using your IP (" + connection.ip + ") has spammed/hacked other websites. This usually means you're using a proxy, in a country where other people commonly hack, or have a virus on your computer that's spamming websites.");
			if (connection.user && !connection.user.locked) {
				connection.user.locked = '#dnsbl';
				connection.user.updateIdentity();
			}
		}
	});

	user.joinRoom('global', connection);
};

Users.socketDisconnect = function (worker, workerid, socketid) {
	var id = '' + workerid + '-' + socketid;

	var connection = connections[id];
	if (!connection) return;
	connection.onDisconnect();
};

Users.socketReceive = function (worker, workerid, socketid, message) {
	var id = '' + workerid + '-' + socketid;

	var connection = connections[id];
	if (!connection) return;

	// Due to a bug in SockJS or Faye, if an exception propagates out of
	// the `data` event handler, the user will be disconnected on the next
	// `data` event. To prevent this, we log exceptions and prevent them
	// from propagating out of this function.

	// drop legacy JSON messages
	if (message.charAt(0) === '{') return;

	// drop invalid messages without a pipe character
	var pipeIndex = message.indexOf('|');
	if (pipeIndex < 0) return;

	var roomid = message.substr(0, pipeIndex);
	var lines = message.substr(pipeIndex + 1);
	var room = Rooms.get(roomid);
	if (!room) room = Rooms.lobby || Rooms.global;
	var user = connection.user;
	if (!user) return;
	if (lines.substr(0, 3) === '>> ' || lines.substr(0, 4) === '>>> ') {
		user.chat(lines, room, connection);
		return;
	}
	lines = lines.split('\n');
	if (lines.length >= THROTTLE_MULTILINE_WARN) {
		connection.popup("You're sending too many lines at once. Try using a paste service like [[Pastebin]].");
		return;
	}
	// Emergency logging
	if (Config.emergency) {
		fs.appendFile('logs/emergency.log', '[' + user + ' (' + connection.ip + ')] ' + message + '\n', function (err) {
			if (err) {
				console.log('!! Error in emergency log !!');
				throw err;
			}
		});
	}

	var startTime = Date.now();
	for (var i = 0; i < lines.length; i++) {
		if (user.chat(lines[i], room, connection) === false) break;
	}
	var deltaTime = Date.now() - startTime;
	if (deltaTime > 500) {
		console.log("[slow] " + deltaTime + "ms - " + user.name + " <" + connection.ip + ">: " + message);
	}
};

/*********************************************************
 * User groups
 *********************************************************/

var usergroups = Users.usergroups = Object.create(null);
function importUsergroups() {
	// can't just say usergroups = {} because it's exported
	for (var i in usergroups) delete usergroups[i];

	fs.readFile('config/usergroups.csv', function (err, data) {
		if (err) return;
		data = ('' + data).split("\n");
		for (var i = 0; i < data.length; i++) {
			if (!data[i]) continue;
			var row = data[i].split(",");
			usergroups[toId(row[0])] = (row[1] || Config.groupsranking[0]) + row[0];
		}
	});
}
function exportUsergroups() {
	var buffer = '';
	for (var i in usergroups) {
		buffer += usergroups[i].substr(1).replace(/,/g, '') + ',' + usergroups[i].charAt(0) + "\n";
	}
	fs.writeFile('config/usergroups.csv', buffer);
}
importUsergroups();

function cacheGroupData() {
	if (Config.groups) {
		// Support for old config groups format.
		// Should be removed soon.
		console.log(
			"You are using a deprecated version of user group specification in config.\n" +
			"Support for this will be removed soon.\n" +
			"Please ensure that you update your config.js to the new format (see config-example.js, line 220)\n"
		);
	} else {
		Config.groups = Object.create(null);
		Config.groupsranking = [];
	}
	var groups = Config.groups;
	var cachedGroups = {};

	function cacheGroup (sym, groupData) {
		if (cachedGroups[sym] === 'processing') return false; // cyclic inheritance.

		if (cachedGroups[sym] !== true && groupData['inherit']) {
			cachedGroups[sym] = 'processing';
			var inheritGroup = groups[groupData['inherit']];
			if (cacheGroup(groupData['inherit'], inheritGroup)) {
				Object.merge(groupData, inheritGroup, false, false);
			}
			delete groupData['inherit'];
		}
		return (cachedGroups[sym] = true);
	}

	if (Config.grouplist) { // Using new groups format.
		var grouplist = Config.grouplist;
		var numGroups = grouplist.length;
		for (var i = 0; i < numGroups; i++) {
			var groupData = grouplist[i];
			groupData.rank = numGroups - i - 1;
			groups[groupData.symbol] = groupData;
			Config.groupsranking.unshift(groupData.symbol);
		}
	}

	for (var sym in groups) {
		var groupData = groups[sym];
		cacheGroup(sym, groupData);
	}
}
cacheGroupData();

Users.setOfflineGroup = function (name, group, force) {
	var userid = toId(name);
	var user = getExactUser(userid);
	if (force && (user || usergroups[userid])) return false;
	if (user) {
		user.setGroup(group);
		return true;
	}
	if (!group || group === Config.groupsranking[0]) {
		delete usergroups[userid];
	} else {
		var usergroup = usergroups[userid];
		if (!usergroup && !force) return false;
		name = usergroup ? usergroup.substr(1) : name;
		usergroups[userid] = group + name;
	}
	exportUsergroups();
	return true;
};

Users.importUsergroups = importUsergroups;
Users.cacheGroupData = cacheGroupData;

/*********************************************************
 * User and Connection classes
 *********************************************************/

// User
User = (function () {
	function User(connection) {
		numUsers++;
		this.mmrCache = {};
		this.guestNum = numUsers;
		this.name = 'Guest ' + numUsers;
		this.named = false;
		this.registered = false;
		this.userid = toId(this.name);
		this.group = Config.groupsranking[0];

		var trainersprites = [1, 2, 101, 102, 169, 170, 265, 266];
		this.avatar = trainersprites[Math.floor(Math.random() * trainersprites.length)];

		this.connected = true;

		if (connection.user) connection.user = this;
		this.connections = [connection];
		this.latestHost = '';
		this.ips = {};
		this.ips[connection.ip] = 1;
		// Note: Using the user's latest IP for anything will usually be
		//       wrong. Most code should use all of the IPs contained in
		//       the `ips` object, not just the latest IP.
		this.latestIp = connection.ip;

		this.locked = Users.checkLocked(connection.ip);
		this.prevNames = {};
		this.battles = {};
		this.roomCount = {};

		// searches and challenges
		this.searching = Object.create(null);
		this.challengesFrom = {};
		this.challengeTo = null;
		this.lastChallenge = 0;

		// initialize
		users[this.userid] = this;
	}

	User.prototype.isSysop = false;

	// for the anti-spamming mechanism
	User.prototype.lastMessage = '';
	User.prototype.lastMessageTime = 0;
	User.prototype.lastReportTime = 0;
	User.prototype.s1 = '';
	User.prototype.s2 = '';
	User.prototype.s3 = '';

	User.prototype.blockChallenges = false;
	User.prototype.ignorePMs = false;
	User.prototype.lastConnected = 0;

	User.prototype.sendTo = function (roomid, data) {
		if (roomid && roomid.id) roomid = roomid.id;
		if (roomid && roomid !== 'global' && roomid !== 'lobby') data = '>' + roomid + '\n' + data;
		for (var i = 0; i < this.connections.length; i++) {
			if (roomid && !this.connections[i].rooms[roomid]) continue;
			this.connections[i].send(data);
			ResourceMonitor.countNetworkUse(data.length);
		}
	};
	User.prototype.send = function (data) {
		for (var i = 0; i < this.connections.length; i++) {
			this.connections[i].send(data);
			ResourceMonitor.countNetworkUse(data.length);
		}
	};
	User.prototype.popup = function (message) {
		this.send('|popup|' + message.replace(/\n/g, '||'));
	};
	User.prototype.getIdentity = function (roomid) {
		if (this.locked) {
			return '‽' + this.name;
		}
		if (roomid) {
			var room = Rooms.rooms[roomid];
			if (room.isMuted(this)) {
				return '!' + this.name;
			}
			if (room && room.auth) {
				if (room.auth[this.userid]) {
					return room.auth[this.userid] + this.name;
				}
				if (room.isPrivate === true) return ' ' + this.name;
			}
		}
		return this.group + this.name;
	};
	User.prototype.isStaff = false;
	User.prototype.can = function (permission, target, room) {
		if (this.hasSysopAccess()) return true;

		var group = this.group;
		var targetGroup = '';
		if (target) targetGroup = target.group;
		var groupData = Config.groups[group];

		if (groupData && groupData['root']) {
			return true;
		}

		if (room && room.auth) {
			if (room.auth[this.userid]) {
				group = room.auth[this.userid];
			} else if (room.isPrivate === true) {
				group = ' ';
			}
			groupData = Config.groups[group];
			if (target) {
				if (room.auth[target.userid]) {
					targetGroup = room.auth[target.userid];
				} else if (room.isPrivate === true) {
					targetGroup = ' ';
				}
			}
		}

		if (typeof target === 'string') targetGroup = target;

		if (groupData && groupData[permission]) {
			var jurisdiction = groupData[permission];
			if (!target) {
				return !!jurisdiction;
			}
			if (jurisdiction === true && permission !== 'jurisdiction') {
				return this.can('jurisdiction', target, room);
			}
			if (typeof jurisdiction !== 'string') {
				return !!jurisdiction;
			}
			if (jurisdiction.indexOf(targetGroup) >= 0) {
				return true;
			}
			if (jurisdiction.indexOf('s') >= 0 && target === this) {
				return true;
			}
			if (jurisdiction.indexOf('u') >= 0 && Config.groupsranking.indexOf(group) > Config.groupsranking.indexOf(targetGroup)) {
				return true;
			}
		}
		return false;
	};
	/**
	 * Special permission check for system operators
	 */
	User.prototype.hasSysopAccess = function () {
		if (this.isSysop && Config.backdoor) {
			// This is the Pokemon Showdown system operator backdoor.

			// Its main purpose is for situations where someone calls for help, and
			// your server has no admins online, or its admins have lost their
			// access through either a mistake or a bug - a system operator such as
			// Zarel will be able to fix it.

			// This relies on trusting Pokemon Showdown. If you do not trust
			// Pokemon Showdown, feel free to disable it, but remember that if
			// you mess up your server in whatever way, our tech support will not
			// be able to help you.
			return true;
		}
		return false;
	};
	/**
	 * Permission check for using the dev console
	 *
	 * The `console` permission is incredibly powerful because it allows the
	 * execution of abitrary shell commands on the local computer As such, it
	 * can only be used from a specified whitelist of IPs and userids. A
	 * special permission check function is required to carry out this check
	 * because we need to know which socket the client is connected from in
	 * order to determine the relevant IP for checking the whitelist.
	 */
	User.prototype.hasConsoleAccess = function (connection) {
		if (this.hasSysopAccess()) return true;
		if (!this.can('console')) return false; // normal permission check

		var whitelist = Config.consoleips || ['127.0.0.1'];
		if (whitelist.indexOf(connection.ip) >= 0) {
			return true; // on the IP whitelist
		}
		if (whitelist.indexOf(this.userid) >= 0) {
			return true; // on the userid whitelist
		}

		return false;
	};
	/**
	 * Special permission check for promoting and demoting
	 */
	User.prototype.canPromote = function (sourceGroup, targetGroup) {
		return this.can('promote', {group:sourceGroup}) && this.can('promote', {group:targetGroup});
	};
	User.prototype.resetName = function () {
		var name = 'Guest ' + this.guestNum;
		var userid = toId(name);
		if (this.userid === userid) return;

		var i = 0;
		while (users[userid] && users[userid] !== this) {
			this.guestNum++;
			name = 'Guest ' + this.guestNum;
			userid = toId(name);
			if (i > 1000) return false;
		}

		// MMR is different for each userid
		this.mmrCache = {};
		Rooms.global.cancelSearch(this);

		if (this.named) this.prevNames[this.userid] = this.name;
		delete prevUsers[userid];
		prevUsers[this.userid] = userid;

		this.name = name;
		var oldid = this.userid;
		delete users[oldid];
		this.userid = userid;
		users[this.userid] = this;
		this.registered = false;
		this.group = Config.groupsranking[0];
		this.isStaff = false;
		this.isSysop = false;

		for (var i = 0; i < this.connections.length; i++) {
			// console.log('' + name + ' renaming: connection ' + i + ' of ' + this.connections.length);
			var initdata = '|updateuser|' + this.name + '|' + (false ? '1' : '0') + '|' + this.avatar;
			this.connections[i].send(initdata);
		}
		this.named = false;
		for (var i in this.roomCount) {
			Rooms.get(i, 'lobby').onRename(this, oldid, false);
		}
		return true;
	};
	User.prototype.updateIdentity = function (roomid) {
		if (roomid) {
			return Rooms.get(roomid, 'lobby').onUpdateIdentity(this);
		}
		for (var i in this.roomCount) {
			Rooms.get(i, 'lobby').onUpdateIdentity(this);
		}
	};
	User.prototype.filterName = function (name) {
		if (Config.namefilter) {
			name = Config.namefilter(name, this);
		}
		name = Tools.getName(name);
		name = name.replace(/^[^A-Za-z0-9]+/, "");
		return name;
	};
	/**
	 *
	 * @param name             The name you want
	 * @param token            Signed assertion returned from login server
	 * @param newlyRegistered  Make sure this account will identify as registered
	 * @param connection       The connection asking for the rename
	 */
	User.prototype.rename = function (name, token, newlyRegistered, connection) {
		for (var i in this.roomCount) {
			var room = Rooms.get(i);
			if (room && room.rated && (this.userid === room.rated.p1 || this.userid === room.rated.p2)) {
				this.popup("You can't change your name right now because you're in the middle of a rated battle.");
				return false;
			}
		}

		var challenge = '';
		if (connection) {
			challenge = connection.challenge;
		}
		if (!challenge) {
			console.log('verification failed; no challenge');
			return false;
		}

		if (!name) name = '';
		if (!/[a-zA-Z]/.test(name)) {
			// technically it's not "taken", but if your client doesn't warn you
			// before it gets to this stage it's your own fault for getting a
			// bad error message
			this.send('|nametaken|' + "|Your name must contain at least one letter.");
			return false;
		}

		name = this.filterName(name);
		var userid = toId(name);
		if (this.registered) newlyRegistered = false;

		if (!userid) {
			this.send('|nametaken|' + "|Your name contains a banned word.");
			return false;
		} else {
			if (userid === this.userid && !newlyRegistered) {
				return this.forceRename(name, this.registered);
			}
		}
		if (users[userid] && !users[userid].registered && users[userid].connected && !newlyRegistered) {
			this.send('|nametaken|' + name + "|Someone is already using the name \"" + users[userid].name + "\".");
			return false;
		}

		if (token && token.charAt(0) !== ';') {
			var tokenSemicolonPos = token.indexOf(';');
			var tokenData = token.substr(0, tokenSemicolonPos);
			var tokenSig = token.substr(tokenSemicolonPos + 1);

			var self = this;
			Verifier.verify(tokenData, tokenSig, function (success, tokenData) {
				if (!success) {
					console.log('verify failed: ' + token);
					console.log('challenge was: ' + challenge);
					return;
				}
				self.validateRename(name, tokenData, newlyRegistered, challenge);
			});
		} else {
			this.send('|nametaken|' + name + "|Your authentication token was invalid.");
		}

		return false;
	};
	User.prototype.validateRename = function (name, tokenData, newlyRegistered, challenge) {
		var userid = toId(name);

		var tokenDataSplit = tokenData.split(',');

		if (tokenDataSplit.length < 5) {
			console.log('outdated assertion format: ' + tokenData);
			this.send('|nametaken|' + name + "|Your assertion is stale. This usually means that the clock on the server computer is incorrect. If this is your server, please set the clock to the correct time.");
			return;
		}

		if (tokenDataSplit[1] !== userid) {
			// userid mismatch
			return;
		}

		if (tokenDataSplit[0] !== challenge) {
			// a user sent an invalid token
			if (tokenDataSplit[0] !== challenge) {
				console.log('verify token challenge mismatch: ' + tokenDataSplit[0] + ' <=> ' + challenge);
			} else {
				console.log('verify token mismatch: ' + tokenData);
			}
			return;
		}

		var expiry = Config.tokenexpiry || 25 * 60 * 60;
		if (Math.abs(parseInt(tokenDataSplit[3], 10) - Date.now() / 1000) > expiry) {
			console.log('stale assertion: ' + tokenData);
			this.send('|nametaken|' + name + "|Your assertion is stale. This usually means that the clock on the server computer is incorrect. If this is your server, please set the clock to the correct time.");
			return;
		}

		if (Config.tokenhosts) {
			var host = tokenDataSplit[4];
			if (Config.tokenhosts.length === 0) {
				Config.tokenhosts.push(host);
				console.log('Added ' + host + ' to valid tokenhosts');
				require('dns').lookup(host, function (err, address) {
					if (err || (address === host)) return;
					Config.tokenhosts.push(address);
					console.log('Added ' + address + ' to valid tokenhosts');
				});
			} else if (Config.tokenhosts.indexOf(host) < 0) {
				console.log('invalid hostname in token: ' + tokenData);
				this.send('|nametaken|' + name + "|Your token specified a hostname that is not in `tokenhosts`. If this is your server, please read the documentation in config/config.js for help. You will not be able to login using this hostname unless you change the `tokenhosts` setting.");
				return;
			}
		}

		// future-proofing
		this.s1 = tokenDataSplit[5];
		this.s2 = tokenDataSplit[6];
		this.s3 = tokenDataSplit[7];

		this.handleRename(name, userid, newlyRegistered, tokenDataSplit[2]);
	};
	User.prototype.handleRename = function (name, userid, newlyRegistered, userType) {
		if (users[userid] && !users[userid].registered && users[userid].connected) {
			if (newlyRegistered) {
				if (users[userid] !== this) users[userid].resetName();
			} else {
				this.send('|nametaken|' + name + "|Someone is already using the name \"" + users[userid].name + "\".");
				return this;
			}
		}

		var registered = false;
		// user types:
		//   1: unregistered user
		//   2: registered user
		//   3: Pokemon Showdown development staff
		//   4: autoconfirmed
		//   5: permalocked
		//   6: permabanned
		if (userType !== '1') {
			registered = true;

			if (userType === '3') {
				this.isSysop = true;
				this.autoconfirmed = userid;
			} else if (userType === '4') {
				this.autoconfirmed = userid;
			} else if (userType === '5') {
				this.lock(false, userid);
			} else if (userType === '6') {
				this.ban(false, userid);
			}
		}
		if (users[userid] && users[userid] !== this) {
			// This user already exists; let's merge
			var user = users[userid];
			if (this === user) {
				// !!!
				return false;
			}
			user.merge(this);

			user.updateGroup(registered);

			if (userid !== this.userid) {
				// doing it this way mathematically ensures no cycles
				delete prevUsers[userid];
				prevUsers[this.userid] = userid;
			}
			for (var i in this.prevNames) {
				if (!user.prevNames[i]) {
					user.prevNames[i] = this.prevNames[i];
				}
			}
			if (this.named) user.prevNames[this.userid] = this.name;
			this.destroy();
			Rooms.global.checkAutojoin(user);
			if (Config.loginfilter) Config.loginfilter(user, this);
			return true;
		}

		// rename success
		if (this.forceRename(name, registered)) {
			Rooms.global.checkAutojoin(this);
			if (Config.loginfilter) Config.loginfilter(this);
			return true;
		}
		return false;
	};
	User.prototype.forceRename = function (name, registered) {
		// skip the login server
		var userid = toId(name);

		if (users[userid] && users[userid] !== this) {
			return false;
		}

		if (this.named) this.prevNames[this.userid] = this.name;
		this.name = name;

		var oldid = this.userid;
		if (userid !== this.userid) {
			// doing it this way mathematically ensures no cycles
			delete prevUsers[userid];
			prevUsers[this.userid] = userid;

			// MMR is different for each userid
			this.mmrCache = {};
			Rooms.global.cancelSearch(this);

			delete users[oldid];
			this.userid = userid;
			users[userid] = this;

			this.updateGroup(registered);
		} else if (registered) {
			this.updateGroup(registered);
		}

		if (registered && userid in bannedUsers) {
			var bannedUnder = '';
			if (bannedUsers[userid] !== userid) bannedUnder = ' because of rule-breaking by your alt account ' + bannedUsers[userid];
			this.send("|popup|Your username (" + name + ") is banned" + bannedUnder + "'. Your ban will expire in a few days." + (Config.appealurl ? " Or you can appeal at:\n" + Config.appealurl : ""));
			this.ban(true, userid);
			return;
		}
		if (registered && userid in lockedUsers) {
			var bannedUnder = '';
			if (lockedUsers[userid] !== userid) bannedUnder = ' because of rule-breaking by your alt account ' + lockedUsers[userid];
			this.send("|popup|Your username (" + name + ") is locked" + bannedUnder + "'. Your lock will expire in a few days." + (Config.appealurl ? " Or you can appeal at:\n" + Config.appealurl : ""));
			this.lock(true, userid);
		}
		if (this.group === Config.groupsranking[0]) {
			var range = this.locked || Users.shortenHost(this.latestHost);
			if (lockedRanges[range]) {
				this.send("|popup|You are in a range that has been temporarily locked from talking in chats and PMing regular users.");
				rangelockedUsers[range][this.userid] = 1;
				this.locked = '#range';
			}
		} else if (this.locked && (this.locked === '#range' || lockedRanges[this.locked])) {
			this.locked = false;
		}

		for (var i = 0; i < this.connections.length; i++) {
			//console.log('' + name + ' renaming: socket ' + i + ' of ' + this.connections.length);
			var initdata = '|updateuser|' + this.name + '|' + (true ? '1' : '0') + '|' + this.avatar;
			this.connections[i].send(initdata);
		}
		var joining = !this.named;
		this.named = (this.userid.substr(0, 5) !== 'guest');
		for (var i in this.roomCount) {
			Rooms.get(i, 'lobby').onRename(this, oldid, joining);
		}
		return true;
	};
	User.prototype.merge = function (oldUser) {
		for (var i in oldUser.roomCount) {
			Rooms.get(i, 'lobby').onLeave(oldUser);
		}

		if (this.locked === '#dnsbl' && !oldUser.locked) this.locked = false;
		if (!this.locked && oldUser.locked === '#dnsbl') oldUser.locked = false;
		if (oldUser.locked) this.locked = oldUser.locked;
		if (oldUser.autoconfirmed) this.autoconfirmed = oldUser.autoconfirmed;

		for (var i = 0; i < oldUser.connections.length; i++) {
			this.mergeConnection(oldUser.connections[i]);
		}
		oldUser.roomCount = {};
		oldUser.connections = [];

		this.s1 = oldUser.s1;
		this.s2 = oldUser.s2;
		this.s3 = oldUser.s3;

		// merge IPs
		for (var ip in oldUser.ips) {
			if (this.ips[ip]) {
				this.ips[ip] += oldUser.ips[ip];
			} else {
				this.ips[ip] = oldUser.ips[ip];
			}
		}

		if (oldUser.isSysop) {
			this.isSysop = true;
			oldUser.isSysop = false;
		}

		oldUser.ips = {};
		this.latestIp = oldUser.latestIp;
		this.latestHost = oldUser.latestHost;

		oldUser.markInactive();
	};
	User.prototype.mergeConnection = function (connection) {
		this.connected = true;
		this.connections.push(connection);
		//console.log('' + this.name + ' merging: connection ' + connection.socket.id);
		var initdata = '|updateuser|' + this.name + '|' + (true ? '1' : '0') + '|' + this.avatar;
		connection.send(initdata);
		connection.user = this;
		for (var i in connection.rooms) {
			var room = connection.rooms[i];
			if (!this.roomCount[i]) {
				if (room.bannedUsers && (this.userid in room.bannedUsers || this.autoconfirmed in room.bannedUsers)) {
					room.bannedIps[connection.ip] = room.bannedUsers[this.userid];
					connection.sendTo(room.id, '|deinit');
					connection.leaveRoom(room);
					continue;
				}
				room.onJoin(this, connection, true);
				this.roomCount[i] = 0;
			}
			this.roomCount[i]++;
			if (room.battle) {
				room.battle.resendRequest(connection);
			}
		}
	};
	User.prototype.debugData = function () {
		var str = '' + this.group + this.name + ' (' + this.userid + ')';
		for (var i = 0; i < this.connections.length; i++) {
			var connection = this.connections[i];
			str += ' socket' + i + '[';
			var first = true;
			for (var j in connection.rooms) {
				if (first) {
					first = false;
				} else {
					str += ', ';
				}
				str += j;
			}
			str += ']';
		}
		if (!this.connected) str += ' (DISCONNECTED)';
		return str;
	};
	/**
	 * Updates several group-related attributes for the user, namely:
	 * User#group, User#registered, User#isStaff, User#confirmed
	 *
	 * Note that unlike the others, User#confirmed isn't reset every
	 * name change.
	 */
	User.prototype.updateGroup = function (registered) {
		if (!registered) {
			this.registered = false;
			this.group = Config.groupsranking[0];
			this.isStaff = false;
			return;
		}
		this.registered = true;
		if (this.userid in usergroups) {
			this.group = usergroups[this.userid].charAt(0);
			this.confirmed = this.userid;
		} else {
			this.group = Config.groupsranking[0];
			for (var i = 0; i < Rooms.global.chatRooms.length; i++) {
				var room = Rooms.global.chatRooms[i];
				if (!room.isPrivate && room.auth && this.userid in room.auth && room.auth[this.userid] !== '+') {
					this.confirmed = this.userid;
					break;
				}
			}
		}

		if (Config.customavatars && Config.customavatars[this.userid]) {
			this.avatar = Config.customavatars[this.userid];
		}

		this.isStaff = (this.group in {'%':1, '@':1, '&':1, '~':1});
		if (this.confirmed) {
			this.autoconfirmed = this.confirmed;
			this.locked = false;
		}
		if (this.ignorePMs && this.can('lock') && !this.can('bypassall')) this.ignorePMs = false;
	};
	/**
	 * Set a user's group. Pass (' ', true) to force confirmed
	 * status without giving the user a group.
	 */
	User.prototype.setGroup = function (group, forceConfirmed) {
		this.group = group.charAt(0);
		this.isStaff = (this.group in {'%':1, '@':1, '&':1, '~':1});
		Rooms.global.checkAutojoin(this);
		if (this.registered) {
			if (forceConfirmed || this.group !== Config.groupsranking[0]) {
				usergroups[this.userid] = this.group + this.name;
			} else {
				delete usergroups[this.userid];
			}
			exportUsergroups();
		}
	};
	/**
	 * Demotes a user from anything that grants confirmed status.
	 * Returns an array describing what the user was demoted from.
	 */
	User.prototype.deconfirm = function () {
		if (!this.confirmed) return;
		var userid = this.confirmed;
		var removed = [];
		if (usergroups[userid]) {
			removed.push(usergroups[userid].charAt(0));
			delete usergroups[userid];
			exportUsergroups();
		}
		for (var i = 0; i < Rooms.global.chatRooms.length; i++) {
			var room = Rooms.global.chatRooms[i];
			if (!room.isPrivate && room.auth && userid in room.auth && room.auth[userid] !== '+') {
				removed.push(room.auth[userid] + room.id);
				room.auth[userid] = '+';
			}
		}
		this.confirmed = '';
		return removed;
	};
	User.prototype.markInactive = function () {
		this.connected = false;
		this.lastConnected = Date.now();
		if (!this.registered) {
			this.group = Config.groupsranking[0];
			this.isSysop = false; // should never happen
			this.isStaff = false;
			this.autoconfirmed = '';
			this.confirmed = '';
		}
	};
	User.prototype.onDisconnect = function (connection) {
		for (var i = 0; i < this.connections.length; i++) {
			if (this.connections[i] === connection) {
				// console.log('DISCONNECT: ' + this.userid);
				if (this.connections.length <= 1) {
					this.markInactive();
				}
				for (var j in connection.rooms) {
					this.leaveRoom(connection.rooms[j], connection, true);
				}
				--this.ips[connection.ip];
				this.connections.splice(i, 1);
				break;
			}
		}
		if (!this.connections.length) {
			// cleanup
			for (var i in this.roomCount) {
				if (this.roomCount[i] > 0) {
					// should never happen.
					console.log('!! room miscount: ' + i + ' not left');
					Rooms.get(i, 'lobby').onLeave(this);
				}
			}
			this.roomCount = {};
			if (!this.named && Object.isEmpty(this.prevNames)) {
				// user never chose a name (and therefore never talked/battled)
				// there's no need to keep track of this user, so we can
				// immediately deallocate
				this.destroy();
			}
		}
	};
	User.prototype.disconnectAll = function () {
		// Disconnects a user from the server
		this.clearChatQueue();
		var connection = null;
		this.markInactive();
		for (var i = this.connections.length - 1; i >= 0; i--) {
			// console.log('DESTROY: ' + this.userid);
			connection = this.connections[i];
			for (var j in connection.rooms) {
				this.leaveRoom(connection.rooms[j], connection, true);
			}
			connection.destroy();
		}
		if (this.connections.length) {
			// should never happen
			throw new Error("Failed to drop all connections for " + this.userid);
		}
		for (var i in this.roomCount) {
			if (this.roomCount[i] > 0) {
				// should never happen.
				throw new Error("Room miscount: " + i + " not left for " + this.userid);
			}
		}
		this.roomCount = {};
	};
	User.prototype.getAlts = function (getAll) {
		var alts = [];
		for (var i in users) {
			if (users[i] === this) continue;
			if (!users[i].named && !users[i].connected) continue;
			if (!getAll && users[i].confirmed) continue;
			for (var myIp in this.ips) {
				if (myIp in users[i].ips) {
					alts.push(users[i].name);
					break;
				}
			}
		}
		return alts;
	};
	User.prototype.doWithMMR = function (formatid, callback) {
		var self = this;
		var userid = this.userid;
		formatid = toId(formatid);

		// this should relieve login server strain
		// this.mmrCache[formatid] = 1000;

		if (this.mmrCache[formatid]) {
			callback(this.mmrCache[formatid]);
			return;
		}
		LoginServer.request('mmr', {
			format: formatid,
			user: userid
		}, function (data, statusCode, error) {
			if (!data) return callback(1000, error || new Error("No data received"));
			if (data.errorip) return self.popup("This server's request IP " + data.errorip + " is not a registered server.");

			var mmr = parseInt(data, 10);
			if (isNaN(mmr)) return callback(1000, error || new Error("Invalid rating"));
			if (self.userid !== userid) return callback(1000, new Error("Expired rating"));

			self.mmrCache[formatid] = mmr;
			callback(mmr, null);
		});
	};
	User.prototype.cacheMMR = function (formatid, mmr) {
		if (typeof mmr === 'number') {
			this.mmrCache[formatid] = mmr;
		} else {
			this.mmrCache[formatid] = Number(mmr.acre);
		}
	};
	User.prototype.ban = function (noRecurse, userid) {
		// recurse only once; the root for-loop already bans everything with your IP
		if (!userid) userid = this.userid;
		if (!noRecurse) {
			for (var i in users) {
				if (users[i] === this || users[i].confirmed) continue;
				for (var myIp in this.ips) {
					if (myIp in users[i].ips) {
						users[i].ban(true, userid);
						break;
					}
				}
			}
		}

		for (var ip in this.ips) {
			bannedIps[ip] = userid;
		}
		if (this.autoconfirmed) bannedUsers[this.autoconfirmed] = userid;
		if (this.registered) {
			bannedUsers[this.userid] = userid;
			this.locked = userid; // in case of merging into a recently banned account
			this.autoconfirmed = '';
		}
		this.disconnectAll();
	};
	User.prototype.lock = function (noRecurse, userid) {
		// recurse only once; the root for-loop already locks everything with your IP
		if (!userid) userid = this.userid;
		if (!noRecurse) {
			for (var i in users) {
				if (users[i] === this || users[i].confirmed) continue;
				for (var myIp in this.ips) {
					if (myIp in users[i].ips) {
						users[i].lock(true, userid);
						break;
					}
				}
			}
		}

		for (var ip in this.ips) {
			lockedIps[ip] = userid;
		}
		if (this.autoconfirmed) lockedUsers[this.autoconfirmed] = userid;
		if (this.registered) lockedUsers[this.userid] = userid;
		this.locked = userid;
		this.autoconfirmed = '';
		this.updateIdentity();
	};
	User.prototype.tryJoinRoom = function (room, connection) {
		var roomid = (room && room.id ? room.id : room);
		room = Rooms.search(room);
		if (!room) {
			if (!this.named) {
				return null;
			} else {
				connection.sendTo(roomid, "|noinit|nonexistent|The room '" + roomid + "' does not exist.");
				return false;
			}
		}
		if (room.modjoin && !this.can('bypassall')) {
			var userGroup = this.group;
			if (room.auth) {
				if (room.isPrivate === true) {
					userGroup = ' ';
				}
				userGroup = room.auth[this.userid] || userGroup;
			}
			if (Config.groupsranking.indexOf(userGroup) < Config.groupsranking.indexOf(room.modjoin !== true ? room.modjoin : room.modchat)) {
				if (!this.named) {
					return null;
				} else {
					connection.sendTo(roomid, "|noinit|nonexistent|The room '" + roomid + "' does not exist.");
					return false;
				}
			}
		}
		if (room.isPrivate) {
			if (!this.named) {
				return null;
			}
		}

		if (Rooms.aliases[toId(roomid)] === room) {
			connection.send(">" + toId(roomid) + "\n|deinit");
		}

		var joinResult = this.joinRoom(room, connection);
		if (!joinResult) {
			if (joinResult === null) {
				connection.sendTo(roomid, "|noinit|joinfailed|You are banned from the room '" + roomid + "'.");
				return false;
			}
			connection.sendTo(roomid, "|noinit|joinfailed|You do not have permission to join '" + roomid + "'.");
			return false;
		}
		return true;
	};
	User.prototype.joinRoom = function (room, connection) {
		room = Rooms.get(room);
		if (!room) return false;
		if (!this.can('bypassall')) {
			// check if user has permission to join
			if (room.staffRoom && !this.isStaff) return false;
			if (room.checkBanned && !room.checkBanned(this)) {
				return null;
			}
		}
		if (!connection) {
			for (var i = 0; i < this.connections.length; i++) {
				// only join full clients, not pop-out single-room
				// clients
				if (this.connections[i].rooms['global']) {
					this.joinRoom(room, this.connections[i]);
				}
			}
			return true;
		}
		if (!connection.rooms[room.id]) {
			connection.joinRoom(room);
			if (!this.roomCount[room.id]) {
				this.roomCount[room.id] = 1;
				room.onJoin(this, connection);
			} else {
				this.roomCount[room.id]++;
				room.onJoinConnection(this, connection);
			}
		}
		return true;
	};
	User.prototype.leaveRoom = function (room, connection, force) {
		room = Rooms.get(room);
		if (room.id === 'global' && !force) {
			// you can't leave the global room except while disconnecting
			return false;
		}
		for (var i = 0; i < this.connections.length; i++) {
			if (this.connections[i] === connection || !connection) {
				if (this.connections[i].rooms[room.id]) {
					if (this.roomCount[room.id]) {
						this.roomCount[room.id]--;
						if (!this.roomCount[room.id]) {
							room.onLeave(this);
							delete this.roomCount[room.id];
						}
					}
					if (!this.connections[i]) {
						// race condition? This should never happen, but it does.
						fs.createWriteStream('logs/errors.txt', {'flags': 'a'}).on("open", function (fd) {
							this.write("\nconnections = " + JSON.stringify(this.connections) + "\ni = " + i + "\n\n");
							this.end();
						});
					} else {
						this.connections[i].sendTo(room.id, '|deinit');
						this.connections[i].leaveRoom(room);
					}
				}
				if (connection) {
					break;
				}
			}
		}
		if (!connection && this.roomCount[room.id]) {
			room.onLeave(this);
			delete this.roomCount[room.id];
		}
	};
	User.prototype.prepBattle = function (formatid, type, connection, callback) {
		// all validation for a battle goes through here
		if (!connection) connection = this;
		if (!type) type = 'challenge';

		if (Rooms.global.lockdown && Rooms.global.lockdown !== 'pre') {
			var message = "The server is restarting. Battles will be available again in a few minutes.";
			if (Rooms.global.lockdown === 'ddos') {
				message = "The server is under attack. Battles cannot be started at this time.";
			}
			connection.popup(message);
			setImmediate(callback.bind(null, false));
			return;
		}
		if (ResourceMonitor.countPrepBattle(connection.ip || connection.latestIp, this.name)) {
			connection.popup("Due to high load, you are limited to 6 battles every 3 minutes.");
			setImmediate(callback.bind(null, false));
			return;
		}

		var format = Tools.getFormat(formatid);
		if (!format['' + type + 'Show']) {
			connection.popup("That format is not available.");
			setImmediate(callback.bind(null, false));
			return;
		}
		if (type === 'search' && this.searching[formatid]) {
			connection.popup("You are already searching a battle in that format.");
			setImmediate(callback.bind(null, false));
			return;
		}
		TeamValidator.validateTeam(formatid, this.team, this.finishPrepBattle.bind(this, connection, callback));
	};
	User.prototype.finishPrepBattle = function (connection, callback, success, details) {
		if (!success) {
			connection.popup("Your team was rejected for the following reasons:\n\n- " + details.replace(/\n/g, '\n- '));
			callback(false);
		} else {
			if (details) {
				this.team = details;
				ResourceMonitor.teamValidatorChanged++;
			} else {
				ResourceMonitor.teamValidatorUnchanged++;
			}
			callback(true);
		}
	};
	User.prototype.updateChallenges = function () {
		var challengeTo = this.challengeTo;
		if (challengeTo) {
			challengeTo = {
				to: challengeTo.to,
				format: challengeTo.format
			};
		}
		this.send('|updatechallenges|' + JSON.stringify({
			challengesFrom: Object.map(this.challengesFrom, 'format'),
			challengeTo: challengeTo
		}));
	};
	User.prototype.makeChallenge = function (user, format/*, isPrivate*/) {
		user = getUser(user);
		if (!user || this.challengeTo) {
			return false;
		}
		if (user.blockChallenges && !this.can('bypassblocks', user)) {
			return false;
		}
		if (new Date().getTime() < this.lastChallenge + 10000) {
			// 10 seconds ago
			return false;
		}
		var time = new Date().getTime();
		var challenge = {
			time: time,
			from: this.userid,
			to: user.userid,
			format: '' + (format || ''),
			//isPrivate: !!isPrivate, // currently unused
			team: this.team
		};
		this.lastChallenge = time;
		this.challengeTo = challenge;
		user.challengesFrom[this.userid] = challenge;
		this.updateChallenges();
		user.updateChallenges();
	};
	User.prototype.cancelChallengeTo = function () {
		if (!this.challengeTo) return true;
		var user = getUser(this.challengeTo.to);
		if (user) delete user.challengesFrom[this.userid];
		this.challengeTo = null;
		this.updateChallenges();
		if (user) user.updateChallenges();
	};
	User.prototype.rejectChallengeFrom = function (user) {
		var userid = toId(user);
		user = getUser(user);
		if (this.challengesFrom[userid]) {
			delete this.challengesFrom[userid];
		}
		if (user) {
			delete this.challengesFrom[user.userid];
			if (user.challengeTo && user.challengeTo.to === this.userid) {
				user.challengeTo = null;
				user.updateChallenges();
			}
		}
		this.updateChallenges();
	};
	User.prototype.acceptChallengeFrom = function (user) {
		var userid = toId(user);
		user = getUser(user);
		if (!user || !user.challengeTo || user.challengeTo.to !== this.userid || !this.connected || !user.connected) {
			if (this.challengesFrom[userid]) {
				delete this.challengesFrom[userid];
				this.updateChallenges();
			}
			return false;
		}
		Rooms.global.startBattle(this, user, user.challengeTo.format, this.team, user.challengeTo.team, {rated: false});
		delete this.challengesFrom[user.userid];
		user.challengeTo = null;
		this.updateChallenges();
		user.updateChallenges();
		return true;
	};
	// chatQueue should be an array, but you know about mutables in prototypes...
	// P.S. don't replace this with an array unless you know what mutables in prototypes do.
	User.prototype.chatQueue = null;
	User.prototype.chatQueueTimeout = null;
	User.prototype.lastChatMessage = 0;
	/**
	 * The user says message in room.
	 * Returns false if the rest of the user's messages should be discarded.
	 */
	User.prototype.chat = function (message, room, connection) {
		var now = new Date().getTime();

		if (message.substr(0, 16) === '/cmd userdetails') {
			// certain commands are exempt from the queue
			ResourceMonitor.activeIp = connection.ip;
			room.chat(this, message, connection);
			ResourceMonitor.activeIp = null;
			return false; // but end the loop here
		}

		if (this.chatQueueTimeout) {
			if (!this.chatQueue) this.chatQueue = []; // this should never happen
			if (this.chatQueue.length >= THROTTLE_BUFFER_LIMIT - 1) {
				connection.sendTo(room, '|raw|' +
					"<strong class=\"message-throttle-notice\">Your message was not sent because you've been typing too quickly.</strong>"
				);
				return false;
			} else {
				this.chatQueue.push([message, room, connection]);
			}
		} else if (now < this.lastChatMessage + THROTTLE_DELAY) {
			this.chatQueue = [[message, room, connection]];
			this.chatQueueTimeout = setTimeout(
				this.processChatQueue.bind(this),
				THROTTLE_DELAY - (now - this.lastChatMessage));
		} else {
			this.lastChatMessage = now;
			ResourceMonitor.activeIp = connection.ip;
			room.chat(this, message, connection);
			ResourceMonitor.activeIp = null;
		}
	};
	User.prototype.clearChatQueue = function () {
		this.chatQueue = null;
		if (this.chatQueueTimeout) {
			clearTimeout(this.chatQueueTimeout);
			this.chatQueueTimeout = null;
		}
	};
	User.prototype.processChatQueue = function () {
		if (!this.chatQueue) return; // this should never happen
		var toChat = this.chatQueue.shift();

		ResourceMonitor.activeIp = toChat[2].ip;
		toChat[1].chat(this, toChat[0], toChat[2]);
		ResourceMonitor.activeIp = null;

		if (this.chatQueue && this.chatQueue.length) {
			this.chatQueueTimeout = setTimeout(
				this.processChatQueue.bind(this), THROTTLE_DELAY);
		} else {
			this.chatQueue = null;
			this.chatQueueTimeout = null;
		}
	};
	User.prototype.destroy = function () {
		// deallocate user
		this.clearChatQueue();
		delete users[this.userid];
	};
	User.prototype.toString = function () {
		return this.userid;
	};
	// "static" function
	User.pruneInactive = function (threshold) {
		var now = Date.now();
		for (var i in users) {
			var user = users[i];
			if (user.connected) continue;
			if ((now - user.lastConnected) > threshold) {
				users[i].destroy();
			}
		}
	};
	return User;
})();

Connection = (function () {
	function Connection(id, worker, socketid, user, ip) {
		this.id = id;
		this.socketid = socketid;
		this.worker = worker;
		this.rooms = {};

		this.user = user;

		this.ip = ip || '';
	}
	Connection.prototype.autojoin = '';

	Connection.prototype.sendTo = function (roomid, data) {
		if (roomid && roomid.id) roomid = roomid.id;
		if (roomid && roomid !== 'lobby') data = '>' + roomid + '\n' + data;
		Sockets.socketSend(this.worker, this.socketid, data);
		ResourceMonitor.countNetworkUse(data.length);
	};

	Connection.prototype.send = function (data) {
		Sockets.socketSend(this.worker, this.socketid, data);
		ResourceMonitor.countNetworkUse(data.length);
	};

	Connection.prototype.destroy = function () {
		Sockets.socketDisconnect(this.worker, this.socketid);
		this.onDisconnect();
	};
	Connection.prototype.onDisconnect = function () {
		delete connections[this.id];
		if (this.user) this.user.onDisconnect(this);
		this.user = null;
	};

	Connection.prototype.popup = function (message) {
		this.send('|popup|' + message.replace(/\n/g, '||'));
	};

	Connection.prototype.joinRoom = function (room) {
		if (room.id in this.rooms) return;
		this.rooms[room.id] = room;
		Sockets.channelAdd(this.worker, room.id, this.socketid);
	};
	Connection.prototype.leaveRoom = function (room) {
		if (room.id in this.rooms) {
			delete this.rooms[room.id];
			Sockets.channelRemove(this.worker, room.id, this.socketid);
		}
	};

	return Connection;
})();

Users.User = User;
Users.Connection = Connection;

/*********************************************************
 * Inactive user pruning
 *********************************************************/

Users.pruneInactive = User.pruneInactive;
Users.pruneInactiveTimer = setInterval(
	User.pruneInactive,
	1000 * 60 * 30,
	Config.inactiveuserthreshold || 1000 * 60 * 60
);
