fics
Version:
A promise-based interface for the Free Internet Chess Server
931 lines (791 loc) • 27.3 kB
JavaScript
// # FICS
//
// A promise-based library for interacting with the Free Internet Chess Server
var net = require("net");
var stream = require("stream");
var util = require("util");
var EventEmitter = require("events").EventEmitter;
var Q = require("q");
var _ = require("underscore");
var ficsHost = "freechess.org";
var ficsPort = 5000;
var ficsPrompt = "fics%";
// ## FICSClient
//
// The main object for interacting with the FICS server. Creates a new
// connection and handles all command processing. The client is an instance
// of EventEmitter that will forward all events from the raw socket, less the
// data event. This is useful for reconnecting in the event of a socket
// timeout.
//
// ```
// var FICSClient = require("fics");
// var fics = new FICSClient();
// ```
//
// @constructor
var FICSClient = function() {
this.socket = net.connect({ port: ficsPort, host: ficsHost });
this.commandQueue = [];
this.deferredData = this.wrapSocket();
};
util.inherits(FICSClient, EventEmitter);
// ## on
//
// An overridden on function that attaches handlers to the internal socket,
// except in the case of the data event.
//
// @public
// @return {FICSClient} Returns `this` for chaining.
FICSClient.prototype.on = function(event) {
if (event === "data") {
return;
}
this.socket.on.apply(this.socket, arguments);
return this;
};
// ### end
//
// Clears keep alive timeout, then removes all listeners from and ends the
// connection to the FICS server.
//
// @public
FICSClient.prototype.end = function() {
clearTimeout(this.keepAliveTimeoutId);
this.socket.removeAllListeners().end();
};
// ### getStream
//
// A readable stream of all data from the socket connection to the FICS server.
// This should only be used for debugging and logging.
//
// @public
// @return {stream.Readable} A stream of the raw data from socket
FICSClient.prototype.getStream = function() {
if (!this.stream) {
this.stream = new stream.Readable().wrap(this.socket);
}
return this.stream;
};
// ### login
//
// Logs in a user based on the provided data. Empty data will log the user in
// as a guest.
//
// The returned promise will fail if there is a failure logging in.
//
// @public
// @param {object} userData Hash with login and password keys
// @return {Promise} promise that will resolve with the user login information
FICSClient.prototype.login = function(userData) {
if (userData && userData.login) {
var username = userData.login;
var password = userData.password;
} else {
var username = "guest";
}
var match = null;
var serverUsername;
var self = this;
var deferredLogin = this.lines(function(data) {
if (data.match(/^login:/)) {
self.sendMessage(username);
}
if (data.match(/^password:/)) {
self.sendMessage(password);
}
if (data.match(/^Press return/)) {
self.sendMessage("");
}
if (match = data.match(/^\*{4} Invalid password! \*{4}$/)) {
deferredLogin.reject(new Error("Invalid Password"));
}
if (match = data.match(/^\*{4} Starting FICS session as (.*) \*{4}$/)) {
serverUsername = match[1];
self.keepAlive();
}
if (data.match(/^fics%$/)) {
self.issueCommand("set prompt");
self.issueCommand("set seek 0");
self.issueCommand("set style 12");
deferredLogin.resolve({ username: serverUsername });
}
}, false);
return deferredLogin.promise;
};
// ### chat
//
// Returns a promise that will notify with any shouts or tells that are
// received. This promise does not resolve automatically; the caller is
// expected to discard it.
//
// Notifications appear in the following format:
// ```
// { type: {string} [it|shout|tell]
// , user: {string} usernameOfSender
// , message: {string} messageBody
// , channel: {string} [channelIfTellToChannel] }
// ```
//
// @public
// @returns {Promise} Will notify as new messages are received.
FICSClient.prototype.chat = function() {
var match = null;
var deferredChat = Q.defer();
this.lines(function(line) {
if (match = line.match(/^--> (\S+) (.*)$/)) {
deferredChat.notify({ type: "it", user: match[1], message: match[2] });
}
if (match = line.match(/^(\S+) shouts: (.*)$/)) {
deferredChat.notify({ type: "shout", user: match[1], message: match[2] });
}
if (match = line.match(/^(\S+) tells you: (.*)$/)) {
deferredChat.notify({ type: "tell", user: match[1], message: match[2] });
}
if (match = line.match(/^(\S+)\((\d+)\): (.*)$/)) {
deferredChat.notify({ type: "tell", user: match[1]
, channel: match[2]
, message: match[3]
});
}
});
return deferredChat.promise;
};
// ### channelList
//
// Returns a promise that will resolve with a hash of channel data in the
// format of:
//
// ```
// [{ number: {string} channelNumber
// , name: {string} channelName
// }
// , ...
// ]
// ```
//
// @public
// @return {Promise} The promise to be resolved with channel data
FICSClient.prototype.channelList = function() {
var self = this;
var channels = [];
var match = null;
var deferredChannels = this.issueBlockingCommand("help channel_list", function(data) {
if (data.match(/^Type \[next\] to see next page\.$/)) {
self.sendMessage("next");
}
if (data.match(/^Last Modified/)) {
deferredChannels.resolve(channels);
}
if (match = data.match(/^(\d+(?:,\d+)*)\s+(.*)$/)) {
_.each(match[1].split(","), function(channelNumber) {
channels.push({ number: channelNumber, name: match[2]});
});
}
});
return deferredChannels.promise;
};
// ### channels
//
// Retrieve a list of channels to which the logged in user is currently
// subscribed, returned as an array of strings representing the channel numbers
//
// ```
// [ {string} channelNumber, ... ]
// ```
//
// @public
// @return {Promise} A promise to be resolved
FICSClient.prototype.channels = function() {
var channels = [];
var match = null;
var deferredChannels = this.issueCommand("=channel", function(data) {
if (match = data.match(/^((\d+)(\s+)?)+$/)) {
deferredChannels.resolve(match[0].split(/\s+/));
}
});
return deferredChannels.promise;
};
// ### joinChannel
//
// Adds the channel to the user's channel list.
//
// @public
// @param {number|string} The channel to join.
// @return {Promise} A promise that will be resolved with `true` if the channel
// was successfully added or `false` if it was already in the
// user's channel list
FICSClient.prototype.joinChannel = function(channelNumber) {
var channel = channelNumber.toString();
var match = null;
var deferredJoinChannel = this.issueCommand("+channel " + channel, function(data) {
if (match = data.match(new RegExp("^\\[" + channel + "\\] added to your channel list\\.$"))) {
deferredJoinChannel.resolve(true);
}
if (match = data.match(new RegExp("^\\[" + channel + "\\] is already on your channel list\\.$"))) {
deferredJoinChannel.resolve(false);
}
});
return deferredJoinChannel.promise;
};
// ### leaveChannel
//
// Removes the channel from the user's channel list.
//
// @public
// @param {number|string} The channel to leave.
// @return {Promise} A promise that will be resolved with `true` if the channel
// was successfully removed or `false` if it was not in the
// user's channel list
FICSClient.prototype.leaveChannel = function(channelNumber) {
var channel = channelNumber.toString();
var match = null;
var deferredLeaveChannel = this.issueCommand("-channel " + channel, function(data) {
if (match = data.match(new RegExp("^\\[" + channel + "\\] removed from your channel list\\.$"))) {
deferredLeaveChannel.resolve(true);
}
if (match = data.match(new RegExp("^\\[" + channel + "\\] is not in your channel list\\.$"))) {
deferredLeaveChannel.resolve(false);
}
});
return deferredLeaveChannel.promise;
};
// ### tell
//
// Broadcast a message to a user or a channel.
//
// @public
// @param {number|string} recipient The channel number or username
// @param {string} message The message to send
// @return {Promise} A promise that will be resolved with `true` if the message
// is successfully sent and `false` otherwise.
FICSClient.prototype.tell = function(recipient, message) {
recipient = recipient.toString();
var deferredTell = this.issueCommand(["tell", recipient, message].join(" "), function(data) {
if (data.match(/^The range of channels is 0 to 255\.$/) ||
data.match(/^Only registered users may send tells to channels other than 4, 7 and 53\.$/) ||
data.match(new RegExp("^Only .* may send tells to channel " + recipient + "\\.$")) ||
data.match(new RegExp("^'" + recipient + "' is not a valid handle\\.$"))) {
deferredTell.resolve(false);
}
if (data.match(new RegExp("^\\(told " + recipient + "\\).*$")) ||
data.match(new RegExp("^\\(told \\d+ players in channel " + recipient + "(?:\\s+\".*\")?\\).*$"))) {
deferredTell.resolve(true);
}
});
return deferredTell.promise;
};
// ### shout
//
// Broadcast a message globally to all users listening to shouts.
//
// @public
// @param {string} message The message to send
// @param {boolean} [it] Whether to broadcast as an `it` message, a special
// kind of shout. Defaults to `false`.
// @return {Promise} A promise that will be resolved with `true` if the message
// is successfully sent and `false` otherwise.
FICSClient.prototype.shout = function(message, it) {
var command = it ? "it" : "shout";
var deferredShout = this.issueCommand([command, message].join(" "), function(data) {
if (data.match(/^Only registered players can use the shout command\.$/)) {
deferredShout.resolve(false);
}
if (data.match(/^\(shouted to \d+ players\)$/)) {
deferredShout.resolve(true);
}
});
return deferredShout.promise;
};
// ### who
//
// Returns a promise that will be resolved with users in the following format.
//
// ```
// [{ name: {string} userName
// , rating: {string} userRating
// , status: {string} userStatus
// , codes: {array} server
// }
// ,...
// ]
// ```
//
// @public
// @return {Promise} To be resolved with user data.
FICSClient.prototype.who = function() {
var users = [];
var match = null;
var deferredUsers = this.issueBlockingCommand("who", function(data) {
_.each(data.split(/\s{2,}/), function(datum) {
if (match = datum.match(/^(\d+|[+-]{4})([\^~:#'&. ])(\w+)((?:\([*A-Z]+\))*)$/)) {
var codes = [];
if (match[4]) {
codes = match[4].substr(1, match[4].length - 2).split(")(");
}
users.push({ name: match[3]
, rating: match[1]
, status: match[2]
, codes: codes });
}
});
if (data.match(/^\d+ players displayed \(of \d+\)\. \(\*\) indicates system administrator\.$/)) {
deferredUsers.resolve(users);
}
});
return deferredUsers.promise;
};
// ### games
//
// Returns a promise that will resolved with an array of data about current
// games on the server in the format:
//
// ```
// [{ number: {string} gameNumber
// , white: { name: {string} userName, rating: {string} userRating: time: {string} timeRemaining }
// , black: { name: {string} userName, rating: {string} userRating: time: {string} timeRemaining }
// , move: { color: {string} colorToMove, number: {string} moveNumber }
// }
// , ...
// ]
// ```
//
// Currently does not capture games being examined and, e.g. lectures by
// LectureBot, so the length of the list is shorter than the length returned
// by the server.
//
// @public
// @return {Promise} The promise to be resolved with game data
FICSClient.prototype.games = function() {
var games = [];
var match = null;
var deferredGames = this.issueBlockingCommand("games", function(data) {
if (match = data.match(/^(\d+)\s+(\d+|\+{4})\s+(\w+)\s+(\d+|\+{4})\s+(\w+)\s+\[.*\]\s+((?:\d+:)?\d+:\d+)\s+-\s+((?:\d+:)?\d+:\d+)\s+\(.*\)\s+(W|B):\s+(\d+)$/)) {
games.push({ number: match[1]
, white: { name: match[3], rating: match[2], time: match[6] }
, black: { name: match[5], rating: match[4], time: match[7] }
, move: { color: match[8], number: match[9] }
});
}
if (data.match(/^\d+ games displayed.$/)) {
deferredGames.resolve(games);
}
});
return deferredGames.promise;
};
// ### observe
//
// Observe a game currently in progress. The promise is notified of three
// events: the initial data for the game, updates as the game progresses, and
// any messages that are sent during the game.
//
// #### initial data
// ```
// { white: { name: {string} userName, rating: {string} userRating }
// , black: { name: {string} userName, rating: {string} userRating }
// , rated: {boolean} isRated
// , type: {string} gameType
// , time: { initial: {string} clockInitial
// , increment: {string} clockIncrement
// } }
// ```
//
// #### game updates
// ```
// { position: {string} fenPosition
// , current: { color: {string} currentMoveColor
// , move: {string} currentMoveNumber
// }
// , time: { white: {string} whiteTimeInSeconds
// , black: {string} blackTimeInSeconds
// }
// , move: { verbose: {string} verboseLastMove
// , algebraic: {string} algebraicLastMove
// } }
// ```
//
// #### messages
// ```
// { user: {string} userName
// , message: {string} messageText
// , type: {string} kibitzOrWhisper }
// ```
//
// #### result
// ```
// { result: {string} gameResult }
// ```
//
// The promise will resolve after the game has been removed from the user's
// oberservation list, either by being closed or by manually `unobserve`ing it.
//
// @public
// @param {number|string} gameNumber Number of game to observe
// @return {Promise} A promise that will notify with game updates
FICSClient.prototype.observe = function(gameNumber) {
var game = gameNumber.toString();
var match = null;
var deferredObservation = this.issueCommand("observe " + game, function(data) {
var rating = " \\((\\d+|\\+{4})\\) ";
var user = "(\\w+)";
var newGame = new RegExp("^Game " + game + ": " + user + rating + user + rating + "((?:un)?rated) (\\w+) (\\d+) (\\d+)$");
if (match = data.match(newGame)) {
deferredObservation.notify({ white: { name: match[1], rating: match[2] }
, black: { name: match[3], rating: match[4] }
, rated: match[5] === "rated"
, type: match[6]
, time: { initial: match[7], increment: match[8] }
});
}
var gameUpdate = new RegExp("^<\\d+> ((?:[-pPrRnNbBqQkK]{8}\\s?){8}) (W|B) (?:-?\\d+ ){6}" + game + " \\w+ \\w+ " +
"(?:\\d+ ){5}(\\d+) (\\d+) (\\d+) ([RNBQKP]\/[a-h][1-8]-[a-h][1-8]) \\(\\d+:\\d+\\) (.+)(?:\\s+\\d+){3}$");
if (match = data.match(gameUpdate)) {
deferredObservation.notify({ position: ranks2fen(match[1])
, current: { color: match[2], move: match[5] }
, time: { white: match[3], black: match[4] }
, move: { verbose: match[6], algebraic: match[7] }
});
}
if (match = data.match(new RegExp("^(.*)\\[" + game + "\\] (kibitzes|whispers): (.*)$"))) {
deferredObservation.notify({ user: match[1]
, message: match[3]
, type: (match[2] === "kibitzes") ? "kibitz" : "whisper"
});
}
if (match = data.match(new RegExp("^{Game " + game + " \\(\\w+ vs. \\w+\\) (?:\\w+\\s?)+} (.*)$"))) {
deferredObservation.notify({ result: match[1] });
}
if (data.match(new RegExp(["^Removing game", game, "from observation list\\.$"].join(' ')))) {
deferredObservation.resolve();
}
});
return deferredObservation.promise;
};
// ### moves
//
// Returns a promise to be resolved with the moves for a given game. The
// structure of the moves is an array of tuple arrays, e.g.
//
// ```
// [ [{string} whiteMove, {string} blackMove]
// , ...
// , [{string} whitheMove]
// ]
// ```
//
// @public
// @param {number|string} gameNumber Number of the game
// @return {Promise} A promise that will return with the moves of the game
FICSClient.prototype.moves = function(gameNumber) {
var game = gameNumber.toString();
var moves = [];
var match = null;
var deferredMoves = this.issueCommand("moves " + game, function(data) {
if (match = data.match(/^\d+\.\s+([RNBQKPa-h1-8Ox-]+)\s+\(\d+:\d+\)(?:\s+([RNBQKPa-h1-8Ox-]+)?\s+\(\d+:\d+\))?$/)) {
if (match[2]) {
moves.push([match[1], match[2]]);
} else {
moves.push([match[1]]);
}
}
if (match = data.match(/^{Still in progress} \*$/)) {
deferredMoves.resolve(moves);
}
});
return deferredMoves.promise;
};
// ### observers
//
// Get a list of all the observers currently watching a game.
//
// @public
// @param {number|string} gameNumber Number of the game
// @return {Promise} A promise that will return with the current observers
FICSClient.prototype.observers = function(gameNumber) {
var game = gameNumber.toString();
var deferredObservers = this.issueCommand("allobservers " + game, function(data) {
var match = data.match(new RegExp("^Observing " + gameNumber + " \\[.*\\]:\\s+((.*\\s)+)\\(\\d+ users\\)$"));
if (match) {
deferredObservers.resolve(match[1].trim().split(/\s+/));
}
});
return deferredObservers.promise;
};
// ### kibitz
//
// Send a message to all observers and players of a game.
//
// @public
// @param {string|number} gameNumber Number of the game
// @param {string} message The message to be broadcast
// @return {Promise} Resolved after message is sent.
FICSClient.prototype.kibitz = function(gameNumber, message) {
var game = gameNumber.toString();
var deferredKibitz = this.issueCommand(["xkibitz", game, message].join(" "), function(data) {
if (data.match(/^\(kibitzed to \d+ players?\)$/)) {
deferredKibitz.resolve(true);
}
});
return deferredKibitz.promise;
};
// ### whisper
//
// Send a message to all observers of a game.
//
// @public
// @param {string|number} gameNumber Number of the game
// @param {string} message The message to be broadcast
// @return {Promise} Resolved after message is sent.
FICSClient.prototype.whisper = function(gameNumber, message) {
var game = gameNumber.toString();
var deferredWhisper = this.issueCommand(["xwhisper", game, message].join(" "), function(data) {
if (data.match(/^\(whispered to \d+ players?\)$/)) {
deferredWhisper.resolve(true);
}
});
return deferredWhisper.promise;
};
// ### unobserve
//
// Stop observing a game.
//
// @public
// @param {number|string} gameNumber Number of the game
// @return {Promise} A promise that will resolve with `true` if the game was
// removed from the observation list or `false` if it was not
// in the observation list
FICSClient.prototype.unobserve = function(gameNumber) {
var game = gameNumber.toString();
var deferredUnobserve = this.issueCommand("unobserve " + game, function(data) {
if (data.match(new RegExp("^Removing game " + game + " from observation list\\.$"))) {
deferredUnobserve.resolve(true);
}
if (data.match(/^You are not observing any games\.$/) || data.match(new RegExp("^You are not observing game " + game + "\\."))) {
deferredUnobserve.resolve(false);
}
});
return deferredUnobserve.promise;
};
// ### sought
//
// Get an objecting representing all the games currently awaiting players.
//
// The games will be presented in the following format:
//
// ```
// [{ number: {string} gameNumber
// , user: { name: {string} userName, rating: {string} userRating }
// , time: { initial: {string} clockInitial, increment: {string} clockIncrement }
// , rated: {boolean} isRated
// , type: {string} gameType
// , range: {string} allowedRatingRange
// }
// , ...
// ]
// ```
//
// @public
// @return {Promise} A promise that will resolve with the structure of games.
FICSClient.prototype.sought = function() {
var games = [];
var match = null;
var deferredSought = this.issueCommand("sought", function(data) {
if (match = data.match(/^\s*(\d*)\s+(\d*|\+{4})\s+(\w+(?:\(C\))?)\s+(\d+)\s+(\d+) ((?:un)?rated)\s+([\w/]+)\s+(\d+-\d+)\s?\w*$/)) {
games.push({ game: match[1]
, user: { name: match[3], rating: match[2] }
, time: { initial: match[4], increment: match[5] }
, rated: match[6] === "rated"
, type: match[7]
, range: match[8]
});
}
if (data.match(/^\d+ ads displayed\.$/)) {
deferredSought.resolve(games);
};
});
return deferredSought.promise;
};
// ### keepAlive
//
// Call the `uptime` command every 59 minutes to keep the connection to the
// server alive and prevent being kicked due to inactivity (espeically useful
// when observing games)
//
// @private
FICSClient.prototype.keepAlive = function() {
var self = this;
this.keepAliveTimeoutId = setTimeout(function() {
var deferredUptime = self.issueCommand("uptime", function() {
deferredUptime.resolve();
});
self.keepAlive();
}, 59 * 60 * 1000);
};
// ### wrapSocket
//
// Creates a deffered object that processes raw data from the socket
// and notifies any promises created therefrom with each line of data.
//
// This function also handles the joining of lines into logical lines before
// notifying the promise, i.e. combining output that spans over multiple lines.
//
// @private
// @return {Deferred} A deferred object wrapping socket data output
FICSClient.prototype.wrapSocket = function() {
var bufferedData = "";
var deferredData = Q.defer();
this.socket.on("data", function(data) {
var data = data.toString();
var lines = logicalLines((bufferedData + data).split("\n"));
if (data[data.length - 1] !== "\n" && data.substr(-2, 2) !== ": ") {
bufferedData = lines.pop();
}
_.each(lines, function(line) {
deferredData.notify(line.trim());
});
});
return deferredData;
function logicalLines(lines) {
return _.reduce(lines, joinContinuationLines(lines), []);
}
function joinContinuationLines(rawLines) {
return function(memo, line, i) {
if (isContinuation(line.trim())) {
return memo;
};
var continueAppend = true;
var combined = _.reduce(_.rest(rawLines, i + 1), function(memo, line) {
var trimmedLine = line.trim();
if (continueAppend && isContinuation(trimmedLine)) {
memo.push(trimmedLine.substr(1).trim());
} else {
continueAppend = false;
}
return memo;
}, [line.trim()]);
memo.push(combined.join(" "));
return memo;
};
}
function isContinuation(line) {
return line.substr(0, 1) === "\\";
}
};
// ### lines
//
// Creates a new promise and then feeds each line of input to the provided
// callback. This allows a command to process the stream line-by-line until it
// determines that the promise can be discarded.
//
// @private
// @param {function} callback A callback that will be attached to the promise
// @param {boolean} [doRemovePrompt] Whether or not to remove the FICS prompt
// when found at the beginning of a line
// @return {Deferred} The promise with attached callback
FICSClient.prototype.lines = function(callback, doRemovePrompt) {
if (arguments.length === 1) {
doRemovePrompt = true;
}
var deferredLines = Q.defer();
var lineNotifier = _.compose(deferredLines.notify, removePrompt)
deferredLines.promise.progress(callback);
this.deferredData.promise.progress(lineNotifier);
return deferredLines;
function removePrompt(line) {
if (doRemovePrompt) {
return line.replace(new RegExp("^" + ficsPrompt + "\\s*"), "");
} else {
return line;
}
}
};
// ### issueCommand
//
// Sends a commands to the FICS server and receive output line by line. If no
// callback is provided, the command will execute and the returned promise will
// be resolved immediately.
//
// @private
// @param {string} command The text of the command
// @param {function} [callback] An optional callback function to process lines
// @return {Deferred} The deferred object to be resolved
FICSClient.prototype.issueCommand = function(command, callback) {
var deferred = Q.defer();
var deferredLines = this.lines(callback || function() {
deferred.resolve();
});
this.sendMessage(command);
deferred.promise.then(deferredLines.resolve, deferredLines.resolve);
return deferred;
};
// ### issueBlockingCommand
//
// Issues a command, but enqueues it if another blocking command is already
// running, thus preventing issues with collisions in regular expressions.
// Other commands will continue to run uninterrupted.
//
// @private
// @param {string} command The text of the command
// @param {function} A callback function to process lines
// @return {Deferred} The deferred object that needs to be resolved before the
// next command will be run.
FICSClient.prototype.issueBlockingCommand = function(command, callback) {
var deferred = Q.defer();
var self = this;
this.commandQueue.push(function() {
var deferredCommand = self.issueCommand(command, callback);
deferred.promise.then(function(data) {
deferredCommand.resolve();
self.commandQueue.shift();
if (self.commandQueue.length > 0) {
self.commandQueue[0]();
}
});
});
if (this.commandQueue.length === 1) {
this.commandQueue[0]();
}
return deferred;
};
// ### sendMessage
//
// sends a message with the approriate encoding and termination character
//
// @private
// @param {string} message a Message to send to the FICS server
FICSClient.prototype.sendMessage = function(message) {
this.socket.write(message + "\r\n", "utf8");
};
// export the class
module.exports = FICSClient;
// ### ranks2fen
//
// Takes a position string like returned by FICS and transforms it into a FEN.
//
// e.g.
// from:
// `--Q----- -p---pkp p-----p- ----q--- P-p----- -----r-P ---R--PK --------`
// to:
// `2Q5/1p3pkp/p5p1/4q3/P1p5/5r1P/3R2PK/8`
//
// @private
// @param {string} str A FICS position
// @return {string} A FEN string
function ranks2fen(str) {
var ranks = str.split(/\s+/);
return _.map(ranks, function(rank) {
var newRank = "";
for (var i = 0, count = 0; i < 8; i++) {
var letter = rank[i];
if (letter === "-") {
count++;
letter = (i === 7) ? count.toString() : "";
} else if (count > 0) {
newRank += count.toString();
count = 0;
}
newRank += letter;
}
return newRank;
}).join("/");
};