Add better comments, testing framework, some simple initial tests.
This commit is contained in:
Generated
+18
-1
@@ -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) {
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package controllers.massivedecks.cardcast
|
||||
package models.massivedecks.cardcast
|
||||
|
||||
import models.massivedecks.Game.{Call, DeckInfo, Response}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]()
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user