Work on #10 - Add skipping for disconnected players.

This commit is contained in:
Gareth Latty
2015-12-18 13:40:42 +00:00
parent 3278b42fa7
commit 00108e6edf
22 changed files with 419 additions and 165 deletions
@@ -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);
+86 -4
View File
@@ -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
+5 -8
View File
@@ -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!")
}