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]()
+
+}