From ff441c415f2879cd4199aeaaefa082f1656f6c09 Mon Sep 17 00:00:00 2001 From: Gareth Latty Date: Fri, 17 Jun 2016 01:58:40 +0100 Subject: [PATCH] Add better comments, testing framework, some simple initial tests. --- .idea/modules/root.iml | 19 +- .../massivedecks/Application.scala | 1 - app/controllers/massivedecks/LobbyStore.scala | 10 ++ .../massivedecks/cardcast/CardcastAPI.scala | 1 + .../exceptions/BadRequestException.scala | 18 ++ .../exceptions/ForbiddenException.scala | 18 ++ .../exceptions/NotFoundException.scala | 12 ++ .../exceptions/RequestFailedException.scala | 12 ++ .../massivedecks/lobby/Config.scala | 2 +- app/controllers/massivedecks/lobby/Deck.scala | 54 ++++-- app/controllers/massivedecks/lobby/Game.scala | 90 +++++++++- .../massivedecks/lobby/Lobby.scala | 164 +++++++++++++++++- .../massivedecks/lobby/Players.scala | 120 ++++++++++++- .../massivedecks/notifications/Notifier.scala | 13 +- .../notifications/Notifiers.scala | 4 +- .../massivedecks/cardcast/CardcastDeck.scala | 2 +- build.sbt | 5 +- .../massivedecks/lobby/ConfigSpec.scala | 62 +++++++ .../massivedecks/lobby/DeckSpec.scala | 47 +++++ .../massivedecks/lobby/GameSpec.scala | 19 ++ .../massivedecks/lobby/LobbySpec.scala | 15 ++ .../massivedecks/lobby/PlayersSpec.scala | 110 ++++++++++++ test/matchers/Matchers.scala | 19 ++ 23 files changed, 787 insertions(+), 30 deletions(-) rename app/{controllers => models}/massivedecks/cardcast/CardcastDeck.scala (84%) create mode 100644 test/controllers/massivedecks/lobby/ConfigSpec.scala create mode 100644 test/controllers/massivedecks/lobby/DeckSpec.scala create mode 100644 test/controllers/massivedecks/lobby/GameSpec.scala create mode 100644 test/controllers/massivedecks/lobby/LobbySpec.scala create mode 100644 test/controllers/massivedecks/lobby/PlayersSpec.scala create mode 100644 test/matchers/Matchers.scala diff --git a/.idea/modules/root.iml b/.idea/modules/root.iml index f768883..53815ba 100644 --- a/.idea/modules/root.iml +++ b/.idea/modules/root.iml @@ -20,12 +20,30 @@ + + + + + + + + + + + + + + + + + + @@ -117,6 +135,5 @@ - \ No newline at end of file diff --git a/app/controllers/massivedecks/Application.scala b/app/controllers/massivedecks/Application.scala index 941670d..e26b07d 100644 --- a/app/controllers/massivedecks/Application.scala +++ b/app/controllers/massivedecks/Application.scala @@ -12,7 +12,6 @@ import models.massivedecks.Game.Formatters._ import play.api.libs.json.{JsResult, JsValue, Json} import play.api.mvc._ - class Application @Inject() (store: LobbyStore) extends Controller { def index() = Action { request => diff --git a/app/controllers/massivedecks/LobbyStore.scala b/app/controllers/massivedecks/LobbyStore.scala index 7240e60..edd11f9 100644 --- a/app/controllers/massivedecks/LobbyStore.scala +++ b/app/controllers/massivedecks/LobbyStore.scala @@ -3,6 +3,16 @@ package controllers.massivedecks import controllers.massivedecks.lobby.Lobby trait LobbyStore { + /** + * Make a new lobby. + * @return The lobby. + */ def newLobby(): Lobby + + /** + * Get the lobby with the given game code. + * @param gameCode The game code. + * @return The lobby. + */ def getLobby(gameCode: String): Lobby } diff --git a/app/controllers/massivedecks/cardcast/CardcastAPI.scala b/app/controllers/massivedecks/cardcast/CardcastAPI.scala index 2efa2f7..de62032 100644 --- a/app/controllers/massivedecks/cardcast/CardcastAPI.scala +++ b/app/controllers/massivedecks/cardcast/CardcastAPI.scala @@ -12,6 +12,7 @@ import play.api.libs.json.JsValue import play.api.libs.ws.WSClient import models.massivedecks.Game.{Call, Response} import controllers.massivedecks.exceptions.BadRequestException +import models.massivedecks.cardcast.CardcastDeck class CardcastAPI @Inject() (ws: WSClient) (implicit ec: ExecutionContext) { private val apiUrl: String = "https://api.cardcastgame.com/v1" diff --git a/app/controllers/massivedecks/exceptions/BadRequestException.scala b/app/controllers/massivedecks/exceptions/BadRequestException.scala index f7453aa..a2b1449 100644 --- a/app/controllers/massivedecks/exceptions/BadRequestException.scala +++ b/app/controllers/massivedecks/exceptions/BadRequestException.scala @@ -2,11 +2,29 @@ package controllers.massivedecks.exceptions import play.api.libs.json.Json.JsValueWrapper +/** + * An exception representing a request that failed due to a request being made by the server failing. + * Represents a 502 error. + * + * @param message The message. Should be JSON - the the companion class for helpers. + */ case class BadRequestException(message: String) extends Exception object BadRequestException { + /** + * Generate a BadRequestException with a JSON error message. + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + * @return The exception + */ def json(error: String, args: (String, JsValueWrapper)*): BadRequestException = new BadRequestException(JsonError.of(error, args: _*)) + /** + * Verify the given requirement - if not met, throw the given BadRequestException with a JSON error message. + * @param requirement The requirement to verify + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + */ def verify(requirement: Boolean, error: String, args: (String, JsValueWrapper)*): Unit = { if (!requirement) { throw BadRequestException.json(error, args: _*) diff --git a/app/controllers/massivedecks/exceptions/ForbiddenException.scala b/app/controllers/massivedecks/exceptions/ForbiddenException.scala index c49fd21..b1815eb 100644 --- a/app/controllers/massivedecks/exceptions/ForbiddenException.scala +++ b/app/controllers/massivedecks/exceptions/ForbiddenException.scala @@ -2,11 +2,29 @@ package controllers.massivedecks.exceptions import play.api.libs.json.Json.JsValueWrapper +/** + * An exception representing a request that failed due to the request not being authorized. + * Represents a 403 error. + * + * @param message The message. Should be JSON - the the companion class for helpers. + */ case class ForbiddenException(message: String) extends Exception object ForbiddenException { + /** + * Generate a ForbiddenException with a JSON error message. + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + * @return The exception + */ def json(error: String, args: (String, JsValueWrapper)*): ForbiddenException = new ForbiddenException(JsonError.of(error, args: _*)) + /** + * Verify the given requirement - if not met, throw the given ForbiddenException with a JSON error message. + * @param requirement The requirement to verify + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + */ def verify(requirement: Boolean, error: String, args: (String, JsValueWrapper)*): Unit = { if (!requirement) { throw ForbiddenException.json(error, args: _*) diff --git a/app/controllers/massivedecks/exceptions/NotFoundException.scala b/app/controllers/massivedecks/exceptions/NotFoundException.scala index 6aed096..4ffbeae 100644 --- a/app/controllers/massivedecks/exceptions/NotFoundException.scala +++ b/app/controllers/massivedecks/exceptions/NotFoundException.scala @@ -2,8 +2,20 @@ package controllers.massivedecks.exceptions import play.api.libs.json.Json.JsValueWrapper +/** + * An exception representing a request that failed due to the requested resource not being found. + * Represents a 404 error. + * + * @param message The message. Should be JSON - the the companion class for helpers. + */ case class NotFoundException(message: String) extends Exception object NotFoundException { + /** + * Generate a NotFoundException with a JSON error message. + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + * @return The exception + */ def json(error: String, args: (String, JsValueWrapper)*): NotFoundException = new NotFoundException(JsonError.of(error, args: _*)) } diff --git a/app/controllers/massivedecks/exceptions/RequestFailedException.scala b/app/controllers/massivedecks/exceptions/RequestFailedException.scala index 53a137f..d481cb0 100644 --- a/app/controllers/massivedecks/exceptions/RequestFailedException.scala +++ b/app/controllers/massivedecks/exceptions/RequestFailedException.scala @@ -2,8 +2,20 @@ package controllers.massivedecks.exceptions import play.api.libs.json.Json.JsValueWrapper +/** + * An exception representing a request that failed due to the request not being authorized. + * Represents a 403 error. + * + * @param message The message. Should be JSON - the the companion class for helpers. + */ case class RequestFailedException(message: String) extends Exception object RequestFailedException { + /** + * Generate a RequestFailedException with a JSON error message. + * @param error The name of the error. + * @param args Any key/value pairs to add to the error. + * @return The exception + */ def json(error: String, args: (String, JsValueWrapper)*): RequestFailedException = new RequestFailedException(JsonError.of(error, args: _*)) } diff --git a/app/controllers/massivedecks/lobby/Config.scala b/app/controllers/massivedecks/lobby/Config.scala index 15f3b7a..eddffd5 100644 --- a/app/controllers/massivedecks/lobby/Config.scala +++ b/app/controllers/massivedecks/lobby/Config.scala @@ -1,8 +1,8 @@ package controllers.massivedecks.lobby import models.massivedecks.Game.{Config => ConfigModel} -import controllers.massivedecks.cardcast.CardcastDeck import controllers.massivedecks.notifications.Notifiers +import models.massivedecks.cardcast.CardcastDeck class Config(notifiers: Notifiers) { diff --git a/app/controllers/massivedecks/lobby/Deck.scala b/app/controllers/massivedecks/lobby/Deck.scala index b18986f..2890398 100644 --- a/app/controllers/massivedecks/lobby/Deck.scala +++ b/app/controllers/massivedecks/lobby/Deck.scala @@ -4,28 +4,37 @@ import java.util.UUID import scala.util.Random +import controllers.massivedecks.exceptions.BadRequestException import models.massivedecks.Game.{Call, Response} -import controllers.massivedecks.cardcast.CardcastDeck +import models.massivedecks.cardcast.CardcastDeck /** * A live deck of cards in a game, constructed from a collection of cardcast decks. * * @param decks The cardcast decks. + * @throws BadRequestException with key "invalid-deck-configuration" if there are no calls or responses. Has value + * "reason" explaining the issue. */ case class Deck(decks: List[CardcastDeck]) { + /** + * The current calls in the deck. + */ var calls: List[Call] = List() + + /** + * The current responses in the deck. + */ var responses: List[Response] = List() + resetCalls() resetResponses() + BadRequestException.verify(calls.nonEmpty, "invalid-deck-configuration", "reason" -> "The decks for the game have no calls.") + BadRequestException.verify(responses.nonEmpty, "invalid-deck-configuration", "reason" -> "The decks for the game have no responses.") - if (calls.length < 1) { - throw new IllegalStateException("The lobby must have at least one call in it.") - } - - if (responses.length < 1) { - throw new IllegalStateException("The lobby must have at least one response in it.") - } - + /** + * Draw a call from the deck, reshuffling if the deck runs out. + * @return The drawn call. + */ def drawCall(): Call = { if (calls.isEmpty) { resetCalls() @@ -35,6 +44,11 @@ case class Deck(decks: List[CardcastDeck]) { drawn } + /** + * Draw the given number of responses, reshuffling if the deck runs out. + * @param count The number of responses to draw. + * @return The drawn responses. + */ def drawResponses(count: Int): List[Response] = { if (responses.length < count) { val partial = responses.take(count) @@ -47,19 +61,37 @@ case class Deck(decks: List[CardcastDeck]) { } } + /** + * Populate the active deck with all calls, shuffling them into a random order. + */ def resetCalls(): Unit = { - calls = (for (deck <- decks) yield deck.calls).flatten.map(call => call.copy(id = UUID.randomUUID().toString)) + calls = decks.flatMap(deck => deck.calls).map(call => call.copy(id = UUID.randomUUID().toString)) shuffleCalls() } + + /** + * Populate the active deck with all responses, shuffling them into a random order. + */ def resetResponses(): Unit = { - responses = (for (deck <- decks) yield deck.responses).flatten.map(call => call.copy(id = UUID.randomUUID().toString)) + responses = decks.flatMap(deck => deck.responses).map(response => response.copy(id = UUID.randomUUID().toString)) shuffleResponses() } + /** + * Shuffle the active deck of calls and responses. + */ def shuffle(): Unit = { shuffleCalls() shuffleResponses() } + + /** + * Shuffle the active deck of calls. + */ def shuffleCalls(): Unit = calls = Random.shuffle(calls) + + /** + * Shuffle the active deck of responses. + */ def shuffleResponses(): Unit = responses = Random.shuffle(responses) } diff --git a/app/controllers/massivedecks/lobby/Game.scala b/app/controllers/massivedecks/lobby/Game.scala index 0af7d46..fd9e0e7 100644 --- a/app/controllers/massivedecks/lobby/Game.scala +++ b/app/controllers/massivedecks/lobby/Game.scala @@ -7,10 +7,22 @@ import models.massivedecks.Player import controllers.massivedecks.exceptions.BadRequestException import controllers.massivedecks.notifications.Notifiers +/** + * The state of the game. + * + * @param players The players in the lobby the game is in. + * @param config The configuration for the lobby the game is in. + * @param notifiers The notifiers for the lobby the game is in. + */ class Game(players: Players, config: Config, notifiers: Notifiers) { import Game._ + /** + * The history of the game. + */ + var history: List[FinishedRound] = List() + private val deck: Deck = new Deck(config.decks) private var czarIndex = 0 private var hands: Map[Player.Id, Hand] = players.ids.map(id => { @@ -19,7 +31,6 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { id -> hand }).toMap private var roundProgress: RoundProgress = Playing - var history: List[FinishedRound] = List() // Initial values overwritten by beginRound() call below, just keeping the compiler happy. private var czar: Player.Id = Player.Id(0) @@ -27,7 +38,14 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { private var playedCards: Map[Player.Id, Option[List[Response]]] = Map() beginRound() + /** + * @return The ids of the players that are taking part in the current round. + */ def playersInRound = playedCards.keySet + + /** + * @return The model representing the current round. + */ def round: Round = { val responses = roundProgress match { case Playing => @@ -40,10 +58,19 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { Round(czar, call, responses) } + /** + * Add a player to the game, giving them a hand. + * @param playerId The id of the player to add. + */ def addPlayer(playerId: Player.Id): Unit = { hands += (playerId -> Hand(deck.drawResponses(Hand.size))) } + /** + * Remove a player from the game, removing their hand and any played cards for the round. + * Note this may invalidate the round (if the player is the czar) or finish the round. + * @param playerId The id of the player to remove. + */ def playerLeft(playerId: Player.Id): Unit = { hands = hands.filterKeys(id => id != playerId) playedCards = playedCards.filterKeys(id => id != playerId) @@ -56,7 +83,9 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { /** * Begin the round. - * @return The list of players who played instantly (AI). + * @throws BadRequestException with key "not-enough-players" if there are not enough players in the game to begin a + * round. The value "required" gives the minimum number of players needed for the request + * to succeed. */ def beginRound() = { if (players.amount < Players.minimum) { @@ -97,6 +126,19 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { (0 until amount).map(i => hand(i).id).toList } + /** + * Play the given cards into the round. + * @param playerId The player the play is for. + * @param cardIds The ids of the responses to play. + * @throws BadRequestException with key "not-in-round" if the player is not in the round. + * @throws BadRequestException with key "already-played" if the player has already played into the round. + * @throws BadRequestException with key "already-judging" if the round is already in it's judging state. + * @throws BadRequestException with key "wrong-number-of-cards-played" if the wrong number of responses were played. + * The value "got" is the number of cards played, the value "expected" is the number + * required for the request to succeed + * @throws BadRequestException with key "invalid-card-id-given" if any of the card ids are not in the given player's + * hand. + */ def play(playerId: Player.Id, cardIds: List[String]): Unit = { BadRequestException.verify(playersInRound.contains(playerId), "not-in-round") BadRequestException.verify(playedCards(playerId).isEmpty, "already-played") @@ -114,6 +156,15 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { checkIfFinishedPlaying() } + /** + * Choose the winning play for the round. + * @param playerId The player who is choosing the winner. + * @param winner The index of the played responses being chosen. + * @throws BadRequestException with key "not-czar" if the current player is not the czar. + * @throws BadRequestException with key "not-judging" if the round is not yet in the judging phase. + * @throws BadRequestException with key "no-such-played-cards" if the index does not exist. + * @throws BadRequestException with key "already-judged" if the round is already finished. + */ def choose(playerId: Player.Id, winner: Int): Unit = { BadRequestException.verify(playerId == czar, "not-czar") roundProgress match { @@ -136,8 +187,18 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { } } + /** + * Get the hand of the given player. + * @param playerId The id of the player. + * @return The hand for the player. + */ def getHand(playerId: Player.Id): Hand = hands(playerId) + /** + * Redraw the hand of the given player. + * @param playerId The id of the player. + * @throws BadRequestException with the key "not-enough-points-to-redraw" if the player doesn't have enough points. + */ def redraw(playerId: Player.Id): Unit = { val player = players.getPlayer(playerId) BadRequestException.verify(player.score > 0, "not-enough-points-to-redraw") @@ -145,6 +206,11 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { hands += (playerId -> Hand(deck.drawResponses(Hand.size))) } + /** + * Start skipping the given players. + * @param playerId The player requesting the skipping. + * @param playerIds The players to start skipping. + */ def skip(playerId: Player.Id, playerIds: Set[Player.Id]): Unit = { for (id <- playerIds) { players.updatePlayer(id, players.setPlayerStatus(Player.Skipping)) @@ -189,8 +255,28 @@ class Game(players: Players, config: Config, notifiers: Notifiers) { } object Game { + /** + * Represents the progress through a round. + */ sealed trait RoundProgress + + /** + * Indicates the game is being played. + */ case object Playing extends RoundProgress + + /** + * Indicates the round is being judged. + * @param shuffled The shuffled list of played responses. + * @param playedOrder A list of player ids for the players who played the responses matching the order shuffled. + */ case class Judging(shuffled: List[List[Response]], playedOrder: List[Player.Id]) extends RoundProgress + + /** + * Indicates the round is done. + * @param shuffled The shuffled list of played responses. + * @param playedOrder A list of player ids for the players who played the responses matching the order shuffled. + * @param winner The id of the winner of the round. + */ case class Finished(shuffled: List[List[Response]], playedOrder: List[Player.Id], winner: Player.Id) extends RoundProgress } diff --git a/app/controllers/massivedecks/lobby/Lobby.scala b/app/controllers/massivedecks/lobby/Lobby.scala index 2e64e0e..2926df9 100644 --- a/app/controllers/massivedecks/lobby/Lobby.scala +++ b/app/controllers/massivedecks/lobby/Lobby.scala @@ -7,7 +7,7 @@ import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} import controllers.massivedecks.cardcast.CardcastAPI -import controllers.massivedecks.exceptions.{BadRequestException, RequestFailedException} +import controllers.massivedecks.exceptions.{BadRequestException, ForbiddenException, RequestFailedException} import controllers.massivedecks.notifications.Notifiers import models.massivedecks.{Game => GameModel} import models.massivedecks.Game.Formatters._ @@ -19,24 +19,68 @@ import play.api.libs.iteratee.{Enumerator, Iteratee} import play.api.libs.json.{JsValue, Json} object Lobby { + + /** + * Factory for dependency injection. + */ class LobbyFactory @Inject() (cardcast: CardcastAPI) (implicit context: ExecutionContext) { def build(gameCode: String) = new Lobby(cardcast, gameCode) } + /** + * How long to wait after a player disconnects to allow for them to reconnect without treating them as disconnected. + */ val disconnectGracePeriod: FiniteDuration = 5.seconds + /** + * How long to wait for calls to Cardcast to complete. + */ val cardCastWaitPeriod: FiniteDuration = 10.seconds + /** + * Wait for the given amount of time. + * @param duration The time to wait for. + * @return A future to wait on. + */ def wait(duration: FiniteDuration): Try[Future[Nothing]] = Try(Await.ready(Promise().future, duration)) } +/** + * Represents a game lobby. + * @param cardcast The cardcast api. + * @param gameCode The game code for the lobby. + */ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: ExecutionContext) { + /** + * The game in progress if there is one. + */ var game: Option[Game] = None + + /** + * Notifiers for the lobby. + */ val notifiers: Notifiers = new Notifiers() + + /** + * Configuration for the lobby. + */ var config = new Config(notifiers) + + /** + * The players in the lobby. + */ val players: Players = new Players(notifiers) + /** + * @return The model for the lobby. + */ def lobby = LobbyModel.Lobby(gameCode, config.config, players.players, game.map(game => game.round)) + /** + * Add a new player to the lobby. + * @param name The name for the player. + * @return The secret for the player. + * @throws BadRequestException with key "name-in-use" if there is a player in the lobby with the same name. + */ def newPlayer(name: String): Player.Secret = { val secret = players.addPlayer(name) if (game.isDefined) { @@ -46,6 +90,14 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution secret } + /** + * Try to add the deck to the lobby. + * @param secret The secret for the player making the request. + * @param playCode The cardcast play code for the deck. + * @return An empty response. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws RequestFailedException with the key "cardcast-timeout" if the request to cardcast doesn't complete. + */ def addDeck(secret: Player.Secret, playCode: String): JsValue = { players.validateSecret(secret) Try(Await.ready({ @@ -61,12 +113,25 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution Json.toJson("") } + /** + * Add a new ai to the lobby. + * @param secret The secret of the player making the request. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + */ def newAi(secret: Player.Secret): Unit = { players.validateSecret(secret) - val aiSecret = players.addAi() + players.addAi() } + /** + * Start a new game in the lobby. + * @param secret The secret of the player making the request. + * @return The hand of the player making the request in the new game. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with the key "game-in-progress" if the game has already started. + */ def newGame(secret: Player.Secret): JsValue = { + players.validateSecret(secret) if (game.isDefined) { throw BadRequestException.json("game-in-progress") } @@ -76,12 +141,40 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution Json.toJson(getHand(secret)) } + /** + * Play the given cards into the round. + * @param secret The secret of the player making the request. + * @param cardIds The ids of the responses to play. + * @return The hand of the player making the request after the cards have been played and replacements drawn. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + * @throws BadRequestException with key "not-in-round" if the player is not in the round. + * @throws BadRequestException with key "already-played" if the player has already played into the round. + * @throws BadRequestException with key "already-judging" if the round is already in it's judging state. + * @throws BadRequestException with key "wrong-number-of-cards-played" if the wrong number of responses were played. + * The value "got" is the number of cards played, the value "expected" is the number + * required for the request to succeed + * @throws BadRequestException with key "invalid-card-id-given" if any of the card ids are not in the given player's + * hand. + */ def play(secret: Player.Secret, cardIds: List[String]): JsValue = { players.validateSecret(secret) validateInGame().play(secret.id, cardIds) Json.toJson(getHand(secret)) } + /** + * Choose the winning play for the round. + * @param secret The secret of the player making the request. + * @param winner The index of the played responses being chosen. + * @return An empty response. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + * @throws BadRequestException with key "not-czar" if the current player is not the czar. + * @throws BadRequestException with key "not-judging" if the round is not yet in the judging phase. + * @throws BadRequestException with key "no-such-played-cards" if the index does not exist. + * @throws BadRequestException with key "already-judged" if the round is already finished. + */ def choose(secret: Player.Secret, winner: Int): JsValue = { players.validateSecret(secret) val game = validateInGame() @@ -90,15 +183,32 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution Json.toJson("") } + /** + * Get the hand of the player. + * @param secret The secret of the player making the request. + * @return The hand of the player. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + */ def getHand(secret: Player.Secret): GameModel.Hand = { players.validateSecret(secret) validateInGame().getHand(secret.id) } + /** + * @return The history of the current game. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + */ def gameHistory() : List[GameModel.FinishedRound] = { validateInGame().history } + /** + * Get the lobby and hand models for the lobby. + * @param secret The secret of the player making the request. + * @return The lobby and the hand of the player. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + */ def getLobbyAndHand(secret: Player.Secret): JsValue = { players.validateSecret(secret) Json.toJson(lobbyAndHand(secret)) @@ -114,6 +224,11 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution LobbyModel.LobbyAndHand(lobby, hand) } + /** + * Mark the given player as having left the game. + * @param secret The secret of the player making the request. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + */ def leave(secret: Player.Secret): Unit = { players.validateSecret(secret) players.leave(secret.id) @@ -125,12 +240,23 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution } } - def endGame(): Unit = { + private def endGame(): Unit = { game = None players.updatePlayers(players.setPlayerStatus(Player.Neutral)) notifiers.gameEnd() } + /** + * Start skipping the given players. + * @param secret The secret of the player making the request. + * @param playerIds The players to start skipping. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + * @throws BadRequestException with key "not-enough-players-to-skip" if the game would end by skipping the given + * players. + * @throws BadRequestException with key "players-must-be-skippable" if the players are not skippable. + * @return An empty response. + */ def skip(secret: Player.Secret, playerIds: Set[Player.Id]): JsValue = { players.validateSecret(secret) BadRequestException.verify((players.activePlayers.length - playerIds.size) >= Players.minimum, "not-enough-players-to-skip") @@ -140,12 +266,28 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution Json.toJson("") } + /** + * Mark the given player as back into the game. + * @param secret The secret of the player making the request. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "not-being-skipped" if the player was not being skipped. + * @return An empty response. + */ def back(secret: Player.Secret): JsValue = { players.validateSecret(secret) players.back(secret.id) Json.toJson("") } + /** + * Redraw the hand of the given player. + * @param secret The secret of the player making the request. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + * @throws BadRequestException with key "rule-not-enabled" if the rule is not enabled. + * @throws BadRequestException with key "no-game-in-progress" if there is not a game underway. + * @throws BadRequestException with the key "not-enough-points-to-redraw" if the player doesn't have enough points. + * @return The hand of the player. + */ def redraw(secret: Player.Secret): JsValue = { players.validateSecret(secret) BadRequestException.verify(config.houseRules.contains("reboot"), "rule-not-enabled") @@ -153,18 +295,34 @@ class Lobby(cardcast: CardcastAPI, gameCode: String)(implicit context: Execution Json.toJson(getHand(secret)) } + /** + * Enable the given rule. + * @param secret The secret of the player making the request. + * @param rule The rule to enable. + * @return An empty response. + */ def enableRule(secret: Player.Secret, rule: String): JsValue = { players.validateSecret(secret) config.addHouseRule(rule) Json.toJson("") } + /** + * Disable the given rule. + * @param secret The secret of the player making the request. + * @param rule The rule to disable. + * @return An empty response. + */ def disableRule(secret: Player.Secret, rule: String): JsValue = { players.validateSecret(secret) config.removeHouseRule(rule) Json.toJson("") } + /** + * Register a websocket connection. + * @return The iteratee and enumerator for the websocket. + */ def register(): (Iteratee[String, Unit], Enumerator[String]) = { notifiers.openedSocket(register, unregister) } diff --git a/app/controllers/massivedecks/lobby/Players.scala b/app/controllers/massivedecks/lobby/Players.scala index 3b2788b..31465a2 100644 --- a/app/controllers/massivedecks/lobby/Players.scala +++ b/app/controllers/massivedecks/lobby/Players.scala @@ -11,19 +11,47 @@ import models.massivedecks.Player */ class Players(notifiers: Notifiers) { + /** + * All players that have been in the game. + */ var players: List[Player] = List() + + /** + * Secrets for all the ais in the game. + */ var ais: Set[Player.Secret] = Set() + + /** + * Any players registered as connected to the game. + */ var connected: Set[Player.Id] = Set() - private var owner: Option[Player.Id] = None + + /** + * The owner of (first player in) the lobby. + */ + var owner: Option[Player.Id] = None + private var secrets: Map[Player.Id, Player.Secret] = Map() private var nextId: Int = 0 private var aiNames: List[String] = Players.aiName :: Random.shuffle(Players.aiNames) private var nameIteration = 0 + /** + * Validate the given secret as valid. + * @param secret The secret to check. + * @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid. + */ def validateSecret(secret: Player.Secret): Unit = { ForbiddenException.verify(secrets.values.exists(s => s == secret), "secret-wrong-or-not-a-player") } + /** + * Add a player to the game, marking them as the owner if they are the first player in the game. + * (Notifies all clients). + * @param name The name of the new player. + * @return The secret for the new player. + * @throws BadRequestException with key "name-in-use" if there is a player in the lobby with the same name. + */ def addPlayer(name: String): Player.Secret = { BadRequestException.verify(players.forall(player => player.name != name), "name-in-use") val id = newId() @@ -38,12 +66,14 @@ class Players(notifiers: Notifiers) { secret } - def addAi(): Player.Secret = { + /** + * Adds an Ai to the game, generating a name for it. (Notifies all clients). + */ + def addAi(): Unit = { val secret = addPlayer(generateAiName()) updatePlayer(secret.id, setPlayerStatus(Player.Ai, Set())) connected += secret.id ais += secret - secret } private def generateAiName(): String = { @@ -60,29 +90,63 @@ class Players(notifiers: Notifiers) { } } + /** + * Update all players using the given updater. + * Note that you will need to notify clients about the changes if the updater doesn't. + * @param update The player updater. + */ def updatePlayers(update: (Player => Player)): Unit = { players = players.map(player => update(player)) } + /** + * Update the given player using the given updater. + * Note that you will need to notify clients about the changes if the updater doesn't. + * @param update The player updater. + * @throws BadRequestException with key "not-a-player" if the player doesn't exist. + */ def updatePlayer(playerId: Player.Id, update: (Player => Player)): Unit = { val updated = update(getPlayer(playerId)) players = players.map(player => if (player.id == playerId) updated else player) } + /** + * Get the given player. + * @param playerId The ID of the player to get. + * @return The player. + * @throws BadRequestException with the key "not-a-player" if the player doesn't exist. + */ def getPlayer(playerId: Player.Id): Player = { - players.find(player => player.id == playerId).getOrElse(throw BadRequestException.json("secret-wrong-or-not-a-player")) + players.find(player => player.id == playerId).getOrElse(throw BadRequestException.json("not-a-player")) } + /** + * Mark the given player as back into the game. (Notifies all clients) + * @param playerId The player to mark as back. + * @throws BadRequestException with key "not-a-player" if the player doesn't exist. + * @throws BadRequestException with key "not-being-skipped" if the player was not being skipped. + */ def back(playerId: Player.Id): Unit = { val player = getPlayer(playerId) BadRequestException.verify(player.status == Player.Skipping, "not-being-skipped") updatePlayer(playerId, setPlayerStatus(Player.Neutral, Player.Status.sticky - Player.Skipping)) } + /** + * Mark the given player as having left the game. (Notifies all clients) + * @param playerId The player to mark as having left the game. + * @throws BadRequestException with key "not-a-player" if the player doesn't exist. + */ def leave(playerId: Player.Id): Unit = { updatePlayer(playerId, setPlayerLeft()) } + /** + * Register the player as connected to the game, and mark them as such. (Notifies all clients) + * @param playerId The player to mark as connected. + * @throws BadRequestException with key "already-left-game" if the player is marked as having left the game. + * @throws BadRequestException with key "not-a-player" if the player doesn't exist. + */ def register(playerId: Player.Id): Unit = { val player = getPlayer(playerId) BadRequestException.verify(!player.left, "already-left-game") @@ -93,6 +157,10 @@ class Players(notifiers: Notifiers) { } } + /** + * Remove the registration of the player as connected to the game. + * @param playerId The player to remove registration for. + */ def unregister(playerId: Player.Id): Unit = { connected -= playerId } @@ -103,16 +171,36 @@ class Players(notifiers: Notifiers) { id } + /** + * Check if the given player can be the card czar (is human, hasn't left, isn't being skipped). + * @param player The player to check. + * @return If the player can be the card czar. + */ def canBeCzar(player: Player): Boolean = { ais.exists(ai => ai.id == player.id) || player.left || player.status == Player.Skipping } + /** + * @return The ids of every player that has been in the game. + */ def ids = players.map(player => player.id) + /** + * @return The number of players that have been in the game. + */ def amount = players.length + /** + * @return Players that are actively in the game (not left or being skipped) + */ def activePlayers = players.filter(player => !player.left && player.status != Player.Skipping) + /** + * An updater for players that changes their status. See updatePlayers()/updaterPlayer(). + * @param newStatus The status to set the player to. + * @param ignoring If the player has a status in this set, do not change it. (Defaults to the sticky statuses). + * @return An updater function for a player. (Notifies all clients) + */ def setPlayerStatus(newStatus: Player.Status, ignoring: Set[Player.Status] = Player.Status.sticky): (Player => Player) = { player => if (!player.left && !ignoring.contains(player.status) && player.status != newStatus) { @@ -123,6 +211,11 @@ class Players(notifiers: Notifiers) { } } + /** + * An updater for players that changes their score. See updatePlayers()/updaterPlayer(). + * @param by The amount to change the score by. + * @return An updater function for a player. (Notifies all clients) + */ def modifyPlayerScore(by: Int): (Player => Player) = { player => val newScore = player.score + by @@ -130,6 +223,10 @@ class Players(notifiers: Notifiers) { player.copy(score = newScore) } + /** + * An updater for players that marks them as having left the game. See updatePlayers()/updaterPlayer(). + * @return An updater function for a player. (Notifies all clients) + */ def setPlayerLeft(): (Player => Player) = { player => notifiers.playerLeft(player.id) @@ -139,6 +236,12 @@ class Players(notifiers: Notifiers) { player.copy(status = Player.Neutral, left = true) } + /** + * An updater for players that marks them as being disconnected from the game. See updatePlayers()/updaterPlayer(). + * Note that we generally leave a grace period for clients to reconnect before marking them as disconnected. + * @param disconnected If the player is connected or disconnected. + * @return An updater function for a player. (Notifies all clients) + */ def setPlayerDisconnected(disconnected: Boolean): (Player => Player) = { player => if (!player.left && player.status != Player.Ai) { @@ -154,10 +257,19 @@ class Players(notifiers: Notifiers) { } } object Players { + /** + * The minimum number of players that can be active in a game for it to start or continue. + */ val minimum = 2 + /** + * The name of the first ai. + */ val aiName = "Rando Cardrissian" + /** + * Names for additional ais. + */ val aiNames = List( "HAL 9000", "GLaDOS", diff --git a/app/controllers/massivedecks/notifications/Notifier.scala b/app/controllers/massivedecks/notifications/Notifier.scala index a9eff15..1f6f0e4 100644 --- a/app/controllers/massivedecks/notifications/Notifier.scala +++ b/app/controllers/massivedecks/notifications/Notifier.scala @@ -12,8 +12,15 @@ import play.api.libs.json.Json * Handles websocket notifications. */ class Notifier (implicit context: ExecutionContext) { - var unicastChannel: Option[Channel[String]] = None + private var unicastChannel: Option[Channel[String]] = None + /** + * Call when a socket has been opened for the notifier. + * @param onConnect The callback when the socket connects. + * @param onIdentify The callback when the client identifies down the socket. + * @param onClose The callback when the socket closes. + * @return The iteratee and enumerator for the socket. + */ def openedSocket(onConnect: () => Unit, onIdentify: (Player.Secret) => Unit, onClose: () => Unit): (Iteratee[String, Unit], Enumerator[String]) = { val iteratee = Notifier.forEachAndOnClose[String]( message => onIdentify(Json.parse(message).validate[Player.Secret].get), @@ -28,6 +35,10 @@ class Notifier (implicit context: ExecutionContext) { (iteratee, enumerator) } + /** + * Notify the client down the socket. + * @param message The message to send. + */ def notify(message: String) = unicastChannel.foreach { channel => channel.push(message) diff --git a/app/controllers/massivedecks/notifications/Notifiers.scala b/app/controllers/massivedecks/notifications/Notifiers.scala index 1f1e48a..98d0cf2 100644 --- a/app/controllers/massivedecks/notifications/Notifiers.scala +++ b/app/controllers/massivedecks/notifications/Notifiers.scala @@ -16,8 +16,8 @@ import play.api.libs.json.{JsValue, Json} */ class Notifiers (implicit context: ExecutionContext) { - val (broadcastEnumerator, broadcastChannel) = Concurrent.broadcast[String] - var identified: Map[Player.Id, Notifier] = Map() + private val (broadcastEnumerator, broadcastChannel) = Concurrent.broadcast[String] + private var identified: Map[Player.Id, Notifier] = Map() /** * Sets up notifications for a new websocket. diff --git a/app/controllers/massivedecks/cardcast/CardcastDeck.scala b/app/models/massivedecks/cardcast/CardcastDeck.scala similarity index 84% rename from app/controllers/massivedecks/cardcast/CardcastDeck.scala rename to app/models/massivedecks/cardcast/CardcastDeck.scala index 41e3c90..ab8baae 100644 --- a/app/controllers/massivedecks/cardcast/CardcastDeck.scala +++ b/app/models/massivedecks/cardcast/CardcastDeck.scala @@ -1,4 +1,4 @@ -package controllers.massivedecks.cardcast +package models.massivedecks.cardcast import models.massivedecks.Game.{Call, DeckInfo, Response} diff --git a/build.sbt b/build.sbt index 4f2725d..7f37999 100644 --- a/build.sbt +++ b/build.sbt @@ -10,13 +10,12 @@ libraryDependencies ++= Seq( cache, ws ) +libraryDependencies += "io.john-ky" %% "hashids-scala" % "1.1.1-7d841a8" +libraryDependencies += specs2 % Test resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" - resolvers += "dl-john-ky" at "http://dl.john-ky.io/maven/releases" -libraryDependencies += "io.john-ky" %% "hashids-scala" % "1.1.1-7d841a8" - includeFilter in (Assets, LessKeys.less) := "massivedecks.less" // Play provides two styles of routers, one expects its actions to be injected, the diff --git a/test/controllers/massivedecks/lobby/ConfigSpec.scala b/test/controllers/massivedecks/lobby/ConfigSpec.scala new file mode 100644 index 0000000..26901bc --- /dev/null +++ b/test/controllers/massivedecks/lobby/ConfigSpec.scala @@ -0,0 +1,62 @@ +package controllers.massivedecks.lobby + +import org.specs2._ +import org.specs2.mock.Mockito +import controllers.massivedecks.notifications.Notifiers +import models.massivedecks.Game.DeckInfo +import models.massivedecks.cardcast.CardcastDeck + +/** + * Specification for the configuration. + */ +class ConfigSpec extends Specification with Mockito { def is = s2""" + + The configuration should + Return added decks $addedDeck + Return added house rules $addedHouseRule + Not return removed house rules $removedHouseRule + Ignore removal of house rule that doesn't exist $nonExistantHouseRule + The model must accurately reflect the configuration $modelReflectsConfiguration + """ + + val mockHouseRule = "test" + val mockNotifiers = mock[Notifiers] + val mockDeckInfo = mock[DeckInfo] + val mockDeck = mock[CardcastDeck] + mockDeck.info returns mockDeckInfo + + def mockConfig = new Config(mockNotifiers) + + def addedDeck = { + val config = mockConfig + config.addDeck(mockDeck) + config.decks must contain(exactly(mockDeck)) + } + + def addedHouseRule = { + val config = mockConfig + config.addHouseRule(mockHouseRule) + config.houseRules must contain(exactly(mockHouseRule)) + } + + def removedHouseRule = { + val config = mockConfig + config.addHouseRule(mockHouseRule) + config.removeHouseRule(mockHouseRule) + config.houseRules must beEmpty + } + + def nonExistantHouseRule = { + val config = mockConfig + config.removeHouseRule(mockHouseRule) + success + } + + def modelReflectsConfiguration = { + val config = mockConfig + config.addHouseRule(mockHouseRule) + config.addDeck(mockDeck) + config.config must_== models.massivedecks.Game.Config(List(mockDeckInfo), Set(mockHouseRule)) + } + +} diff --git a/test/controllers/massivedecks/lobby/DeckSpec.scala b/test/controllers/massivedecks/lobby/DeckSpec.scala new file mode 100644 index 0000000..6a6c534 --- /dev/null +++ b/test/controllers/massivedecks/lobby/DeckSpec.scala @@ -0,0 +1,47 @@ +package controllers.massivedecks.lobby + +import controllers.massivedecks.exceptions.BadRequestException +import models.massivedecks.Game.{Call, Response} +import models.massivedecks.cardcast.CardcastDeck +import matchers.Matchers._ +import org.specs2._ +import org.specs2.mock.Mockito + +/** + * Specification for the deck. + */ +class DeckSpec extends Specification with Mockito { def is = s2""" + + The deck should + Throw if there are not any responses. $noResponses + Throw if there are not any calls. $noCalls + Reused responses should get unique ids. $uniqueIdsOnReusedResponses + Reused calls should get unique ids. $uniqueIdsOnReusedCalls + + """ + + def call = Call("call", List("callContent", "callContent")) + def response = Response("response", "responseContent") + + def deckNoResponses = CardcastDeck("noResponses", "", List(call), List()) + def deckNoCalls = CardcastDeck("noCalls", "", List(), List(response)) + + def decksOneOfEach = List(deckNoResponses, deckNoCalls) + + def noResponses = + new Deck(List(deckNoResponses)) must throwA[BadRequestException] + + def noCalls = + new Deck(List(deckNoCalls)) must throwA[BadRequestException] + + def uniqueIdsOnReusedResponses = { + val deck = new Deck(decksOneOfEach) + deck.drawResponses(2) must haveNoDuplicateValues + } + + def uniqueIdsOnReusedCalls = { + val deck = new Deck(decksOneOfEach) + deck.drawCall() must_!= deck.drawCall() + } + +} diff --git a/test/controllers/massivedecks/lobby/GameSpec.scala b/test/controllers/massivedecks/lobby/GameSpec.scala new file mode 100644 index 0000000..69ea20c --- /dev/null +++ b/test/controllers/massivedecks/lobby/GameSpec.scala @@ -0,0 +1,19 @@ +package controllers.massivedecks.lobby + +import controllers.massivedecks.exceptions.BadRequestException +import models.massivedecks.Game.{Call, Response} +import models.massivedecks.cardcast.CardcastDeck +import matchers.Matchers._ +import org.specs2._ +import org.specs2.mock.Mockito + +/** + * Specification for the game. + */ +class GameSpec extends Specification with Mockito { def is = s2""" + + The game should + + """ + +} diff --git a/test/controllers/massivedecks/lobby/LobbySpec.scala b/test/controllers/massivedecks/lobby/LobbySpec.scala new file mode 100644 index 0000000..ffc674d --- /dev/null +++ b/test/controllers/massivedecks/lobby/LobbySpec.scala @@ -0,0 +1,15 @@ +package controllers.massivedecks.lobby + +import org.specs2._ +import org.specs2.mock.Mockito + +/** + * Specification for the lobby. + */ +class LobbySpec extends Specification with Mockito { def is = s2""" + + The lobby should + + """ + +} diff --git a/test/controllers/massivedecks/lobby/PlayersSpec.scala b/test/controllers/massivedecks/lobby/PlayersSpec.scala new file mode 100644 index 0000000..461d608 --- /dev/null +++ b/test/controllers/massivedecks/lobby/PlayersSpec.scala @@ -0,0 +1,110 @@ +package controllers.massivedecks.lobby + +import controllers.massivedecks.exceptions.{BadRequestException, ForbiddenException} +import controllers.massivedecks.notifications.Notifiers +import models.massivedecks.Player +import org.specs2._ +import org.specs2.mock.Mockito + +/** + * Specification for the players. + */ +class PlayersSpec extends Specification with Mockito { def is = s2""" + + The players controller should + Not allow new players with the same name as an existing one $notAllowNewPlayerWithSameNameAsExisting + Notify all clients when a new player joins $notifyAllClientsOnNewPlayer + There is no owner until the first player joins $ownerIsNoOneUntilPlayerJoins + Set the owner to the new player if they are first $ownerIsNewPlayerIfFirst + Not set the owner if the new player is not first $ownerIsNotChangedWhenANewPlayerJoinsAfterFirst + Validate a valid secret $validateValidSecret + Not validate a secret for a player that doesn't exist $noValidateSecretForNonExistentPlayer + Not validate a secret where the secret is wrong $noValidateSecretWhereWrong + Adding an AI should never result in a name clash $aiShouldAvoidNameClashes + Adding an AI should notify all clients $aiShouldNotifyAllClients + """ + + val playerName = "Player Name" + val playerName2 = "Player 2 Name" + + def notAllowNewPlayerWithSameNameAsExisting = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + players.addPlayer(playerName) + players.addPlayer(playerName) must throwA[BadRequestException] + } + + def notifyAllClientsOnNewPlayer = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + there was one(mockNotifiers).playerJoin(players.getPlayer(secret.id)) + } + + def ownerIsNewPlayerIfFirst = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + players.owner must_== Some(secret.id) + } + + /** + * Mostly this exists because we want to change this. + */ + def ownerIsNoOneUntilPlayerJoins = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + players.owner must_== None + } + + def ownerIsNotChangedWhenANewPlayerJoinsAfterFirst = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + players.addPlayer(playerName2) + players.owner must_== Some(secret.id) + } + + def validateValidSecret = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + players.validateSecret(secret) + success + } + + def noValidateSecretForNonExistentPlayer = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + players.validateSecret(Player.Secret(Player.Id(-1), secret.secret)) must throwA[ForbiddenException] + } + + def noValidateSecretWhereWrong = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addPlayer(playerName) + players.validateSecret(Player.Secret(secret.id, "wrong")) must throwA[ForbiddenException] + } + + def aiShouldAvoidNameClashes = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + players.addPlayer(Players.aiName) + for (name <- Players.aiNames) { + players.addPlayer(name) + } + players.addAi() + players.addAi() + success + } + + def aiShouldNotifyAllClients = { + val mockNotifiers = mock[Notifiers] + val players = new Players(mockNotifiers) + val secret = players.addAi() + there was one(mockNotifiers).playerJoin(Player(Player.Id(0), "Rando Cardrissian", Player.Neutral, 0, disconnected=false, left=false)) + there was one(mockNotifiers).playerStatus(Player.Id(0), Player.Ai) + } + +} diff --git a/test/matchers/Matchers.scala b/test/matchers/Matchers.scala new file mode 100644 index 0000000..a1e0247 --- /dev/null +++ b/test/matchers/Matchers.scala @@ -0,0 +1,19 @@ +package matchers + +import org.specs2.matcher.{Expectable, MatchResult, Matcher} + +object Matchers { + + class UniqueMatcher[E] extends Matcher[Traversable[E]] { + override def apply[S <: Traversable[E]](t: Expectable[S]): MatchResult[S] = + result( + t.value.size == t.value.toSet.size, + s"${t.description} contains no duplicate values", + s"${t.description} contains duplicate values", + t + ) + } + + def haveNoDuplicateValues[E] = new UniqueMatcher[E]() + +}