BattleSim/server/server.coffee

543 lines
19 KiB
CoffeeScript

{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.TEAM_PREVIEW
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.TEAM_PREVIEW
Conditions.TIMED_BATTLE
Conditions.SLEEP_CLAUSE
Conditions.EVASION_CLAUSE
Conditions.SPECIES_CLAUSE
Conditions.PRANKSTER_SWAGGER_CLAUSE
Conditions.OHKO_CLAUSE
Conditions.UNRELEASED_BAN
]
MAX_NICKNAME_LENGTH = 15
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) ->
@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!")
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("<strong>The server is restarting!</strong> We're waiting for all battles to finish to push some updates. No new battles may start at this time.")
unlockdown: ->
@canBattlesStart = true
@announce("<strong>Battles have been unlocked!</strong> 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
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 <= 120)
err.push("#{prefix}: Level must be between 1 and 120.")
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) ->
{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 ?= 100
pokemon.level = Math.floor(pokemon.level)
return pokemon