Work on #10 - Add skipping for disconnected players.
This commit is contained in:
@@ -19,14 +19,18 @@ jQuery(function($) {
|
||||
$('body').on('click', '.js-hide-scores', hideScores);
|
||||
});
|
||||
|
||||
function inviteOverlay(event) {
|
||||
function inviteOverlay() {
|
||||
mui.overlay('on', $('#invite')[0].cloneNode(true));
|
||||
}
|
||||
|
||||
function aboutOverlay(event) {
|
||||
function aboutOverlay() {
|
||||
mui.overlay('on', $('#about')[0].cloneNode(true));
|
||||
}
|
||||
|
||||
function toggleWarningDrawer() {
|
||||
$('#warning-drawer').toggleClass('shut');
|
||||
}
|
||||
|
||||
function start(url) {
|
||||
var socket = null;
|
||||
var gameCode = window.location.hash.substr(1);
|
||||
@@ -76,7 +80,6 @@ function start(url) {
|
||||
}
|
||||
|
||||
game.ports.subscription.subscribe(function (lobbyIdAndSecret) {
|
||||
console.log(lobbyIdAndSecret);
|
||||
if (lobbyIdAndSecret != null) {
|
||||
localStorage.setItem("existingGame", JSON.stringify(lobbyIdAndSecret))
|
||||
openWebSocket(lobbyIdAndSecret.lobbyId, lobbyIdAndSecret.secret);
|
||||
|
||||
@@ -251,6 +251,8 @@ ol.played {
|
||||
|
||||
transition: opacity 1s;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -291,7 +293,7 @@ body {
|
||||
overflow: auto;
|
||||
z-index: 2;
|
||||
background-color: #fff;
|
||||
transition: transform 0.2s;
|
||||
transition: transform @sidebar-transition-time;
|
||||
@media (min-width: @enhance-width) {
|
||||
transform: translate(@sidebar-width);
|
||||
}
|
||||
@@ -301,7 +303,7 @@ body {
|
||||
}
|
||||
|
||||
&.active {
|
||||
transform: translate(@sidebar-width);
|
||||
transform: translate(@sidebar-width) !important;
|
||||
}
|
||||
|
||||
& > table {
|
||||
@@ -321,7 +323,7 @@ body {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
&.skipping {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -345,6 +347,86 @@ body {
|
||||
|
||||
body.hide-scores {
|
||||
#scores {
|
||||
transform: translate(0px);
|
||||
transform: translate(0px);
|
||||
}
|
||||
}
|
||||
|
||||
#warning-drawer {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0px;
|
||||
|
||||
z-index: 100;
|
||||
|
||||
transition: opacity 1s, left @sidebar-transition-time;
|
||||
|
||||
max-height: 75%;
|
||||
|
||||
& > .contents {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
& > .toggle {
|
||||
float: right;
|
||||
margin: 5px;
|
||||
background-color: #FFCC00;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
& > .top {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
background-color: #FFCC00;
|
||||
}
|
||||
|
||||
&.shut {
|
||||
& > .top {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
& > .contents {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
left: @sidebar-width;
|
||||
}
|
||||
|
||||
#disconnected-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: #FFCC00;
|
||||
padding: 2px;
|
||||
|
||||
& > h3 {
|
||||
margin: 2px 5px;
|
||||
}
|
||||
|
||||
& > .actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
& > button {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.hide-scores {
|
||||
#warning-drawer {
|
||||
@media (min-width: @enhance-width) {
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ body {
|
||||
min-height: 100%;
|
||||
|
||||
margin-left: 0px;
|
||||
transition: margin-left @sidebar-transition-time;
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
margin-left: @sidebar-width;
|
||||
}
|
||||
@@ -35,7 +37,7 @@ header {
|
||||
}
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
transition: left 0.2s;
|
||||
transition: left @sidebar-transition-time;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
@@ -46,6 +48,15 @@ header {
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0px;
|
||||
|
||||
& > .score-buttons {
|
||||
position: relative;
|
||||
|
||||
& > .badge {
|
||||
position:absolute;
|
||||
top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,12 +75,6 @@ header {
|
||||
margin: 0px 5px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > ul > li > a > .fa {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
body.hide-scores {
|
||||
@@ -158,3 +163,7 @@ body.hide-scores {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@enhance-width: 768px;
|
||||
@sidebar-width: 200px;
|
||||
@sidebar-transition-time: 0.2s;
|
||||
|
||||
@card-width: 135px;
|
||||
@card-height: 160px;
|
||||
|
||||
@@ -28,6 +28,8 @@ object Actions {
|
||||
case class Play(secret: Secret, ids: List[Int]) extends Command
|
||||
case class Choose(secret: Secret, winner: Int) extends Command
|
||||
case class GetLobbyAndHand(secret: Secret) extends Command
|
||||
case class Skip(secret: Secret, players: List[Id]) extends Command
|
||||
case class Back(secret: Secret) extends Command
|
||||
|
||||
object Action {
|
||||
def apply(json: JsValue): Option[Command] =
|
||||
@@ -40,6 +42,8 @@ object Actions {
|
||||
case "play" => Json.fromJson[Play](command).asOpt
|
||||
case "choose" => Json.fromJson[Choose](command).asOpt
|
||||
case "getLobbyAndHand" => Json.fromJson[GetLobbyAndHand](command).asOpt
|
||||
case "skip" => Json.fromJson[Skip](command).asOpt
|
||||
case "back" => Json.fromJson[Back](command).asOpt
|
||||
case _ => None
|
||||
}
|
||||
})
|
||||
@@ -54,6 +58,8 @@ object Actions {
|
||||
implicit val playFormat: Format[Play] = Json.format[Play]
|
||||
implicit val chooseFormat: Format[Choose] = Json.format[Choose]
|
||||
implicit val getLobbyAndHandFormat: Format[GetLobbyAndHand] = Json.format[GetLobbyAndHand]
|
||||
implicit val skipFormat: Format[Skip] = Json.format[Skip]
|
||||
implicit val backFormat: Format[Back] = Json.format[Back]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,18 @@ class Game @Inject()(private val state: State, @Assisted private val id: String)
|
||||
Json.toJson(state.lobbyAndHand(secret))
|
||||
}
|
||||
|
||||
case Skip(secret, players) =>
|
||||
sender() ! Try {
|
||||
state.skip(secret, players)
|
||||
Json.toJson(state.lobbyAndHand(secret))
|
||||
}
|
||||
|
||||
case Back(secret) =>
|
||||
sender() ! Try {
|
||||
state.back(secret)
|
||||
Json.toJson(state.lobbyAndHand(secret))
|
||||
}
|
||||
|
||||
case _ =>
|
||||
sender() ! Try {
|
||||
throw new IllegalArgumentException("Unknown message: " + message)
|
||||
|
||||
@@ -3,9 +3,11 @@ package controllers.massivedecks.game
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
import scala.concurrent.Future
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.Random
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import akka.pattern.after
|
||||
import akka.actor.ActorRef
|
||||
import com.google.inject.assistedinject.Assisted
|
||||
import controllers.massivedecks.cardcast.{CardCastAPI, CardCastDeck}
|
||||
@@ -13,9 +15,11 @@ import models.massivedecks.Game._
|
||||
import models.massivedecks.Lobby.Formatters._
|
||||
import models.massivedecks.Lobby.{Lobby, LobbyAndHand}
|
||||
import models.massivedecks.Player._
|
||||
import play.api.libs.concurrent.Akka
|
||||
import play.api.libs.json.Json
|
||||
import play.api.Play.current
|
||||
|
||||
class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: String) {
|
||||
class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: String)(implicit ec: ExecutionContext) {
|
||||
private var decks: Set[CardCastDeck] = Set()
|
||||
private var players: List[Player] = List()
|
||||
private var lastPlayerId: Int = -1
|
||||
@@ -27,6 +31,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
private var czarIndex: Int = 0
|
||||
private var history: List[Round] = List()
|
||||
private var ais: Set[Secret] = Set()
|
||||
private var connected: Set[Id] = Set()
|
||||
|
||||
def config = Config(decks.map(deck => deck.info).toList)
|
||||
def lobby = Lobby(id, config, players, game.map(game => game.round))
|
||||
@@ -41,15 +46,25 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
}
|
||||
val secret = newPlayer(name)
|
||||
setPlayerStatus(secret.id, Ai, force=true)
|
||||
connected += secret.id
|
||||
ais += secret
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
private def setPlayerDisconnectedAfterGracePeriod(id: Id) = {
|
||||
after(State.disconnectGracePeriod, using=Akka.system.scheduler)(Future {
|
||||
if (setPlayerDisconnected(id, !connected.contains(id))) {
|
||||
sendNotifications()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
def newPlayer(name: String): Secret = {
|
||||
require(players.forall(player => player.name != name), "The name is already in use.")
|
||||
lastPlayerId += 1
|
||||
val id = Id(lastPlayerId)
|
||||
players = players ++ List(Player(id, name, Disconnected, 0))
|
||||
players = players ++ List(Player(id, name, Neutral, 0, disconnected=false, left=false))
|
||||
setPlayerDisconnectedAfterGracePeriod(id)
|
||||
val secret = Secret(id, UUID.randomUUID().toString)
|
||||
secrets += (id -> secret)
|
||||
if (game.isDefined) {
|
||||
@@ -87,13 +102,17 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
private val statusNotInRound: Set[Status] = Set(Left, Czar)
|
||||
private val statusNotInRound: Set[Status] = Set(Skipping, Czar)
|
||||
|
||||
def beginRound() = {
|
||||
for (player <- players) {
|
||||
setPlayerStatus(player.id, NotPlayed)
|
||||
}
|
||||
val round = game.get.round
|
||||
val czar = round.czar
|
||||
setPlayerStatus(czar, Czar)
|
||||
playedInRound = (for (player <- players if !statusNotInRound.contains(player.status)) yield player.id -> None).toMap
|
||||
playedInRound = (for (player <- players if !statusNotInRound.contains(player.status) && !player.left)
|
||||
yield player.id -> None).toMap
|
||||
val firstSlots = (0 until round.call.slots).toList
|
||||
for (ai <- ais) {
|
||||
play(ai, firstSlots)
|
||||
@@ -171,7 +190,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
|
||||
def leave(secret: Secret): Unit = {
|
||||
val id = validateSecretAndGetId(secret)
|
||||
setPlayerStatus(id, Left, force=true)
|
||||
setPlayerLeft(id, left=true)
|
||||
playedInRound = playedInRound.filterKeys(pId => pId != id)
|
||||
if (numberOfPlayers < State.minimumPlayers) {
|
||||
endGame()
|
||||
@@ -190,17 +209,52 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
def skip(secret: Secret, players: List[Id]): Unit = {
|
||||
validateSecretAndGetId(secret)
|
||||
require((numberOfPlayers - players.length) > State.minimumPlayers, "Not enough players to skip.")
|
||||
for (id <- players) {
|
||||
setPlayerStatus(id, Skipping)
|
||||
playedInRound = playedInRound.filterKeys(pId => pId != id)
|
||||
}
|
||||
game match {
|
||||
case Some(state) =>
|
||||
if (players.contains(state.round.czar)) {
|
||||
invalidateRound()
|
||||
} else {
|
||||
if (numberOfPlayersInRound == numberOfPlayersWhoHavePlayed) {
|
||||
beginJudging()
|
||||
}
|
||||
}
|
||||
case None =>
|
||||
}
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
def back(secret: Secret): Unit = {
|
||||
val id = validateSecretAndGetId(secret)
|
||||
val player = playerForId(id)
|
||||
require(player.status == Skipping, "You are not being skipped.")
|
||||
setPlayerStatus(id, Neutral, force=true)
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
def register(secret: Secret, socket: ActorRef): Unit = {
|
||||
val id = validateSecretAndGetId(secret)
|
||||
require(playerForId(id).status != Left, "You have left this game.")
|
||||
setPlayerStatus(id, playerStatusIfConnected(id), force=true)
|
||||
val player = playerForId(id)
|
||||
require(!player.left, "You have left this game.")
|
||||
setPlayerDisconnected(id, disconnected=false)
|
||||
connected += id
|
||||
if (player.status == Skipping) {
|
||||
back(secret)
|
||||
}
|
||||
notifications = socket :: notifications
|
||||
sendNotifications()
|
||||
}
|
||||
|
||||
def unregister(secret: Secret, socket: ActorRef): Unit = {
|
||||
val id = validateSecretAndGetId(secret)
|
||||
setPlayerStatus(id, Disconnected)
|
||||
connected -= id
|
||||
setPlayerDisconnectedAfterGracePeriod(id)
|
||||
notifications.filter(s => s != socket)
|
||||
sendNotifications()
|
||||
}
|
||||
@@ -228,34 +282,17 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
if (czarIndex >= players.length) {
|
||||
czarIndex = 0
|
||||
}
|
||||
val playerId = players(czarIndex).id
|
||||
val player = players(czarIndex)
|
||||
val playerId = player.id
|
||||
czarIndex += 1
|
||||
if (ais.exists(ai => ai.id == playerId) || playerForId(playerId).status == Left) {
|
||||
if (ais.exists(ai => ai.id == playerId) || player.left || player.status == Skipping) {
|
||||
nextCzar()
|
||||
} else {
|
||||
playerId
|
||||
}
|
||||
}
|
||||
|
||||
private def playerStatusIfConnected(id: Id): Status = {
|
||||
val player = playerForId(id)
|
||||
if (player.status == Left) { Left } else {
|
||||
game match {
|
||||
case Some(state) =>
|
||||
if (state.round.czar == id) {
|
||||
Czar
|
||||
} else {
|
||||
playedInRound.get(id) match {
|
||||
case Some(playedState) => if (playedState.isDefined) { Played } else { NotPlayed }
|
||||
case None => Neutral
|
||||
}
|
||||
}
|
||||
case None => Neutral
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def numberOfPlayers = players.count(player => player.status != Left)
|
||||
private def numberOfPlayers = players.count(player => !player.left)
|
||||
private def numberOfPlayersInRound = playedInRound.size
|
||||
private def numberOfPlayersWhoHavePlayed = playedInRound.values.count(play => play.isDefined)
|
||||
|
||||
@@ -274,26 +311,41 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
playedOrder = None
|
||||
playedInRound = Map()
|
||||
game = Some(state.copy(round = Round(nextCzar(), state.deck.drawCall(), Responses.hidden(0))))
|
||||
for (player <- players) {
|
||||
setPlayerStatus(player.id, NotPlayed)
|
||||
}
|
||||
beginRound()
|
||||
}
|
||||
|
||||
private val stickyStatus: Set[Status] = Set(Disconnected, Left, Ai)
|
||||
private val stickyStatus: Set[Status] = Set(Ai, Skipping)
|
||||
|
||||
private def setPlayerStatus(id: Id, status: Status, force: Boolean = false): Unit = {
|
||||
if (playerForId(id).status != Left) {
|
||||
players = players.map(player => if (player.id == id) {
|
||||
if (force || !stickyStatus.contains(player.status)) {
|
||||
if (!playerForId(id).left) {
|
||||
players = players.map(player =>
|
||||
if (player.id == id && (force || !stickyStatus.contains(player.status))) {
|
||||
player.copy(status = status)
|
||||
} else {
|
||||
player
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private def setPlayerDisconnected(id: Id, disconnected: Boolean): Boolean = {
|
||||
var changed = false
|
||||
players = players.map(player =>
|
||||
if (player.id == id) {
|
||||
changed = true
|
||||
player.copy(disconnected=disconnected)
|
||||
} else {
|
||||
player
|
||||
})
|
||||
changed
|
||||
}
|
||||
|
||||
private def setPlayerLeft(id: Id, left: Boolean): Unit = {
|
||||
players = players.map(player =>
|
||||
if (player.id == id) {
|
||||
player.copy(status=Neutral, left=left)
|
||||
} else {
|
||||
player
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private def validateInGameAndGetState(): GameState = game match {
|
||||
@@ -316,6 +368,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
|
||||
}
|
||||
object State {
|
||||
val minimumPlayers: Int = 2
|
||||
val disconnectGracePeriod: FiniteDuration = 5.seconds
|
||||
|
||||
trait Factory {
|
||||
def apply(id: String): State
|
||||
|
||||
@@ -10,7 +10,7 @@ object Player {
|
||||
val name: String
|
||||
}
|
||||
object Status {
|
||||
private val types = List(NotPlayed, Played, Czar, Disconnected, Left, Ai, Neutral)
|
||||
private val types = List(NotPlayed, Played, Czar, Ai, Neutral, Skipping)
|
||||
val fromName: Map[String, Status] = (for (status <- types) yield status.name -> status).toMap
|
||||
}
|
||||
case object NotPlayed extends Status {
|
||||
@@ -22,20 +22,17 @@ object Player {
|
||||
case object Czar extends Status {
|
||||
val name = "czar"
|
||||
}
|
||||
case object Disconnected extends Status {
|
||||
val name = "disconnected"
|
||||
}
|
||||
case object Left extends Status {
|
||||
val name = "left"
|
||||
}
|
||||
case object Ai extends Status {
|
||||
val name = "ai"
|
||||
}
|
||||
case object Neutral extends Status {
|
||||
val name = "neutral"
|
||||
}
|
||||
case object Skipping extends Status {
|
||||
val name = "skipping"
|
||||
}
|
||||
|
||||
case class Player(id: Id, name: String, status: Status, score: Int) {
|
||||
case class Player(id: Id, name: String, status: Status, score: Int, disconnected: Boolean, left: Boolean) {
|
||||
require(!name.isEmpty, "Name can't be empty!")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user