PretendYoureXyzzy/src/main/java/net/socialgamer/cah/data/Game.java

1562 lines
60 KiB
Java

/**
* 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;
}
}