Add better comments, testing framework, some simple initial tests.

This commit is contained in:
Gareth Latty
2016-06-17 01:58:40 +01:00
parent b12b56a8d8
commit ff441c415f
23 changed files with 787 additions and 30 deletions
+18 -1
View File
@@ -20,12 +20,30 @@
<sourceFolder url="file://$MODULE_DIR$/../../target/scala-2.11/resource_managed/test" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/../../test/resources" type="java-test-resource" />
<excludeFolder url="file://$MODULE_DIR$/../../target/resolution-cache" />
<excludeFolder url="file://$MODULE_DIR$/../../target/specs2-reports" />
<excludeFolder url="file://$MODULE_DIR$/../../target/streams" />
<excludeFolder url="file://$MODULE_DIR$/../../target/test-reports" />
<excludeFolder url="file://$MODULE_DIR$/../../target/web" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="SBT: aopalliance:aopalliance:1.0:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.specs2:specs2-mock_2.11:3.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.specs2:specs2-matcher_2.11:3.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.specs2:specs2-junit_2.11:3.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.specs2:specs2-core_2.11:3.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.specs2:specs2-common_2.11:3.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scodec:scodec-bits_2.11:1.0.6:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scalaz.stream:scalaz-stream_2.11:0.7a:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scalaz:scalaz-effect_2.11:7.1.1:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scalaz:scalaz-core_2.11:7.1.1:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scalaz:scalaz-concurrent_2.11:7.1.1:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scala-lang.modules:scala-xml_2.11:1.0.2:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.scala-lang.modules:scala-parser-combinators_2.11:1.0.2:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.objenesis:objenesis:1.0:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: org.mockito:mockito-core:1.9.5:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: junit:junit:4.12:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: com.typesafe.play:play-specs2_2.11:2.4.4:jar" level="project" />
<orderEntry type="library" name="SBT: org.scala-lang:scala-library:2.11.8:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: cglib:cglib-nodep:2.1_3:jar" level="project" />
<orderEntry type="library" name="SBT: ch.qos.logback:logback-classic:1.1.3:jar" level="project" />
@@ -117,6 +135,5 @@
<orderEntry type="library" scope="TEST" name="SBT: xalan:xalan:2.7.1:jar" level="project" />
<orderEntry type="library" name="SBT: xerces:xercesImpl:2.11.0:jar" level="project" />
<orderEntry type="library" name="SBT: xml-apis:xml-apis:1.4.01:jar" level="project" />
<orderEntry type="library" scope="TEST" name="SBT: junit:junit:4.11:jar" level="project" />
</component>
</module>
@@ -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 =>
@@ -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
}
@@ -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"
@@ -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: _*)
@@ -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: _*)
@@ -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: _*))
}
@@ -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: _*))
}
@@ -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) {
+43 -11
View File
@@ -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)
}
+88 -2
View File
@@ -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
}
+161 -3
View File
@@ -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)
}
@@ -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",
@@ -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)
@@ -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.
@@ -1,4 +1,4 @@
package controllers.massivedecks.cardcast
package models.massivedecks.cardcast
import models.massivedecks.Game.{Call, DeckInfo, Response}
+2 -3
View File
@@ -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
@@ -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))
}
}
@@ -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()
}
}
@@ -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
"""
}
@@ -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
"""
}
@@ -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)
}
}
+19
View File
@@ -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]()
}