content: /**
   * Copyright (c) 2012-2018, Andy Janata
   * All rights reserved.
   * <p>
   * Redistribution and use in source and binary forms, with or without modification, are permitted
   * provided that the following conditions are met:
   * <p>
   * * Redistributions of source code must retain the above copyright notice, this list of conditions
   * and the following disclaimer.
   * * Redistributions in binary form must reproduce the above copyright notice, this list of
   * conditions and the following disclaimer in the documentation and/or other materials provided
   * with the distribution.
   * <p>
   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
   * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
   * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
   * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
   * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
   * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
   * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
   */
  
  package net.socialgamer.cah.data;
  
  import com.google.inject.Inject;
  import com.google.inject.Provider;
  import net.socialgamer.cah.CahModule.*;
  import net.socialgamer.cah.Constants.*;
  import net.socialgamer.cah.cardcast.CardcastDeck;
  import net.socialgamer.cah.cardcast.CardcastService;
  import net.socialgamer.cah.data.GameManager.GameId;
  import net.socialgamer.cah.data.QueuedMessage.MessageType;
  import net.socialgamer.cah.metrics.Metrics;
  import net.socialgamer.cah.task.SafeTimerTask;
  import org.apache.commons.lang3.StringUtils;
  import org.apache.log4j.Logger;
  import org.hibernate.Session;
  
  import javax.annotation.Nonnull;
  import javax.annotation.Nullable;
  import java.util.*;
  import java.util.concurrent.ScheduledFuture;
  import java.util.concurrent.ScheduledThreadPoolExecutor;
  import java.util.concurrent.TimeUnit;
  
  
  /**
   * Game data and logic class. Games are simple finite state machines, with 3 states that wait for
   * user input, and 3 transient states that it quickly passes through on the way back to a waiting
   * state:
   *
   * ......Lobby.----------->.Dealing.(transient).-------->.Playing
   * .......^........................^.........................|....................
   * .......|.v----.Win.(transient).<+------.Judging.<---------+....................
   * .....Reset.(transient)
   *
   * Lobby is the default state. When the game host sends a start game event, the game moves to the
   * Dealing state, where it deals out cards to every player and automatically moves into the Playing
   * state. After all players have played a card, the game moves to Judging and waits for the judge to
   * pick a card. The game either moves to Win, if a player reached the win goal, or Dealing
   * otherwise. Win moves through Reset to reset the game back to default state. The game also
   * immediately moves through Reset at any point there are fewer than 3 players in the game.
   *
   * @author Andy Janata (ajanata@socialgamer.net)
   */
  public class Game {
      /**
       * The minimum number of black cards that must be added to a game for it to be able to start.
       */
      public final static int MINIMUM_BLACK_CARDS = 50;
      /**
       * The minimum number of white cards per player limit slots that must be added to a game for it to
       * be able to start.
       *
       * We need 20 * maxPlayers cards. This allows black cards up to "draw 9" to work correctly.
       */
      public final static int MINIMUM_WHITE_CARDS_PER_PLAYER = 20;
      private static final Logger logger = Logger.getLogger(Game.class);
      /**
       * Time, in milliseconds, to delay before starting a new round.
       */
      private final static int ROUND_INTERMISSION = 8 * 1000;
      /**
       * Duration, in milliseconds, for the minimum timeout a player has to choose a card to play.
       * Minimum 10 seconds.
       */
      private final static int PLAY_TIMEOUT_BASE = 45 * 1000;
      /**
       * Duration, in milliseconds, for the additional timeout a player has to choose a card to play,
       * for each card that must be played. For example, on a PICK 2 card, two times this amount of
       * time is added to {@code PLAY_TIMEOUT_BASE}.
       */
      private final static int PLAY_TIMEOUT_PER_CARD = 15 * 1000;
      /**
       * Duration, in milliseconds, for the minimum timeout a judge has to choose a winner.
       * Minimum combined of this and 2 * {@code JUDGE_TIMEOUT_PER_CARD} is 10 seconds.
       */
      private final static int JUDGE_TIMEOUT_BASE = 40 * 1000;
      /**
       * Duration, in milliseconds, for the additional timeout a judge has to choose a winning card,
       * for each additional card that was played in the round. For example, on a PICK 2 card with
       * 3 non-judge players, 6 times this value is added to {@code JUDGE_TIMEOUT_BASE}.
       */
      private final static int JUDGE_TIMEOUT_PER_CARD = 7 * 1000;
      private final static int MAX_SKIPS_BEFORE_KICK = 2;
      private final static Set<String> FINITE_PLAYTIMES;
  
      static {
          final Set<String> finitePlaytimes = new TreeSet<String>(Arrays.asList(
                  new String[]{"0.25x", "0.5x", "0.75x", "1x", "1.25x", "1.5x", "1.75x", "2x", "2.5x", "3x", "4x", "5x", "10x"}));
          FINITE_PLAYTIMES = Collections.unmodifiableSet(finitePlaytimes);
      }
  
      private final int id;
      /**
       * All players present in the game.
       */
      private final List<Player> players = Collections.synchronizedList(new ArrayList<Player>(10));
      /**
       * Players participating in the current round.
       */
      private final List<Player> roundPlayers = Collections.synchronizedList(new ArrayList<Player>(9));
      private final PlayerPlayedCardsTracker playedCards = new PlayerPlayedCardsTracker();
      private final List<User> spectators = Collections.synchronizedList(new ArrayList<User>(10));
      private final ConnectedUsers connectedUsers;
      private final GameManager gameManager;
      private final Provider<Session> sessionProvider;
      private final Object blackCardLock = new Object();
      private final GameOptions options;
      private final Set<String> cardcastDeckIds = Collections.synchronizedSet(new HashSet<String>());
      private final Metrics metrics;
      private final Provider<Boolean> showGameLinkProvider;
      private final Provider<String> gamePermalinkFormatProvider;
      private final Provider<Boolean> showRoundLinkProvider;
      private final Provider<String> roundPermalinkFormatProvider;
  
      // All of these delays could be moved to pyx.properties.
      private final Provider<Boolean> allowBlankCardsProvider;
      private final long created = System.currentTimeMillis();
      /**
       * Lock object to prevent judging during idle judge detection and vice-versa.
       */
      private final Object judgeLock = new Object();
      /**
       * Lock to prevent missing timer updates.
       */
      private final Object roundTimerLock = new Object();
      private final ScheduledThreadPoolExecutor globalTimer;
      private final Provider<CardcastService> cardcastServiceProvider;
      private final Provider<String> uniqueIdProvider;
      private Player host;
      private BlackDeck blackDeck;
      private BlackCard blackCard;
      private WhiteDeck whiteDeck;
      private GameState state;
      private int judgeIndex = 0;
      private volatile ScheduledFuture<?> lastScheduledFuture;
      private String currentUniqueId;
      /**
       * Sequence number of cards dealt. This allows re-shuffles and re-deals to still be tracked as
       * unique card deals.
       */
      private long dealSeq = 0;
  
      /**
       * Create a new game.
       *
       * @param id
       *          The game's ID.
       * @param connectedUsers
       *          The user manager, for broadcasting messages.
       * @param gameManager
       *          The game manager, for broadcasting game list refresh notices and destroying this game
       *          when everybody leaves.
       * @param hibernateSession Hibernate session from which to load cards.
       * @param globalTimer The global timer on which to schedule tasks.
       */
      @Inject
      public Game(@GameId final Integer id, final ConnectedUsers connectedUsers,
                  final GameManager gameManager, final ScheduledThreadPoolExecutor globalTimer,
                  final Provider<Session> sessionProvider,
                  final Provider<CardcastService> cardcastServiceProvider,
                  @UniqueId final Provider<String> uniqueIdProvider,
                  final Metrics metrics, @ShowRoundPermalink final Provider<Boolean> showRoundLinkProvider,
                  @RoundPermalinkUrlFormat final Provider<String> roundPermalinkFormatProvider,
                  @ShowGamePermalink final Provider<Boolean> showGameLinkProvider,
                  @GamePermalinkUrlFormat final Provider<String> gamePermalinkFormatProvider,
                  @AllowBlankCards final Provider<Boolean> allowBlankCardsProvider,
                  final Provider<GameOptions> gameOptionsProvider) {
          this.id = id;
          this.options = gameOptionsProvider.get();
          this.connectedUsers = connectedUsers;
          this.gameManager = gameManager;
          this.globalTimer = globalTimer;
          this.sessionProvider = sessionProvider;
          this.cardcastServiceProvider = cardcastServiceProvider;
          this.uniqueIdProvider = uniqueIdProvider;
          this.metrics = metrics;
          this.showRoundLinkProvider = showRoundLinkProvider;
          this.roundPermalinkFormatProvider = roundPermalinkFormatProvider;
          this.showGameLinkProvider = showGameLinkProvider;
          this.gamePermalinkFormatProvider = gamePermalinkFormatProvider;
          this.allowBlankCardsProvider = allowBlankCardsProvider;
  
          state = GameState.LOBBY;
      }
  
      /**
       * Adds a permalink to this game to the client request response data, if said permalinks are
       * enabled.
       * @param data A map of data being returned to a client request.
       */
      public void maybeAddPermalinkToData(final Map<ReturnableData, Object> data) {
          if (showGameLinkProvider.get() && null != currentUniqueId) {
              data.put(AjaxResponse.GAME_PERMALINK,
                      String.format(gamePermalinkFormatProvider.get(), currentUniqueId));
          }
      }
  
      /**
       * Add a player to the game.
       *
       * Synchronizes on {@link #players}.
       *
       * @param user
       *          Player to add to this game.
       * @throws TooManyPlayersException
       *           Thrown if this game is at its maximum player capacity.
       * @throws IllegalStateException
       *           Thrown if {@code user} is already in a game.
       */
      public void addPlayer(final User user) throws TooManyPlayersException, IllegalStateException {
          logger.info(String.format("%s joined game %d.", user.toString(), id));
          synchronized (players) {
              if (options.playerLimit >= 3 && players.size() >= options.playerLimit) {
                  throw new TooManyPlayersException();
              }
              // this will throw IllegalStateException if the user is already in a game, including this one.
              user.joinGame(this);
              final Player player = new Player(user);
              players.add(player);
              if (host == null) {
                  host = player;
              }
          }
  
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_JOIN.toString());
          data.put(LongPollResponse.NICKNAME, user.getNickname());
          broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data);
  
          // Don't do this anymore, it was driving up a crazy amount of traffic.
          // gameManager.broadcastGameListRefresh();
      }
  
      /**
       * Remove a player from the game.
       * <br/>
       * Synchronizes on {@link #players}, {@link #playedCards}, {@link #whiteDeck}, and
       * {@link #roundTimerLock}.
       *
       * @param user
       *          Player to remove from the game.
       * @return True if {@code user} was the last player in the game.
       */
      public boolean removePlayer(final User user) {
          logger.info(String.format("Removing %s from game %d.", user.toString(), id));
          boolean wasJudge = false;
          final Player player = getPlayerForUser(user);
  
          if (null != player) {
              HashMap<ReturnableData, Object> data;
              // If they played this round, remove card from played card list.
              final List<WhiteCard> cards = playedCards.remove(player);
              if (cards != null && cards.size() > 0) {
                  for (final WhiteCard card : cards) {
                      whiteDeck.discard(card);
                  }
              }
              // If they are to play this round, remove them from that list.
              if (roundPlayers.remove(player)) {
                  if (startJudging()) {
                      judgingState();
                  }
              }
              // If they have a hand, return it to discard pile.
              if (player.getHand().size() > 0) {
                  final List<WhiteCard> hand = player.getHand();
                  for (final WhiteCard card : hand) {
                      whiteDeck.discard(card);
                  }
              }
              // If they are judge, return all played cards to hand, and move to next judge.
              if (getJudge() == player && (state == GameState.PLAYING || state == GameState.JUDGING)) {
                  data = getEventMap();
                  data.put(LongPollResponse.EVENT, LongPollEvent.GAME_JUDGE_LEFT.toString());
                  data.put(LongPollResponse.INTERMISSION, ROUND_INTERMISSION);
                  broadcastToPlayers(MessageType.GAME_EVENT, data);
                  returnCardsToHand();
                  // startNextRound will advance it again.
                  judgeIndex--;
                  // Can't start the next round right here.
                  wasJudge = true;
              }
              // If they aren't judge but are earlier in judging order, fix the judge index.
              else if (players.indexOf(player) < judgeIndex) {
                  judgeIndex--;
              }
  
              // we can't actually remove them until down here because we need to deal with the judge
              // index stuff first.
              players.remove(player);
              user.leaveGame(this);
  
              // do this down here so the person that left doesn't get the notice too
              data = getEventMap();
              data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_LEAVE.toString());
              data.put(LongPollResponse.NICKNAME, user.getNickname());
              broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data);
  
              // Don't do this anymore, it was driving up a crazy amount of traffic.
              // gameManager.broadcastGameListRefresh();
  
              if (host == player) {
                  if (players.size() > 0) {
                      host = players.get(0);
                  } else {
                      host = null;
                  }
              }
              // this seems terrible
              if (players.size() == 0) {
                  gameManager.destroyGame(id);
              }
              if (players.size() < 3 && state != GameState.LOBBY) {
                  logger.info(String.format("Resetting game %d due to too few players after someone left.",
                          id));
                  resetState(true);
              } else if (wasJudge) {
                  synchronized (roundTimerLock) {
                      final SafeTimerTask task = new SafeTimerTask() {
                          @Override
                          public void process() {
                              startNextRound();
                          }
                      };
                      rescheduleTimer(task, ROUND_INTERMISSION);
                  }
              }
              return players.size() == 0;
          }
          return false;
      }
  
      /**
       * Add a spectator to the game.
       *
       * Synchronizes on {@link #spectators}.
       *
       * @param user
       *          Spectator to add to this game.
       * @throws TooManySpectatorsException
       *           Thrown if this game is at its maximum spectator capacity.
       * @throws IllegalStateException
       *           Thrown if {@code user} is already in a game.
       */
      public void addSpectator(final User user) throws TooManySpectatorsException,
              IllegalStateException {
          logger.info(String.format("%s joined game %d as a spectator.", user.toString(), id));
          synchronized (spectators) {
              if (spectators.size() >= options.spectatorLimit) {
                  throw new TooManySpectatorsException();
              }
              // this will throw IllegalStateException if the user is already in a game, including this one.
              user.joinGame(this);
              spectators.add(user);
          }
  
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_SPECTATOR_JOIN.toString());
          data.put(LongPollResponse.NICKNAME, user.getNickname());
          broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data);
  
          gameManager.broadcastGameListRefresh();
      }
  
      /**
       * Remove a spectator from the game.
       * <br/>
       * Synchronizes on {@link #spectator}.
       *
       * @param user
       *          Spectator to remove from the game.
       */
      public void removeSpectator(final User user) {
          logger.info(String.format("Removing spectator %s from game %d.", user.toString(), id));
          synchronized (spectators) {
              if (!spectators.remove(user)) {
                  return;
              } // not actually spectating
              user.leaveGame(this);
          }
  
          // do this down here so the person that left doesn't get the notice too
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_SPECTATOR_LEAVE.toString());
          data.put(LongPollResponse.NICKNAME, user.getNickname());
          broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data);
  
          // Don't do this anymore, it was driving up a crazy amount of traffic.
          // gameManager.broadcastGameListRefresh();
      }
  
      /**
       * Return all played cards to their respective player's hand.
       * <br/>
       * Synchronizes on {@link #playedCards}.
       */
      private void returnCardsToHand() {
          synchronized (playedCards) {
              for (final Player p : playedCards.playedPlayers()) {
                  p.getHand().addAll(playedCards.getCards(p));
                  sendCardsToPlayer(p, playedCards.getCards(p));
              }
              // prevent startNextRound from discarding cards
              playedCards.clear();
          }
      }
  
      /**
       * Broadcast a message to all players in this game.
       *
       * @param type
       *          Type of message to broadcast. This determines the order the messages are returned by
       *          priority.
       * @param masterData
       *          Message data to broadcast.
       */
      public void broadcastToPlayers(final MessageType type,
                                     final HashMap<ReturnableData, Object> masterData) {
          connectedUsers.broadcastToList(playersToUsers(), type, masterData);
      }
  
      /**
       * Sends updated player information about a specific player to all players in the game.
       *
       * @param player
       *          The player whose information has been changed.
       */
      public void notifyPlayerInfoChange(final Player player) {
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_INFO_CHANGE.toString());
          data.put(LongPollResponse.PLAYER_INFO, getPlayerInfo(player));
          broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data);
      }
  
      /**
       * Sends updated game information to all players in the game.
       */
      private void notifyGameOptionsChanged() {
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_OPTIONS_CHANGED.toString());
          data.put(LongPollResponse.GAME_INFO, getInfo(true));
          broadcastToPlayers(MessageType.GAME_EVENT, data);
      }
  
      /**
       * @return The game's current state.
       */
      public GameState getState() {
          return state;
      }
  
      /**
       * @return The {@code User} who is the host of this game.
       */
      public User getHost() {
          if (host == null) {
              return null;
          }
          return host.getUser();
      }
  
      /**
       * @return All {@code User}s in this game.
       */
      public List<User> getUsers() {
          return playersToUsers();
      }
  
      /**
       * @return This game's ID.
       */
      public int getId() {
          return id;
      }
  
      public String getPassword() {
          return options.password;
      }
  
      public void updateGameSettings(final GameOptions newOptions) {
          this.options.update(newOptions);
          notifyGameOptionsChanged();
      }
  
      public Set<String> getCardcastDeckIds() {
          return cardcastDeckIds;
      }
  
      /**
       * Get information about this game, without the game's password.
       * <br/>
       * Synchronizes on {@link #players}.
       * @return This game's general information: ID, host, state, player list, etc.
       */
      @Nullable
      public Map<GameInfo, Object> getInfo() {
          return getInfo(false);
      }
  
      /**
       * Get information about this game.
       * <br/>
       * Synchronizes on {@link #players}.
       * @param includePassword
       *          Include the actual password with the information. This should only be
       *          sent to people in the game.
       * @return This game's general information: ID, host, state, player list, etc.
       */
      @Nullable
      public Map<GameInfo, Object> getInfo(final boolean includePassword) {
          final Map<GameInfo, Object> info = new HashMap<GameInfo, Object>();
          info.put(GameInfo.ID, id);
          // This is probably happening because the game ceases to exist in the middle of getting the
          // game list. Just return nothing.
          if (null == host) {
              return null;
          }
          info.put(GameInfo.CREATED, created);
          info.put(GameInfo.HOST, host.getUser().getNickname());
          info.put(GameInfo.STATE, state.toString());
          info.put(GameInfo.GAME_OPTIONS, options.serialize(includePassword));
          info.put(GameInfo.HAS_PASSWORD, options.password != null && !options.password.equals(""));
  
          final Player[] playersCopy = players.toArray(new Player[players.size()]);
          final List<String> playerNames = new ArrayList<String>(playersCopy.length);
          for (final Player player : playersCopy) {
              playerNames.add(player.getUser().getNickname());
          }
          info.put(GameInfo.PLAYERS, playerNames);
  
          final User[] spectatorsCopy = spectators.toArray(new User[spectators.size()]);
          final List<String> spectatorNames = new ArrayList<String>(spectatorsCopy.length);
          for (final User spectator : spectatorsCopy) {
              spectatorNames.add(spectator.getNickname());
          }
          info.put(GameInfo.SPECTATORS, spectatorNames);
  
          return info;
      }
  
      /**
       * Synchronizes on {@link #players}.
       * @return Player information for every player in this game: Name, score, status.
       */
      public List<Map<GamePlayerInfo, Object>> getAllPlayerInfo() {
          final List<Map<GamePlayerInfo, Object>> info;
          final Player[] playersCopy = players.toArray(new Player[players.size()]);
          info = new ArrayList<Map<GamePlayerInfo, Object>>(playersCopy.length);
          for (final Player player : playersCopy) {
              final Map<GamePlayerInfo, Object> playerInfo = getPlayerInfo(player);
              info.add(playerInfo);
          }
          return info;
      }
  
      public final List<Player> getPlayers() {
          final List<Player> copy = new ArrayList<Player>(players.size());
          copy.addAll(players);
          return copy;
      }
  
      /**
       * Get player information for a single player.
       *
       * @param player
       *          The player for whom to get status.
       * @return Information for {@code player}: Name, score, status.
       */
      public Map<GamePlayerInfo, Object> getPlayerInfo(final Player player) {
          final Map<GamePlayerInfo, Object> playerInfo = new HashMap<GamePlayerInfo, Object>();
          // TODO make sure this can't happen in the first place
          if (player == null) {
              return playerInfo;
          }
          playerInfo.put(GamePlayerInfo.NAME, player.getUser().getNickname());
          playerInfo.put(GamePlayerInfo.SCORE, player.getScore());
          playerInfo.put(GamePlayerInfo.STATUS, getPlayerStatus(player).toString());
  
          return playerInfo;
      }
  
      /**
       * Determine the player status for a given player, based on game state.
       *
       * @param player
       *          Player for whom to get the state.
       * @return The state of {@code player}, one of {@code HOST}, {@code IDLE}, {@code JUDGE},
       *         {@code PLAYING}, {@code JUDGING}, or {@code WINNER}, depending on the game's state and
       *         what the player has done.
       */
      private GamePlayerStatus getPlayerStatus(final Player player) {
          final GamePlayerStatus playerStatus;
  
          switch (state) {
              case LOBBY:
                  if (host == player) {
                      playerStatus = GamePlayerStatus.HOST;
                  } else {
                      playerStatus = GamePlayerStatus.IDLE;
                  }
                  break;
              case PLAYING:
                  if (getJudge() == player) {
                      playerStatus = GamePlayerStatus.JUDGE;
                  } else {
                      if (!roundPlayers.contains(player)) {
                          playerStatus = GamePlayerStatus.IDLE;
                          break;
                      }
                      final List<WhiteCard> playerCards = playedCards.getCards(player);
                      if (playerCards != null && blackCard != null
                              && playerCards.size() == blackCard.getPick()) {
                          playerStatus = GamePlayerStatus.IDLE;
                      } else {
                          playerStatus = GamePlayerStatus.PLAYING;
                      }
                  }
                  break;
              case JUDGING:
                  if (getJudge() == player) {
                      playerStatus = GamePlayerStatus.JUDGING;
                  } else {
                      playerStatus = GamePlayerStatus.IDLE;
                  }
                  break;
              case ROUND_OVER:
                  if (getJudge() == player) {
                      playerStatus = GamePlayerStatus.JUDGE;
                  }
                  // TODO win-by-x
                  else if (player.getScore() >= options.scoreGoal) {
                      playerStatus = GamePlayerStatus.WINNER;
                  } else {
                      playerStatus = GamePlayerStatus.IDLE;
                  }
                  break;
              default:
                  throw new IllegalStateException("Unknown GameState " + state.toString());
          }
          return playerStatus;
      }
  
      /**
       * Start the game, if there are at least 3 players present. This does not do any access checking!
       * <br/>
       * Synchronizes on {@link #players}.
       *
       * @return True if the game is started. Would only be false if there aren't enough players, or the
       *         game is already started, or doesn't have enough cards, but hopefully callers and
       *         clients would prevent that from happening!
       */
      public boolean start() {
          Session session = null;
          try {
              session = sessionProvider.get();
              if (state != GameState.LOBBY || !hasEnoughCards(session)) {
                  return false;
              }
              boolean started;
              final int numPlayers = players.size();
              if (numPlayers >= 3) {
                  // Pick a random start judge, though the "next" judge will actually go first.
                  judgeIndex = (int) (Math.random() * numPlayers);
                  started = true;
              } else {
                  started = false;
              }
              if (started) {
                  currentUniqueId = uniqueIdProvider.get();
                  logger.info(String.format("Starting game %d with card sets %s, Cardcast %s, %d blanks, %d "
                                  + "max players, %d max spectators, %d score limit, players %s, unique %s.",
                          id, options.cardSetIds, cardcastDeckIds, options.blanksInDeck, options.playerLimit,
                          options.spectatorLimit, options.scoreGoal, players, currentUniqueId));
                  // do this stuff outside the players lock; they will lock players again later for much less
                  // time, and not at the same time as trying to lock users, which has caused deadlocks
                  final List<CardSet> cardSets;
                  synchronized (options.cardSetIds) {
                      cardSets = loadCardSets(session);
                      blackDeck = loadBlackDeck(cardSets);
                      whiteDeck = loadWhiteDeck(cardSets);
                  }
                  metrics.gameStart(currentUniqueId, cardSets, options.blanksInDeck, options.playerLimit,
                          options.scoreGoal, !StringUtils.isBlank(options.password));
                  startNextRound();
                  gameManager.broadcastGameListRefresh();
              }
              return started;
          } finally {
              if (null != session) {
                  session.close();
              }
          }
      }
  
      public List<CardSet> loadCardSets(final Session session) {
          synchronized (options.cardSetIds) {
              try {
                  final List<CardSet> cardSets = new ArrayList<>();
  
                  if (!options.getPyxCardSetIds().isEmpty()) {
                      @SuppressWarnings("unchecked") final List<CardSet> pyxCardSets = session
                              .createQuery("from PyxCardSet where id in (:ids)")
                              .setParameterList("ids", options.getPyxCardSetIds()).list();
                      cardSets.addAll(pyxCardSets);
                  }
  
                  // Not injecting the service itself because we might need to assisted inject it later
                  // with card id stuff.
                  // also TODO maybe make card ids longs instead of ints
                  final CardcastService service = cardcastServiceProvider.get();
  
                  // Avoid ConcurrentModificationException
                  for (final String cardcastId : cardcastDeckIds.toArray(new String[0])) {
                      // Ideally, we can assume that anything in that set is going to load, but it is entirely
                      // possible that the cache has expired and we can't re-load it for some reason, so
                      // let's be safe.
                      final CardcastDeck cardcastDeck = service.loadSet(cardcastId);
                      if (null == cardcastDeck) {
                          // TODO better way to indicate this to the user
                          logger.error(String.format("Unable to load %s from Cardcast", cardcastId));
                          return null;
                      }
                      cardSets.add(cardcastDeck);
                  }
  
                  return cardSets;
              } catch (final Exception e) {
                  logger.error(String.format("Unable to load cards for game %d", id), e);
                  return null;
              }
          }
      }
  
      public BlackDeck loadBlackDeck(final List<CardSet> cardSets) {
          return new BlackDeck(cardSets);
      }
  
      public WhiteDeck loadWhiteDeck(final List<CardSet> cardSets) {
          return new WhiteDeck(options.maxBlankCardLimit, cardSets, allowBlankCardsProvider.get() ? options.blanksInDeck : 0);
      }
  
      public int getRequiredWhiteCardCount() {
          return MINIMUM_WHITE_CARDS_PER_PLAYER * options.playerLimit;
      }
  
      /**
       * Determine if there are sufficient cards in the selected card sets to start the game.
       */
      public boolean hasEnoughCards(final Session session) {
          synchronized (options.cardSetIds) {
              final List<CardSet> cardSets = loadCardSets(session);
  
              if (cardSets.isEmpty()) {
                  return false;
              }
  
              final BlackDeck tempBlackDeck = loadBlackDeck(cardSets);
              if (tempBlackDeck.totalCount() < MINIMUM_BLACK_CARDS) {
                  return false;
              }
  
              final WhiteDeck tempWhiteDeck = loadWhiteDeck(cardSets);
              if (tempWhiteDeck.totalCount() < getRequiredWhiteCardCount()) {
                  return false;
              }
  
              return true;
          }
      }
  
      /**
       * Deal cards for this round. The game immediately then moves into the {@code PLAYING} state.
       * <br/>
       */
      private void dealState() {
          final Player[] playersCopy = players.toArray(new Player[players.size()]);
          for (final Player player : playersCopy) {
              final List<WhiteCard> hand = player.getHand();
              final List<WhiteCard> newCards = new LinkedList<WhiteCard>();
              while (hand.size() < 10) {
                  final WhiteCard card = getNextWhiteCard();
                  hand.add(card);
                  newCards.add(card);
                  metrics.cardDealt(currentUniqueId, player.getUser().getSessionId(), card, dealSeq++);
              }
              sendCardsToPlayer(player, newCards);
          }
          playingState();
      }
  
      /**
       * Move the game into the {@code PLAYING} state, drawing a new Black Card and dispatching a
       * message to all players.
       * <br/>
       * Synchronizes on {@link #players}, {@link #blackCardLock}, and {@link #roundTimerLock}.
       */
      private void playingState() {
          final GameState oldState = state;
          state = GameState.PLAYING;
  
          playedCards.clear();
  
          BlackCard newBlackCard;
  
          synchronized (blackCardLock) {
              if (blackCard != null) {
                  blackDeck.discard(blackCard);
              }
              newBlackCard = blackCard = getNextBlackCard();
          }
          if (newBlackCard.getDraw() > 0) {
              synchronized (players) {
                  for (final Player player : players) {
                      if (getJudge() == player) {
                          continue;
                      }
                      final List<WhiteCard> cards = new ArrayList<WhiteCard>(newBlackCard.getDraw());
                      for (int i = 0; i < newBlackCard.getDraw(); i++) {
                          cards.add(getNextWhiteCard());
                      }
                      player.getHand().addAll(cards);
                      sendCardsToPlayer(player, cards);
                  }
              }
          }
  
          // Perhaps figure out a better way to do this...
          final int playTimer = calculateTime(PLAY_TIMEOUT_BASE + (PLAY_TIMEOUT_PER_CARD * blackCard.getPick()));
  
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_STATE_CHANGE.toString());
          data.put(LongPollResponse.BLACK_CARD, getBlackCard());
          data.put(LongPollResponse.GAME_STATE, GameState.PLAYING.toString());
          data.put(LongPollResponse.PLAY_TIMER, playTimer);
          // if we're moving from lobby to playing, this is the first round
          if (GameState.LOBBY == oldState) {
              maybeAddPermalinkToData(data);
          }
  
          broadcastToPlayers(MessageType.GAME_EVENT, data);
  
          synchronized (roundTimerLock) {
              final SafeTimerTask task = new SafeTimerTask() {
                  @Override
                  public void process() {
                      warnPlayersToPlay();
                  }
              };
              // 10 second warning
              rescheduleTimer(task, playTimer - 10 * 1000);
          }
      }
  
      private int calculateTime(final int base) {
          double factor = 1.0d;
          final String tm = options.timerMultiplier;
  
          if (tm.equals("Unlimited")) {
              return Integer.MAX_VALUE;
          }
  
          if (FINITE_PLAYTIMES.contains(tm)) {
              factor = Double.valueOf(tm.substring(0, tm.length() - 1));
          }
  
          final long retval = Math.round(base * factor);
  
          if (retval > Integer.MAX_VALUE) {
              return Integer.MAX_VALUE;
          }
  
          return (int) retval;
      }
  
      /**
       * Warn players that have not yet played that they are running out of time to do so.
       * <br/>
       * Synchronizes on {@link #roundTimerLock} and {@link #roundPlayers}.
       */
      private void warnPlayersToPlay() {
          // have to do this all synchronized in case they play while we're processing this
          synchronized (roundTimerLock) {
              killRoundTimer();
  
              synchronized (roundPlayers) {
                  for (final Player player : roundPlayers) {
                      final List<WhiteCard> cards = playedCards.getCards(player);
                      if (cards == null || cards.size() < blackCard.getPick()) {
                          final Map<ReturnableData, Object> data = new HashMap<ReturnableData, Object>();
                          data.put(LongPollResponse.EVENT, LongPollEvent.HURRY_UP.toString());
                          data.put(LongPollResponse.GAME_ID, this.id);
                          final QueuedMessage q = new QueuedMessage(MessageType.GAME_EVENT, data);
                          player.getUser().enqueueMessage(q);
                      }
                  }
              }
  
              final SafeTimerTask task = new SafeTimerTask() {
                  @Override
                  public void process() {
                      skipIdlePlayers();
                  }
              };
              // 10 seconds to finish playing
              rescheduleTimer(task, 10 * 1000);
          }
      }
  
      private void warnJudgeToJudge() {
          // have to do this all synchronized in case they play while we're processing this
          synchronized (roundTimerLock) {
              killRoundTimer();
  
              if (state == GameState.JUDGING) {
                  final Map<ReturnableData, Object> data = new HashMap<ReturnableData, Object>();
                  data.put(LongPollResponse.EVENT, LongPollEvent.HURRY_UP.toString());
                  data.put(LongPollResponse.GAME_ID, this.id);
                  final QueuedMessage q = new QueuedMessage(MessageType.GAME_EVENT, data);
                  getJudge().getUser().enqueueMessage(q);
              }
  
              final SafeTimerTask task = new SafeTimerTask() {
                  @Override
                  public void process() {
                      skipIdleJudge();
                  }
              };
              // 10 seconds to finish playing
              rescheduleTimer(task, 10 * 1000);
          }
      }
  
      private void skipIdleJudge() {
          killRoundTimer();
          // prevent them from playing a card while we kick them (or us kicking them while they play!)
          synchronized (judgeLock) {
              if (state != GameState.JUDGING) {
                  return;
              }
              // Not sure why this would happen but it has happened before.
              // I guess they disconnected at the exact wrong time?
              final Player judge = getJudge();
              String judgeName = "[unknown]";
              if (judge != null) {
                  judge.skipped();
                  judgeName = judge.getUser().getNickname();
              }
              logger.info(String.format("Skipping idle judge %s in game %d", judgeName, id));
              final HashMap<ReturnableData, Object> data = getEventMap();
              data.put(LongPollResponse.EVENT, LongPollEvent.GAME_JUDGE_SKIPPED.toString());
              broadcastToPlayers(MessageType.GAME_EVENT, data);
              returnCardsToHand();
              startNextRound();
          }
      }
  
      private void skipIdlePlayers() {
          killRoundTimer();
          final List<User> playersToRemove = new ArrayList<User>();
          final List<Player> playersToUpdateStatus = new ArrayList<Player>();
          synchronized (roundPlayers) {
  
              for (final Player player : roundPlayers) {
                  final List<WhiteCard> cards = playedCards.getCards(player);
                  if (cards == null || cards.size() < blackCard.getPick()) {
                      logger.info(String.format("Skipping idle player %s in game %d.", player, id));
                      player.skipped();
  
                      final HashMap<ReturnableData, Object> data = getEventMap();
                      data.put(LongPollResponse.NICKNAME, player.getUser().getNickname());
                      if (player.getSkipCount() >= MAX_SKIPS_BEFORE_KICK || playedCards.size() < 2) {
                          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_KICKED_IDLE.toString());
                          playersToRemove.add(player.getUser());
                      } else {
                          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_SKIPPED.toString());
                          playersToUpdateStatus.add(player);
                      }
                      broadcastToPlayers(MessageType.GAME_EVENT, data);
  
                      // put their cards back
                      final List<WhiteCard> returnCards = playedCards.remove(player);
                      if (returnCards != null) {
                          player.getHand().addAll(returnCards);
                          sendCardsToPlayer(player, returnCards);
                      }
                  }
              }
          }
  
          for (final User user : playersToRemove) {
              removePlayer(user);
              final HashMap<ReturnableData, Object> data = getEventMap();
              data.put(LongPollResponse.EVENT, LongPollEvent.KICKED_FROM_GAME_IDLE.toString());
              final QueuedMessage q = new QueuedMessage(MessageType.GAME_PLAYER_EVENT, data);
              user.enqueueMessage(q);
          }
  
          synchronized (playedCards) {
              if (state == GameState.PLAYING || playersToRemove.size() == 0) {
                  // not sure how much of this check is actually required
                  if (players.size() < 3 || playedCards.size() < 2) {
                      logger.info(String.format(
                              "Resetting game %d due to insufficient players after removing %d idle players.",
                              id, playersToRemove.size()));
                      resetState(true);
                  } else {
                      judgingState();
                  }
              }
          }
  
          // have to do this after we move to judging state
          for (final Player player : playersToUpdateStatus) {
              notifyPlayerInfoChange(player);
          }
      }
  
      private void killRoundTimer() {
          synchronized (roundTimerLock) {
              if (null != lastScheduledFuture) {
                  logger.trace(String.format("Killing timer task %s", lastScheduledFuture));
                  lastScheduledFuture.cancel(false);
                  lastScheduledFuture = null;
              }
          }
      }
  
      private void rescheduleTimer(final SafeTimerTask task, final long timeout) {
          synchronized (roundTimerLock) {
              killRoundTimer();
              logger.trace(String.format("Scheduling timer task %s after %d ms", task, timeout));
              lastScheduledFuture = globalTimer.schedule(task, timeout, TimeUnit.MILLISECONDS);
          }
      }
  
      /**
       * Move the game into the {@code JUDGING} state.
       */
      private void judgingState() {
          killRoundTimer();
          state = GameState.JUDGING;
  
          // Perhaps figure out a better way to do this...
          final int judgeTimer = calculateTime(JUDGE_TIMEOUT_BASE + (JUDGE_TIMEOUT_PER_CARD * playedCards.size() * blackCard.getPick()));
  
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_STATE_CHANGE.toString());
          data.put(LongPollResponse.GAME_STATE, GameState.JUDGING.toString());
          data.put(LongPollResponse.WHITE_CARDS, getWhiteCards());
          data.put(LongPollResponse.PLAY_TIMER, judgeTimer);
          broadcastToPlayers(MessageType.GAME_EVENT, data);
  
          notifyPlayerInfoChange(getJudge());
  
          synchronized (roundTimerLock) {
              final SafeTimerTask task = new SafeTimerTask() {
                  @Override
                  public void process() {
                      warnJudgeToJudge();
                  }
              };
              // 10 second warning
              rescheduleTimer(task, judgeTimer - 10 * 1000);
          }
      }
  
      /**
       * Move the game into the {@code WIN} state, which really just moves into the game reset logic.
       */
      private void winState() {
          resetState(false);
      }
  
      /**
       * Reset the game state to a lobby.
       *
       * TODO change the message sent to the client if the game reset due to insufficient players.
       *
       * @param lostPlayer
       *          True if because there are no long enough people to play a game, false if because the
       *          previous game finished.
       */
      public void resetState(final boolean lostPlayer) {
          logger.info(String.format("Resetting game %d to lobby (lostPlayer=%b)", id, lostPlayer));
          killRoundTimer();
          synchronized (players) {
              for (final Player player : players) {
                  player.getHand().clear();
                  player.resetScore();
              }
          }
          whiteDeck = null;
          blackDeck = null;
          synchronized (blackCardLock) {
              blackCard = null;
          }
          playedCards.clear();
          roundPlayers.clear();
          state = GameState.LOBBY;
          currentUniqueId = null;
          final Player judge = getJudge();
          judgeIndex = 0;
  
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_STATE_CHANGE.toString());
          data.put(LongPollResponse.GAME_STATE, GameState.LOBBY.toString());
          broadcastToPlayers(MessageType.GAME_EVENT, data);
  
          if (host != null) {
              notifyPlayerInfoChange(host);
          }
  
          if (judge != null) {
              notifyPlayerInfoChange(judge);
          }
  
          gameManager.broadcastGameListRefresh();
      }
  
      /**
       * Check to see if judging should begin, based on the number of players that have played and the
       * number of cards they have played.
       *
       * @return True if judging should begin.
       */
      private boolean startJudging() {
          if (state != GameState.PLAYING) {
              return false;
          }
          if (playedCards.size() == roundPlayers.size()) {
              boolean startJudging = true;
              for (final List<WhiteCard> cards : playedCards.cards()) {
                  if (cards.size() != blackCard.getPick()) {
                      startJudging = false;
                      break;
                  }
              }
              return startJudging;
          } else {
              return false;
          }
      }
  
      /**
       * Start the next round. Clear out the list of played cards into the discard pile, pick a new
       * judge, set the list of players participating in the round, and move into the {@code DEALING}
       * state.
       */
      private void startNextRound() {
          killRoundTimer();
  
          synchronized (playedCards) {
              for (final List<WhiteCard> cards : playedCards.cards()) {
                  for (final WhiteCard card : cards) {
                      whiteDeck.discard(card);
                  }
              }
          }
  
          synchronized (players) {
              judgeIndex++;
              if (judgeIndex >= players.size()) {
                  judgeIndex = 0;
              }
              roundPlayers.clear();
              for (final Player player : players) {
                  if (player != getJudge()) {
                      roundPlayers.add(player);
                  }
              }
          }
  
          dealState();
      }
  
      /**
       * @return A HashMap to use for events dispatched from this game, with the game id already set.
       */
      public HashMap<ReturnableData, Object> getEventMap() {
          final HashMap<ReturnableData, Object> data = new HashMap<ReturnableData, Object>();
          data.put(LongPollResponse.GAME_ID, id);
          return data;
      }
  
      /**
       * @return The next White Card from the deck, reshuffling if required.
       */
      private WhiteCard getNextWhiteCard() {
          try {
              return whiteDeck.getNextCard();
          } catch (final OutOfCardsException e) {
              whiteDeck.reshuffle();
              final HashMap<ReturnableData, Object> data = getEventMap();
              data.put(LongPollResponse.EVENT, LongPollEvent.GAME_WHITE_RESHUFFLE.toString());
              broadcastToPlayers(MessageType.GAME_EVENT, data);
              return getNextWhiteCard();
          }
      }
  
      /**
       * @return The next Black Card from the deck, reshuffling if required.
       */
      private BlackCard getNextBlackCard() {
          try {
              return blackDeck.getNextCard();
          } catch (final OutOfCardsException e) {
              blackDeck.reshuffle();
              final HashMap<ReturnableData, Object> data = getEventMap();
              data.put(LongPollResponse.EVENT, LongPollEvent.GAME_BLACK_RESHUFFLE.toString());
              broadcastToPlayers(MessageType.GAME_EVENT, data);
              return getNextBlackCard();
          }
      }
  
      /**
       * Get the {@code Player} object for a given {@code User} object.
       *
       * @param user
       * @return The {@code Player} object representing {@code user} in this game, or {@code null} if
       *         {@code user} is not in this game.
       */
      @Nullable
      public Player getPlayerForUser(final User user) {
          final Player[] playersCopy = players.toArray(new Player[players.size()]);
          for (final Player player : playersCopy) {
              if (player.getUser() == user) {
                  return player;
              }
          }
          return null;
      }
  
      /**
       * Synchronizes on {@link #blackCardLock}.
       *
       * @return Client data for the current {@code BlackCard}, or {@code null} if there is not a
       *         {@code BlackCard}.
       */
      public Map<BlackCardData, Object> getBlackCard() {
          synchronized (blackCardLock) {
              if (blackCard != null) {
                  return blackCard.getClientData();
              } else {
                  return null;
              }
          }
      }
  
      /**
       * @return The "real" white cards played.
       */
      private List<List<Map<WhiteCardData, Object>>> getWhiteCards() {
          if (state != GameState.JUDGING) {
              return new ArrayList<List<Map<WhiteCardData, Object>>>();
          } else {
              final List<List<WhiteCard>> shuffledPlayedCards;
              shuffledPlayedCards = new ArrayList<List<WhiteCard>>(playedCards.cards());
              // list of all sets of cards played, which have data. this looks terrible...
              final List<List<Map<WhiteCardData, Object>>> cardData =
                      new ArrayList<List<Map<WhiteCardData, Object>>>(shuffledPlayedCards.size());
              Collections.shuffle(shuffledPlayedCards);
              for (final List<WhiteCard> cards : shuffledPlayedCards) {
                  cardData.add(getWhiteCardData(cards));
              }
              return cardData;
          }
      }
  
      /**
       * @param user
       *          User to return white cards for.
       * @return The white cards the specified user can see, i.e., theirs and face-down cards for
       *         everyone else.
       */
      public List<List<Map<WhiteCardData, Object>>> getWhiteCards(final User user) {
          // if we're in judge mode, return all of the cards and ignore which user is asking
          if (state == GameState.JUDGING) {
              return getWhiteCards();
          } else if (state != GameState.PLAYING) {
              return new ArrayList<List<Map<WhiteCardData, Object>>>();
          } else {
              // getPlayerForUser synchronizes on players. This has caused a deadlock in the past.
              // Good idea to not nest synchronizes if possible anyway.
              final Player player = getPlayerForUser(user);
              synchronized (playedCards) {
                  final List<List<Map<WhiteCardData, Object>>> cardData =
                          new ArrayList<List<Map<WhiteCardData, Object>>>(playedCards.size());
                  int faceDownCards = playedCards.size();
                  if (playedCards.hasPlayer(player)) {
                      cardData.add(getWhiteCardData(playedCards.getCards(player)));
                      faceDownCards--;
                  }
                  // TODO make this figure out how many blank cards in each spot, for multi-play cards
                  while (faceDownCards-- > 0) {
                      cardData.add(Arrays.asList(WhiteCard.getFaceDownCardClientData()));
                  }
                  return cardData;
              }
          }
      }
  
      /**
       * Convert a list of {@code WhiteCard}s to data suitable for sending to a client.
       *
       * @param cards
       *          Cards to convert to client data.
       * @return Client representation of {@code cards}.
       */
      private List<Map<WhiteCardData, Object>> getWhiteCardData(final List<WhiteCard> cards) {
          final List<Map<WhiteCardData, Object>> data =
                  new ArrayList<Map<WhiteCardData, Object>>(cards.size());
          for (final WhiteCard card : cards) {
              data.add(card.getClientData());
          }
          return data;
      }
  
      /**
       * Send a list of {@code WhiteCard}s to a player.
       *
       * @param player
       *          Player to send the cards to.
       * @param cards
       *          The cards to send the player.
       */
      private void sendCardsToPlayer(final Player player, final List<WhiteCard> cards) {
          final Map<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.HAND_DEAL.toString());
          data.put(LongPollResponse.HAND, getWhiteCardData(cards));
          final QueuedMessage qm = new QueuedMessage(MessageType.GAME_EVENT, data);
          player.getUser().enqueueMessage(qm);
      }
  
      /**
       * Get a client data representation of a user's hand.
       *
       * @param user
       *          User whose hand to convert to client data.
       * @return Client representation of {@code user}'s hand.
       */
      @Nonnull
      public List<Map<WhiteCardData, Object>> getHand(final User user) {
          final Player player = getPlayerForUser(user);
          if (player != null) {
              final List<WhiteCard> hand = player.getHand();
              synchronized (hand) {
                  return getWhiteCardData(hand);
              }
          } else {
              return new ArrayList<Map<WhiteCardData, Object>>(0);
          }
      }
  
      /**
       * @return A list of all {@code User}s in this game.
       */
      private List<User> playersToUsers() {
          final List<User> users;
          final Player[] playersCopy = players.toArray(new Player[players.size()]);
          users = new ArrayList<User>(playersCopy.length);
          for (final Player player : playersCopy) {
              users.add(player.getUser());
          }
          synchronized (spectators) {
              users.addAll(spectators);
          }
          return users;
      }
  
      /**
       * @return The judge for the current round, or {@code null} if the judge index is somehow invalid.
       */
      @Nullable
      private Player getJudge() {
          if (judgeIndex >= 0 && judgeIndex < players.size()) {
              return players.get(judgeIndex);
          } else {
              return null;
          }
      }
  
      /**
       * Play a card.
       *
       * @param user
       *          User playing the card.
       * @param cardId
       *          ID of the card to play.
       * @param cardText
       *          User text for a blank card.  Ignored for normal cards.
       * @return An {@code ErrorCode} if the play was unsuccessful ({@code user} doesn't have the card,
       *         {@code user} is the judge, etc.), or {@code null} if there was no error and the play
       *         was successful.
       */
      public ErrorCode playCard(final User user, final int cardId, final String cardText) {
          final Player player = getPlayerForUser(user);
          if (player != null) {
              player.resetSkipCount();
  
              if (getJudge() == player || state != GameState.PLAYING) {
                  return ErrorCode.NOT_YOUR_TURN;
              }
  
              synchronized (blackCardLock) {
                  if (playedCards.getCardsCount(player) >= blackCard.getPick()) {
                      return ErrorCode.PLAYED_ALL_CARDS;
                  }
              }
  
              final List<WhiteCard> hand = player.getHand();
              WhiteCard playCard = null;
              synchronized (hand) {
                  final Iterator<WhiteCard> iter = hand.iterator();
                  while (iter.hasNext()) {
                      final WhiteCard card = iter.next();
                      if (card.getId() == cardId) {
                          playCard = card;
                          if (WhiteDeck.isBlankCard(card)) {
                              ((BlankWhiteCard) playCard).setText(cardText);
                          }
                          // remove the card from their hand. the client will also do so when we return
                          // success, so no need to tell it to do so here.
                          iter.remove();
                          break;
                      }
                  }
              }
              if (playCard != null) {
                  playedCards.addCard(player, playCard);
                  notifyPlayerInfoChange(player);
  
                  if (startJudging()) {
                      judgingState();
                  }
                  return null;
              } else {
                  return ErrorCode.DO_NOT_HAVE_CARD;
              }
          } else {
              return null;
          }
      }
  
      /**
       * The judge has selected a card. The {@code cardId} passed in may be any white card's ID for
       * black cards that have multiple selection, however only the first card in the set's ID will be
       * passed around to clients.
       *
       * @param judge
       *          Judge user.
       * @param cardId
       *          Selected card ID.
       * @return Error code if there is an error, or null if success.
       */
      public ErrorCode judgeCard(final User judge, final int cardId) {
          final Player cardPlayer;
          synchronized (judgeLock) {
              final Player judgePlayer = getPlayerForUser(judge);
              if (getJudge() != judgePlayer) {
                  return ErrorCode.NOT_JUDGE;
              } else if (state != GameState.JUDGING) {
                  return ErrorCode.NOT_YOUR_TURN;
              }
  
              // shouldn't ever happen, but just in case...
              if (null != judgePlayer) {
                  judgePlayer.resetSkipCount();
              }
  
              cardPlayer = playedCards.getPlayerForId(cardId);
              if (cardPlayer == null) {
                  return ErrorCode.INVALID_CARD;
              }
  
              cardPlayer.increaseScore();
              state = GameState.ROUND_OVER;
          }
          final int clientCardId = playedCards.getCards(cardPlayer).get(0).getId();
  
          final String roundId = uniqueIdProvider.get();
          final HashMap<ReturnableData, Object> data = getEventMap();
          data.put(LongPollResponse.EVENT, LongPollEvent.GAME_ROUND_COMPLETE.toString());
          data.put(LongPollResponse.ROUND_WINNER, cardPlayer.getUser().getNickname());
          data.put(LongPollResponse.WINNING_CARD, clientCardId);
          data.put(LongPollResponse.INTERMISSION, ROUND_INTERMISSION);
          if (showRoundLinkProvider.get()) {
              data.put(LongPollResponse.ROUND_PERMALINK,
                      String.format(roundPermalinkFormatProvider.get(), roundId));
          }
          broadcastToPlayers(MessageType.GAME_EVENT, data);
  
          notifyPlayerInfoChange(getJudge());
          notifyPlayerInfoChange(cardPlayer);
  
          synchronized (roundTimerLock) {
              final SafeTimerTask task;
              // TODO win-by-x option
              if (cardPlayer.getScore() >= options.scoreGoal) {
                  task = new SafeTimerTask() {
                      @Override
                      public void process() {
                          winState();
                      }
                  };
              } else {
                  task = new SafeTimerTask() {
                      @Override
                      public void process() {
                          startNextRound();
                      }
                  };
              }
              rescheduleTimer(task, ROUND_INTERMISSION);
          }
  
          final Map<String, List<WhiteCard>> cardsBySessionId = new HashMap<>();
          playedCards.cardsByUser().forEach(
                  (key, value) -> cardsBySessionId.put(key.getSessionId(), value));
          metrics.roundComplete(currentUniqueId, roundId, judge.getSessionId(),
                  cardPlayer.getUser().getSessionId(), blackCard, cardsBySessionId);
  
          return null;
      }
  
      /**
       * Exception to be thrown when there are too many players in a game.
       */
      public class TooManyPlayersException extends Exception {
          private static final long serialVersionUID = -6603422097641992017L;
      }
  
      /**
       * Exception to be thrown when there are too many spectators in a game.
       */
      public class TooManySpectatorsException extends Exception {
          private static final long serialVersionUID = -6603422097641992018L;
      }
  }
  