{createHmac} = require 'crypto' {_} = require 'underscore' Limiter = require 'ratelimiter' {User} = require('./user') {BattleQueue} = require './queue' {UserStore} = require './user_store' async = require('async') gen = require './generations' auth = require('./auth') learnsets = require '../shared/learnsets' {Conditions, SelectableConditions, Formats, DEFAULT_FORMAT} = require '../shared/conditions' ConditionsFunc = require '../shared/conditions' pbv = require '../shared/pokebattle_values' config = require './config' errors = require '../shared/errors' redis = require('./redis') alts = require './alts' achievements = require './achievements' FIND_BATTLE_CONDITIONS = [ Conditions.RATED_BATTLE Conditions.TIMED_BATTLE Conditions.SLEEP_CLAUSE Conditions.EVASION_CLAUSE Conditions.SPECIES_CLAUSE Conditions.PRANKSTER_SWAGGER_CLAUSE Conditions.OHKO_CLAUSE Conditions.UNRELEASED_BAN ] FIND_BATTLE_CONDITIONS_UNRANKED = [ Conditions.TIMED_BATTLE Conditions.SLEEP_CLAUSE Conditions.EVASION_CLAUSE Conditions.SPECIES_CLAUSE Conditions.PRANKSTER_SWAGGER_CLAUSE Conditions.OHKO_CLAUSE ] MAX_NICKNAME_LENGTH = 16 class @BattleServer constructor: -> @queues = {} @unrankedqueues = {} allformats = ConditionsFunc.Formats() for format of allformats @queues[format] = new BattleQueue() @unrankedqueues[format] = new BattleQueue() @unrankedqueues[format].setUnranked() @battles = {} # A hash mapping users to battles. @userBattles = {} # same as user battles, but indexed by name and does not include alts @visibleUserBattles = {} # A hash mapping user ids to challenges # challenges[challengeeId][challengerId] = {generation: 'xy', team: []} @challenges = {} # A hash mapping ids to users @users = new UserStore() @rooms = [] # rate limiters @limiters = {} # Battles can start. @unlockdown() hasRoom: (roomId) -> !!@getRoom(roomId) getRoom: (roomId) -> _.find(@rooms, (room) -> room.name == roomId) # Creates a new user or finds an existing one, and adds a spark to it findOrCreateUser: (json, spark) -> user = @users.get(json.name) user = @users.add(json, spark) user getUser: (userId) -> @users.get(userId) join: (spark) -> @showTopic(spark) for battleId of @userBattles[spark.user.name] battle = @battles[battleId] battle.add(spark) battle.sendRequestTo(spark.user.name) battle.sendUpdates() @limiters[spark.user.id] ?= {} return spark leave: (spark) -> for room in @rooms room.remove(spark) @users.remove(spark) return if spark.user.hasSparks() delete @limiters[spark.user.id] @stopChallenges(spark.user) showTopic: (player) -> redis.hget "topic", "main", (err, topic) -> player.send('topic', topic) if topic registerChallenge: (player, challengeeId, format, team, conditions, altName) -> if @isLockedDown() errorMessage = "The server is locked. No new battles can start at this time." player.error(errors.PRIVATE_MESSAGE, challengeeId, errorMessage) return false else if !@users.contains(challengeeId) errorMessage = "This user is offline." player.error(errors.PRIVATE_MESSAGE, challengeeId, errorMessage) return false else if player.name == challengeeId errorMessage = "You cannot challenge yourself." player.error(errors.PRIVATE_MESSAGE, challengeeId, errorMessage) return false else if @challenges[player.name]?[challengeeId] || @challenges[challengeeId]?[player.name] errorMessage = "A challenge already exists between you two." player.error(errors.PRIVATE_MESSAGE, challengeeId, errorMessage) return false # Do not allow rated battles or other unallowed conditions. if _.difference(conditions, SelectableConditions).length > 0 player.error(errors.FIND_BATTLE, 'This battle cannot have certain conditions.') return false err = @validateTeam(team, format, conditions) if err.length > 0 # TODO: Use a modal error instead player.error(errors.FIND_BATTLE, err) return false @challenges[player.name] ?= {} @challenges[player.name][challengeeId] = {format, team, conditions, challengerName: player.name, altName} challengee = @users.get(challengeeId) challengee.send("challenge", player.name, format, conditions) return true acceptChallenge: (player, challengerId, team, altName) -> if !@challenges[challengerId]?[player.name]? errorMessage = "The challenge no longer exists." player.error(errors.PRIVATE_MESSAGE, challengerId, errorMessage) return null challenge = @challenges[challengerId][player.name] err = @validateTeam(team, challenge.format, challenge.conditions) if err.length > 0 # TODO: Use a modal error instead player.error(errors.FIND_BATTLE, err) return null teams = [ { id: challengerId, name: challenge.altName || challenge.challengerName, team: challenge.team, ratingKey: alts.uniqueId(challengerId, challenge.altName) } { id: player.name, name: altName || player.name, team: team, ratingKey: alts.uniqueId(player.name, altName) } ] id = @createBattle(challenge.format, teams, challenge.conditions) challenger = @users.get(challengerId) challenger.send("challengeSuccess", player.name) player.send("challengeSuccess", challengerId) delete @challenges[challengerId][player.name] return id rejectChallenge: (player, challengerId) -> if !@challenges[challengerId]?[player.name]? errorMessage = "The challenge no longer exists." player.error(errors.PRIVATE_MESSAGE, challengerId, errorMessage) return false delete @challenges[challengerId][player.name] player.send("rejectChallenge", challengerId) challenger = @users.get(challengerId) challenger.send("rejectChallenge", player.name) cancelChallenge: (player, challengeeId) -> if !@challenges[player.name]?[challengeeId]? errorMessage = "The challenge no longer exists." player.error(errors.PRIVATE_MESSAGE, challengeeId, errorMessage) return false delete @challenges[player.name][challengeeId] player.send("cancelChallenge", challengeeId) challengee = @users.get(challengeeId) challengee.send("cancelChallenge", player.name) stopChallenges: (player) -> playerId = player.name for challengeeId of @challenges[playerId] @cancelChallenge(player, challengeeId) delete @challenges[playerId] for challengerId of @challenges if @challenges[challengerId][playerId] @rejectChallenge(player, challengerId) # Adds the player to the queue. Note that there is no validation on whether altName # is correct, so make queuePlayer: (playerId, team, format = DEFAULT_FORMAT, altName) -> if @isLockedDown() err = ["The server is restarting after all battles complete. No new battles can start at this time."] else if format != DEFAULT_FORMAT # TODO: Implement ratings for other formats err = ["The server doesn't support this ladder at this time. Please ask for challenges instead."] else err = @validateTeam(team, format, FIND_BATTLE_CONDITIONS) if err.length == 0 name = @users.get(playerId).name ratingKey = alts.uniqueId(playerId, altName) @queues[format].add(playerId, altName || name, team, ratingKey) return err queuedPlayers: (format = DEFAULT_FORMAT) -> @queues[format].queuedPlayers() removePlayer: (playerId, format = DEFAULT_FORMAT) -> return false if format not of @queues @queues[format].remove(playerId) return true beginBattles: (next) -> allformats = ConditionsFunc.Formats() array = for format in Object.keys(allformats) do (format) => (callback) => @queues[format].pairPlayers (err, pairs) => if err then return callback(err) # Create a battle for each pair battleIds = [] for pair in pairs id = @createBattle(format, pair, FIND_BATTLE_CONDITIONS) battleIds.push(id) callback(null, battleIds) async.parallel array, (err, battleIds) -> return next(err) if err next(null, _.flatten(battleIds)) return true ######################################################################################### # Adds the player to the queue. Note that there is no validation on whether altName # is correct, so make queuePlayerunranked: (playerId, team, format = DEFAULT_FORMAT, altName) -> if @isLockedDown() err = ["The server is restarting after all battles complete. No new battles can start at this time."] else err = @validateTeam(team, format, FIND_BATTLE_CONDITIONS) if err.length == 0 name = @users.get(playerId).name ratingKey = alts.uniqueId(playerId, altName) @unrankedqueues[format].add(playerId, altName || name, team, ratingKey) return err queuedPlayersunranked: (format = DEFAULT_FORMAT) -> @unrankedqueues[format].queuedPlayers() removePlayerunranked: (playerId, format = DEFAULT_FORMAT) -> return false if format not of @unrankedqueues @unrankedqueues[format].remove(playerId) return true beginBattlesunranked: (next) -> allformats = ConditionsFunc.Formats() array = for format in Object.keys(allformats) do (format) => (callback) => @unrankedqueues[format].pairPlayers (err, pairs) => if err then console.log(err) if err then return callback(err) # Create a battle for each pair battleIds = [] for pair in pairs id = @createBattle(format, pair, FIND_BATTLE_CONDITIONS_UNRANKED) battleIds.push(id) callback(null, battleIds) async.parallel array, (err, battleIds) -> return next(err) if err next(null, _.flatten(battleIds)) return true ######################################################################################### # Creates a battle and returns its battleId createBattle: (rawFormat = DEFAULT_FORMAT, pair = [], conditions = []) -> allformats = ConditionsFunc.Formats() format = allformats[rawFormat] generation = format.generation conditions = conditions.concat(format.conditions) {Battle} = require("../server/#{generation}/battle") {BattleController} = require("../server/#{generation}/battle_controller") playerIds = pair.map((user) -> user.name) battleId = @generateBattleId(playerIds) battle = new Battle(battleId, pair, format: rawFormat, conditions: _.clone(conditions)) @battles[battleId] = new BattleController(battle) for player in pair # Add user to spectators # TODO: player.id should be using player.name, but alts present a problem. user = @users.get(player.id) battle.add(spark) for spark in user.sparks # Add/remove player ids to/from user battles @userBattles[player.id] ?= {} @userBattles[player.id][battleId] = true # Add the player to the list if its not an alt if player.id == player.ratingKey # hacky - but no alternative right now @visibleUserBattles[player.id] ?= {} @visibleUserBattles[player.id][battleId] = true battle.once 'end', @removeUserBattle.bind(this, player.id, player.name, battleId) battle.once 'expire', @removeBattle.bind(this, battleId) # Add the battle to the achievements system # Uneligible battles are ignored by this function achievements.registerBattle(this, battle) @rooms.push(battle) @battles[battleId].beginBattle() battleId # Generate a random ID for a new battle. generateBattleId: (players) -> hmac = createHmac('sha1', config.SECRET_KEY) hmac.update((new Date).toISOString()) for id in players hmac.update(id) hmac.digest('hex') # Returns the battle with battleId. findBattle: (battleId) -> @battles[battleId] getUserBattles: (userId) -> (id for id, value of @userBattles[userId]) # Returns all non-alt battles the user is playing in getVisibleUserBattles: (username) -> (id for id, value of @visibleUserBattles[username]) getOngoingBattles: -> # TODO: This is very inefficient. Improve this. _.chain(@battles).values().reject((b) -> b.battle.isOver()).value() removeUserBattle: (userId, username, battleId) -> delete @userBattles[userId][battleId] delete @visibleUserBattles[username]?[battleId] removeBattle: (battleId) -> for room, i in @rooms if room.name == battleId @rooms.splice(i, 1) break delete @battles[battleId] # A length of -1 denotes a permanent ban. ban: (username, reason, length = -1) -> auth.ban(username, reason, length) if user = @users.get(username) user.error(errors.BANNED, reason, length) user.close() unban: (username, next) -> auth.unban(username, next) mute: (username, reason, length) -> auth.mute(username, reason, length) unmute: (username) -> auth.unmute(username) announce: (message) -> for room in @rooms room.announce("warning", message) userMessage: (user, room, message) -> isAllowed = @checkMessage(message) return if !isAllowed @runIfUnmuted user, room.name, -> room.userMessage(user, message) runIfUnmuted: (user, roomId, next) -> auth.getMuteTTL user.name, (err, ttl) -> if ttl == -2 next() else user.announce(roomId, 'warning', "You are muted for another #{ttl} seconds!") checkMessage: (message) -> #No links, unless within p-insurgence domain if message.match(/.*p-insurgence\.com.*/) return true if message.match(/[a-zA-Z0-9]{1,100}\.[a-zA-Z0-9]{1,100}/) return false else return true setAuthority: (user, newAuthority) -> user = @users.get(user) if user not instanceof User user.authority = newAuthority if user limit: (player, kind, options, next) -> attributes = max: options.max duration: options.duration id: player.id db: redis @limiters[player.id][kind] ?= new Limiter(attributes) @limiters[player.id][kind].get(next) lockdown: -> @canBattlesStart = false for user in @users.getUsers() @stopChallenges(user) @announce("The server is restarting! We're waiting for all battles to finish to push some updates. No new battles may start at this time.") unlockdown: -> @canBattlesStart = true @announce("Battles have been unlocked! You may battle again.") isLockedDown: -> !@canBattlesStart # Returns an empty array if the given team is valid, an array of errors # otherwise. validateTeam: (team, format = DEFAULT_FORMAT, conditions = []) -> allformats = ConditionsFunc.Formats() return [ "Invalid format: #{format}." ] if format not of allformats allformats = ConditionsFunc.Formats() format = allformats[format] return [ "Invalid team format." ] if team not instanceof Array return [ "Team must have 1 to 6 Pokemon." ] unless 1 <= team.length <= 6 conditions = conditions.concat(format.conditions) genData = gen.GenerationJSON[format.generation.toUpperCase()] err = require('./conditions').validateTeam(conditions, team, genData) return err if err.length > 0 err = team.map (pokemon, i) => @validatePokemon(conditions, pokemon, i + 1, format.generation) return _.flatten(err) # Returns an empty array if the given Pokemon is valid, an array of errors # otherwise. validatePokemon: (conditions, pokemon, slot, generation = gen.DEFAULT_GENERATION) -> genData = gen.GenerationJSON[generation.toUpperCase()] {SpeciesData, FormeData, MoveData} = genData err = [] prefix = "Slot ##{slot}" if !pokemon.species err.push("#{prefix}: No species given.") return err species = SpeciesData[pokemon.species] if !species err.push("#{prefix}: Invalid species: #{pokemon.species}.") return err prefix += " (#{pokemon.species})" @normalizePokemon(pokemon, generation) forme = FormeData[pokemon.species][pokemon.forme] if !forme err.push("#{prefix}: Invalid forme: #{pokemon.forme}.") return err if forme.isBattleOnly and !forme.isItemBased err.push("#{prefix}: #{pokemon.forme} forme is battle-only.") return err unless 0 < pokemon.name.length <= MAX_NICKNAME_LENGTH err.push("#{prefix}: Nickname cannot be blank or be #{MAX_NICKNAME_LENGTH} characters or higher.") return err if pokemon.name != pokemon.species && pokemon.name of SpeciesData err.push("#{prefix}: Nickname cannot be another Pokemon's name.") return err if /[\u0300-\u036F\u20D0-\u20FF\uFE20-\uFE2F]/.test(pokemon.name) err.push("#{prefix}: Nickname cannot contain some special characters.") return err if isNaN(pokemon.level) err.push("#{prefix}: Invalid level: #{pokemon.level}.") # TODO: 100 is a magic constant else if !(1 <= pokemon.level <= genData.maxLevel) err.push("#{prefix}: Level must be between 1 and #{genData.maxLevel}.") if pokemon.gender not in [ "M", "F", "Genderless" ] err.push("#{prefix}: Invalid gender: #{pokemon.gender}.") if species.genderRatio == -1 && pokemon.gender != "Genderless" err.push("#{prefix}: Must be genderless.") if species.genderRatio == 0 && pokemon.gender != "M" err.push("#{prefix}: Must be male.") if species.genderRatio == 8 && pokemon.gender != "F" err.push("#{prefix}: Must be female.") if (typeof pokemon.evs != "object") err.push("#{prefix}: Invalid evs.") if (typeof pokemon.ivs != "object") err.push("#{prefix}: Invalid ivs.") if !_.chain(pokemon.evs).values().all((ev) -> 0 <= ev <= 255).value() err.push("#{prefix}: EVs must be between 0 and 255.") if !_.chain(pokemon.ivs).values().all((iv) -> 0 <= iv <= 31).value() err.push("#{prefix}: IVs must be between 0 and 31.") if _.values(pokemon.evs).reduce(((x, y) -> x + y), 0) > 510 err.push("#{prefix}: EV total must be less than 510.") if pokemon.ability not in forme["abilities"] && pokemon.ability != forme["hiddenAbility"] err.push("#{prefix}: Invalid ability.") if pokemon.moves not instanceof Array err.push("#{prefix}: Invalid moves.") # TODO: 4 is a magic constant else if !(1 <= pokemon.moves.length <= 4) err.push("#{prefix}: Must have 1 to 4 moves.") else if !_(pokemon.moves).all((name) -> MoveData[name]?) invalidMove = _(pokemon.moves).find((name) -> !MoveData[name]?) err.push("#{prefix}: Invalid move name: #{invalidMove}") else if !learnsets.checkMoveset(gen.GenerationJSON, pokemon, gen.GENERATION_TO_INT[generation], pokemon.moves) err.push("#{prefix}: Invalid moveset.") err.push require('./conditions').validatePokemon(conditions, pokemon, genData, prefix)... return err # Normalizes a Pokemon by setting default values where applicable. # Assumes that the Pokemon is a real Pokemon (i.e. its species/forme is valid) normalizePokemon: (pokemon, generation = gen.DEFAULT_GENERATION) -> {maxLevel, SpeciesData, FormeData} = gen.GenerationJSON[generation.toUpperCase()] pokemon.forme ?= "default" pokemon.name ?= pokemon.species pokemon.ability ?= FormeData[pokemon.species][pokemon.forme]?["abilities"][0] if !pokemon.gender? {genderRatio} = SpeciesData[pokemon.species] if genderRatio == -1 then pokemon.gender = "Genderless" else if Math.random() < (genderRatio / 8) then pokemon.gender = "F" else pokemon.gender = "M" pokemon.evs ?= {} pokemon.ivs ?= {} pokemon.level ?= maxLevel pokemon.level = Math.floor(pokemon.level) return pokemon