BattleSim/test/bw/battle.coffee

478 lines
18 KiB
CoffeeScript

require '../helpers'
sinon = require('sinon')
{Attachment} = require('../../server/bw/attachment')
{Battle} = require('../../server/bw/battle')
{Weather} = require('../../shared/weather')
{Conditions} = require '../../shared/conditions'
{Factory} = require('../factory')
{BattleServer} = require('../../server/server')
{Protocol} = require '../../shared/protocol'
shared = require('../shared')
should = require 'should'
{_} = require 'underscore'
ratings = require('../../server/ratings')
describe 'Battle', ->
beforeEach ->
@server = new BattleServer()
shared.create.call this,
format: 'xy1000'
team1: [Factory('Hitmonchan'), Factory('Heracross')]
team2: [Factory('Hitmonchan'), Factory('Heracross')]
it 'starts at turn 1', ->
@battle.turn.should.equal 1
describe '#hasWeather(weatherName)', ->
it 'returns true if the current battle weather is weatherName', ->
@battle.weather = "Sunny"
@battle.hasWeather("Sunny").should.be.true
it 'returns false on non-None in presence of a weather-cancel ability', ->
@battle.weather = "Sunny"
@sandbox.stub(@battle, 'hasWeatherCancelAbilityOnField', -> true)
@battle.hasWeather("Sunny").should.be.false
it 'returns true on None in presence of a weather-cancel ability', ->
@battle.weather = "Sunny"
@sandbox.stub(@battle, 'hasWeatherCancelAbilityOnField', -> true)
@battle.hasWeather("None").should.be.true
describe '#recordMove', ->
it "records a player's move", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
action = _(@battle.pokemonActions).find((a) => a.pokemon == @p1)
should.exist(action)
action.should.have.property("move")
action.move.should.equal @battle.getMove('Tackle')
it "does not record a move if player has already made an action", ->
length = @battle.pokemonActions.length
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.pokemonActions.length.should.equal(1 + length)
describe '#undoCompletedRequest', ->
it "fails if the player didn't make any action", ->
@battle.undoCompletedRequest(@id1).should.be.false
it "fails on the second turn as well if the player didn't make any action", ->
@controller.makeMove(@id1, "Mach Punch")
@controller.makeMove(@id2, "Mach Punch")
@battle.turn.should.equal 2
@battle.undoCompletedRequest(@id1).should.be.false
it "succeeds if the player selected an action already", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.pokemonActions.should.not.be.empty
@battle.undoCompletedRequest(@id1).should.be.true
@battle.pokemonActions.should.be.empty
it "can cancel an action multiple times", ->
for i in [0..5]
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.pokemonActions.should.not.be.empty
@battle.undoCompletedRequest(@id1).should.be.true
@battle.pokemonActions.should.be.empty
describe '#recordSwitch', ->
it "records a player's switch", ->
@battle.recordSwitch(@id1, 1)
action = _(@battle.pokemonActions).find((a) => a.pokemon == @p1)
should.exist(action)
action.should.have.property("to")
action.to.should.equal 1
it "does not record a switch if player has already made an action", ->
length = @battle.pokemonActions.length
@battle.recordSwitch(@id1, 1)
@battle.recordSwitch(@id1, 1)
@battle.pokemonActions.length.should.equal(1 + length)
describe '#performSwitch', ->
it "swaps pokemon positions of a player's team", ->
[poke1, poke2] = @team1.pokemon
@battle.performSwitch(@p1, 1)
@team1.pokemon.slice(0, 2).should.eql [poke2, poke1]
it "calls the pokemon's switchOut() method", ->
pokemon = @p1
mock = @sandbox.mock(pokemon)
mock.expects('switchOut').once()
@battle.performSwitch(@p1, 1)
mock.verify()
describe "#setWeather", ->
it "can last a set number of turns", ->
@battle.setWeather(Weather.SUN, 5)
for i in [0...5]
@battle.endTurn()
@battle.weather.should.equal Weather.NONE
describe "weather", ->
it "damages pokemon who are not of a certain type", ->
@battle.setWeather(Weather.SAND)
@battle.endTurn()
maxHP = @p1.stat('hp')
(maxHP - @p1.currentHP).should.equal Math.floor(maxHP / 16)
(maxHP - @p2.currentHP).should.equal Math.floor(maxHP / 16)
@battle.setWeather(Weather.HAIL)
@battle.endTurn()
maxHP = @p1.stat('hp')
(maxHP - @p1.currentHP).should.equal 2*Math.floor(maxHP / 16)
(maxHP - @p2.currentHP).should.equal 2*Math.floor(maxHP / 16)
describe "move PP", ->
it "goes down after a pokemon uses a move", ->
pokemon = @p1
move = @p1.moves[0]
@battle.performMove(@p1, move)
@p1.pp(move).should.equal(@p1.maxPP(move) - 1)
describe "#performMove", ->
it "records this move as the battle's last move", ->
move = @p1.moves[0]
@battle.performMove(@p1, move)
should.exist @battle.lastMove
@battle.lastMove.should.equal move
describe "#bump", ->
it "bumps a pokemon to the front of its priority bracket", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id2, @battle.getMove('Splash'))
@battle.determineTurnOrder()
# Get last pokemon to move and bump it up
{pokemon} = @battle.pokemonActions[@battle.pokemonActions.length - 1]
@battle.bump(pokemon)
@battle.pokemonActions[0].pokemon.should.eql pokemon
it "bumps a pokemon to the front of a specific priority bracket", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id2, @battle.getMove('Mach Punch'))
queue = @battle.determineTurnOrder()
@battle.bump(@p1, @battle.getMove('Mach Punch').priority)
queue[0].pokemon.should.eql @p1
it "still works even if there's nothing in that bracket", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id2, @battle.getMove('Mach Punch'))
queue = @battle.determineTurnOrder()
queue.should.have.length(2)
@battle.bump(@p1)
queue.should.have.length(2)
queue[0].pokemon.should.eql @p2
queue[1].pokemon.should.eql @p1
it "is a no-op if no actions", ->
queue = @battle.determineTurnOrder()
queue.should.have.length(0)
@battle.bump(@p1)
queue.should.have.length(0)
describe "#delay", ->
it "delays a pokemon to the end of its priority bracket", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id2, @battle.getMove('Splash'))
@battle.determineTurnOrder()
# Get first pokemon to move and delay it
{pokemon} = @battle.pokemonActions[0]
@battle.delay(pokemon)
@battle.pokemonActions[1].pokemon.should.eql pokemon
it "delays a pokemon to the end of a specific priority bracket", ->
@battle.recordMove(@id1, @battle.getMove('Mach Punch'))
@battle.recordMove(@id2, @battle.getMove('Tackle'))
queue = @battle.determineTurnOrder()
@battle.delay(@p1, @battle.getMove('Tackle').priority)
queue[1].pokemon.should.eql @p1
it "still works even if there's nothing in that bracket", ->
@battle.recordMove(@id1, @battle.getMove('Tackle'))
@battle.recordMove(@id2, @battle.getMove('Mach Punch'))
queue = @battle.determineTurnOrder()
queue.should.have.length(2)
@battle.delay(@p1)
queue.should.have.length(2)
queue[0].pokemon.should.eql @p2
queue[1].pokemon.should.eql @p1
it "is a no-op if no actions", ->
queue = @battle.determineTurnOrder()
queue.should.have.length(0)
@battle.delay(@p1)
queue.should.have.length(0)
describe "#weatherUpkeep", ->
it "does not damage Pokemon if a weather-cancel ability is on the field", ->
@battle.setWeather(Weather.HAIL)
@sandbox.stub(@battle, 'hasWeatherCancelAbilityOnField', -> true)
@battle.endTurn()
@p1.currentHP.should.not.be.lessThan @p1.stat('hp')
@p2.currentHP.should.not.be.lessThan @p2.stat('hp')
it "does not damage a Pokemon who is immune to a weather", ->
@battle.setWeather(Weather.HAIL)
@sandbox.stub(@p2, 'isWeatherDamageImmune', -> true)
@battle.endTurn()
@p1.currentHP.should.be.lessThan @p1.stat('hp')
@p2.currentHP.should.not.be.lessThan @p2.stat('hp')
describe "#add", ->
it "adds the spectator to an internal array", ->
connection = @stubSpark()
spectator = @server.findOrCreateUser(id: 1, name: "derp", connection)
length = @battle.sparks.length
@battle.add(connection)
@battle.sparks.should.have.length(length + 1)
@battle.sparks.should.containEql(connection)
it "gives the spectator battle information", ->
connection = @stubSpark()
spectator = @server.findOrCreateUser(id: 1, name: "derp", connection)
spy = @sandbox.spy(connection, 'send')
@battle.add(connection)
{id, numActive, log} = @battle
spy.calledWithMatch("spectateBattle", id, sinon.match.string, numActive, null, @battle.playerNames, log).should.be.true
it "receives the correct set of initial teams", ->
connection = @stubSpark()
spectator = @server.findOrCreateUser(id: 1, name: "derp", connection)
spy = @sandbox.spy(connection, 'send')
teams = @battle.getTeams().map((team) -> team.toJSON(hidden: true))
@team1.switch(@p1, 1)
@battle.add(connection)
{id, numActive, log} = @battle
spy.calledWithMatch("spectateBattle", id, sinon.match.string, numActive, null, @battle.playerNames, log).should.be.true
it "does not add a spectator twice", ->
connection = @stubSpark()
spectator = @server.findOrCreateUser(id: 1, name: "derp", connection)
length = @battle.sparks.length
@battle.add(connection)
@battle.add(connection)
@battle.sparks.should.have.length(length + 1)
describe "#getWinner", ->
it "returns player 1 if player 2's team has all fainted", ->
pokemon.faint() for pokemon in @team2.pokemon
@battle.getWinner().should.equal(@id1)
it "returns player 2 if player 1's team has all fainted", ->
pokemon.faint() for pokemon in @team1.pokemon
@battle.getWinner().should.equal(@id2)
it "declares the pokemon dying of recoil the winner", ->
shared.create.call this,
team1: [Factory('Talonflame', moves: ["Brave Bird"])]
team2: [Factory('Magikarp')]
@p1.currentHP = @p2.currentHP = 1
spy = @sandbox.spy(@battle, 'getWinner')
@controller.makeMove(@id1, "Brave Bird")
@controller.makeMove(@id2, "Splash")
spy.returned(@id1).should.be.true
describe "#endBattle", ->
it "cannot end multiple times", ->
spy = @sandbox.spy(@battle, 'emit')
spy.withArgs("end")
@battle.endBattle()
@battle.endBattle()
spy.withArgs("end").calledOnce.should.be.true
it "marks the battle as over", ->
@battle.endBattle()
@battle.isOver().should.be.true
it "updates the winner and losers' ratings", (done) ->
shared.create.call this,
team1: [Factory('Hitmonchan')]
team2: [Factory('Mew')]
conditions: [ Conditions.RATED_BATTLE ]
@battle.on "ratingsUpdated", =>
ratings.getPlayer @id1, (err, rating1) =>
ratings.getPlayer @id2, (err, rating2) =>
rating1.rating.should.be.greaterThan(ratings.DEFAULT_RATING)
rating2.rating.should.be.lessThan(ratings.DEFAULT_RATING)
done()
@p2.currentHP = 1
mock = @sandbox.mock(@controller)
@controller.makeMove(@id1, 'Mach Punch')
@controller.makeMove(@id2, 'Psychic')
it "doesn't update ratings if an unrated battle", (done) ->
shared.create.call this,
team1: [Factory('Hitmonchan')]
team2: [Factory('Mew')]
@battle.on 'end', =>
ratings.getPlayer @id1, (err, rating1) =>
ratings.getPlayer @id2, (err, rating2) =>
rating1.rating.should.equal(ratings.DEFAULT_RATING)
rating2.rating.should.equal(ratings.DEFAULT_RATING)
done()
@p2.currentHP = 1
mock = @sandbox.mock(@controller)
@controller.makeMove(@id1, 'Mach Punch')
@controller.makeMove(@id2, 'Psychic')
describe "#forfeit", ->
it "prematurely ends the battle", ->
spy = @sandbox.spy(@battle, 'tell')
spy.withArgs(Protocol.FORFEIT_BATTLE, @battle.getPlayerIndex(@id1))
@battle.forfeit(@id1)
spy.withArgs(Protocol.FORFEIT_BATTLE, @battle.getPlayerIndex(@id1))
.called.should.be.true
it "does not forfeit if the player given is invalid", ->
mock = @sandbox.mock(@battle).expects('tell').never()
@battle.forfeit('this definitely should not work')
mock.verify()
it "cannot forfeit multiple times", ->
spy = @sandbox.spy(@battle, 'tell')
spy.withArgs(Protocol.FORFEIT_BATTLE, @battle.getPlayerIndex(@id1))
@battle.forfeit(@id1)
@battle.forfeit(@id1)
spy.withArgs(Protocol.FORFEIT_BATTLE, @battle.getPlayerIndex(@id1))
.calledOnce.should.be.true
it "marks the battle as over", ->
@battle.forfeit(@id1)
@battle.isOver().should.be.true
it "doesn't update the winner and losers' ratings if not a rated battle", (done) ->
@battle.on 'end', =>
ratings.getPlayer @id1, (err, rating1) =>
ratings.getPlayer @id2, (err, rating2) =>
rating1.rating.should.equal(ratings.DEFAULT_RATING)
rating2.rating.should.equal(ratings.DEFAULT_RATING)
done()
@battle.forfeit(@id2)
describe "#hasStarted", ->
it "returns false if the battle has not started", ->
battle = new Battle('id', [])
battle.hasStarted().should.be.false
it "returns true if the battle has started", ->
battle = new Battle('id', [])
battle.begin()
battle.hasStarted().should.be.true
describe "#getAllAttachments", ->
it "returns a list of attachments for all pokemon, teams, and battles", ->
@battle.attach(Attachment.TrickRoom)
@team2.attach(Attachment.Reflect)
@p1.attach(Attachment.Ingrain)
attachments = @battle.getAllAttachments()
should.exist(attachments)
attachments = attachments.map((a) -> a.constructor)
attachments.length.should.be.greaterThan(2)
attachments.should.containEql(Attachment.TrickRoom)
attachments.should.containEql(Attachment.Reflect)
attachments.should.containEql(Attachment.Ingrain)
describe "#query", ->
it "queries all attachments attached to a specific event", ->
@battle.attach(Attachment.TrickRoom)
@team2.attach(Attachment.Reflect)
@p1.attach(Attachment.Ingrain)
mocks = []
mocks.push @sandbox.mock(Attachment.TrickRoom.prototype)
mocks.push @sandbox.mock(Attachment.Reflect.prototype)
mocks.push @sandbox.mock(Attachment.Ingrain.prototype)
mock.expects("endTurn").once() for mock in mocks
attachments = @battle.query("endTurn")
mock.verify() for mock in mocks
describe "#getOpponents", ->
it "returns all opponents of a particular pokemon as an array", ->
@battle.getOpponents(@p1).should.be.an.instanceOf(Array)
@battle.getOpponents(@p1).should.have.length(1)
it "does not include fainted opponents", ->
@p2.faint()
@battle.getOpponents(@p1).should.have.length(0)
describe "#sendRequestTo", ->
it "sends all requests to a certain player", ->
mock = @sandbox.mock(@battle).expects('tellPlayer').once()
mock.withArgs(@id1, Protocol.REQUEST_ACTIONS)
@battle.sendRequestTo(@id1)
mock.verify()
describe "expiration", ->
beforeEach ->
shared.create.call(this)
it "ends ongoing battles after a specific amount", ->
time = Battle::ONGOING_BATTLE_TTL
@clock.tick(time)
@battle.isOver().should.be.true
it "sends BATTLE_EXPIRED after a specific amount after battle end", ->
time = Battle::ENDED_BATTLE_TTL
delta = 5000
spy = @sandbox.spy(@battle, 'tell').withArgs(Protocol.BATTLE_EXPIRED)
@clock.tick(delta)
@battle.forfeit(@id1)
@battle.isOver().should.be.true
spy.withArgs(Protocol.BATTLE_EXPIRED).called.should.be.false
@battle.tell.restore()
spy = @sandbox.spy(@battle, 'tell').withArgs(Protocol.BATTLE_EXPIRED)
@clock.tick(time)
@battle.isOver().should.be.true
spy.withArgs(Protocol.BATTLE_EXPIRED).calledOnce.should.be.true
it "doesn't call 'expire' if expiring twice", ->
firstExpire = 24 * 60 * 60 * 1000
secondExpire = 48 * 60 * 60 * 1000
spy = @sandbox.spy(@battle, 'expire')
@clock.tick(firstExpire)
@battle.isOver().should.be.true
@clock.tick(secondExpire)
spy.calledOnce.should.be.true
it "doesn't call 'end' twice", ->
longExpire = 48 * 60 * 60 * 1000
spy = @sandbox.spy()
@battle.on('end', spy)
@battle.forfeit(@id1)
@battle.isOver().should.be.true
@clock.tick(longExpire)
spy.calledOnce.should.be.true
describe "Rated battles", ->
beforeEach ->
shared.create.call this,
team1: [Factory('Hitmonchan')]
team2: [Factory('Mew')]
conditions: [ Conditions.RATED_BATTLE ]
it "updates the winner and losers' ratings", (done) ->
@battle.on "ratingsUpdated", =>
ratings.getPlayer @id1, (err, rating1) =>
ratings.getPlayer @id2, (err, rating2) =>
defaultPlayer = ratings.algorithm.createPlayer()
rating1.rating.should.be.greaterThan(defaultPlayer.rating)
rating2.rating.should.be.lessThan(defaultPlayer.rating)
done()
@battle.forfeit(@id2)