diff --git a/WebContent/game.jsp b/WebContent/game.jsp index 416f468..00c6678 100644 --- a/WebContent/game.jsp +++ b/WebContent/game.jsp @@ -32,10 +32,11 @@ created for the user now. <%@ page import="com.google.inject.Key" %> <%@ page import="com.google.inject.TypeLiteral" %> <%@ page import="javax.servlet.http.HttpSession" %> -<%@ page import="net.socialgamer.cah.CahModule.AllowBlankCards" %> <%@ page import="net.socialgamer.cah.RequestWrapper" %> <%@ page import="net.socialgamer.cah.StartupUtils" %> <%@ page import="net.socialgamer.cah.data.GameOptions" %> +<%@ page import="net.socialgamer.cah.CahModule" %> +<%@ page import="net.socialgamer.cah.CahModule.*" %> <% // Ensure a session exists for the user. @SuppressWarnings("unused") @@ -425,9 +426,9 @@ boolean allowBlankCards = injector.getInstance(Key.get(new TypeLiteral(
@@ -435,9 +436,9 @@ boolean allowBlankCards = injector.getInstance(Key.get(new TypeLiteral( Having more than 10 players may get cramped! @@ -446,9 +447,9 @@ boolean allowBlankCards = injector.getInstance(Key.get(new TypeLiteral( Spectators can watch and chat, but not actually play. Not even as Czar. @@ -486,9 +487,9 @@ boolean allowBlankCards = injector.getInstance(Key.get(new TypeLiteral( diff --git a/build.properties.example b/build.properties.example index 23994ac..643b0a8 100644 --- a/build.properties.example +++ b/build.properties.example @@ -59,6 +59,19 @@ pyx.game.flood_count=5 # seconds pyx.game.flood_time=30 +# Game options +pyx.game.min_score_limit=4 +pyx.game.default_score_limit=8 +pyx.game.max_score_limit=69 +pyx.game.min_player_limit=3 +pyx.game.default_player_limit=10 +pyx.game.max_player_limit=20 +pyx.game.min_spectator_limit=0 +pyx.game.default_spectator_limit=10 +pyx.game.max_spectator_limit=20 +pyx.game.min_blank_card_limit=0 +pyx.game.default_blank_card_limit=0 +pyx.game.max_blank_card_limit=30 # for production use, use postgres #hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/filtered-resources/WEB-INF/pyx.properties b/src/main/filtered-resources/WEB-INF/pyx.properties index 99c46aa..fc0e545 100644 --- a/src/main/filtered-resources/WEB-INF/pyx.properties +++ b/src/main/filtered-resources/WEB-INF/pyx.properties @@ -25,6 +25,19 @@ pyx.chat.game.flood_time=${pyx.game.flood_time} pyx.build=${buildNumber} pyx.banned_nicks=${pyx.banned_nicks} +pyx.game.min_score_limit=${pyx.game.min_score_limit} +pyx.game.default_score_limit=${pyx.game.default_score_limit} +pyx.game.max_score_limit=${pyx.game.max_score_limit} +pyx.game.min_player_limit=${pyx.game.min_player_limit} +pyx.game.default_player_limit=${pyx.game.default_player_limit} +pyx.game.max_player_limit=${pyx.game.max_player_limit} +pyx.game.min_spectator_limit=${pyx.game.min_spectator_limit} +pyx.game.default_spectator_limit=${pyx.game.default_spectator_limit} +pyx.game.max_spectator_limit=${pyx.game.max_spectator_limit} +pyx.game.min_blank_card_limit=${pyx.game.min_blank_card_limit} +pyx.game.default_blank_card_limit=${pyx.game.default_blank_card_limit} +pyx.game.max_blank_card_limit=${pyx.game.max_blank_card_limit} + pyx.metrics.game.enabled=${pyx.metrics.game.enabled} pyx.metrics.game.url_format=${pyx.metrics.game.url_format} pyx.metrics.round.enabled=${pyx.metrics.round.enabled} diff --git a/src/main/java/net/socialgamer/cah/CahModule.java b/src/main/java/net/socialgamer/cah/CahModule.java index fdb7bf7..ed1d566 100644 --- a/src/main/java/net/socialgamer/cah/CahModule.java +++ b/src/main/java/net/socialgamer/cah/CahModule.java @@ -37,14 +37,12 @@ import java.util.concurrent.atomic.AtomicInteger; import javax.servlet.ServletContext; +import com.google.inject.*; +import net.socialgamer.cah.data.GameOptions; import org.apache.log4j.Logger; import org.hibernate.Session; import com.google.common.collect.ImmutableSet; -import com.google.inject.AbstractModule; -import com.google.inject.BindingAnnotation; -import com.google.inject.Provides; -import com.google.inject.TypeLiteral; import com.google.inject.assistedinject.FactoryModuleBuilder; import net.socialgamer.cah.data.GameManager; @@ -427,4 +425,160 @@ public class CahModule extends AbstractModule { @Retention(RetentionPolicy.RUNTIME) public @interface AllowBlankCards { } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MinScoreLimit { + } + + @Provides + @MinScoreLimit + Integer provideMinScoreLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.min_score_limit", "4")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface DefaultScoreLimit { + } + + @Provides + @DefaultScoreLimit + Integer provideDefaultScoreLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.default_score_limit", "8")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MaxScoreLimit { + } + + @Provides + @MaxScoreLimit + Integer provideMaxScoreLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.max_score_limit", "69")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MinPlayerLimit { + } + + @Provides + @MinPlayerLimit + Integer provideMinPlayerLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.min_player_limit", "3")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface DefaultPlayerLimit { + } + + @Provides + @DefaultPlayerLimit + Integer provideDefaultPlayerLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.default_player_limit", "10")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MaxPlayerLimit { + } + + @Provides + @MaxPlayerLimit + Integer provideMaxPlayerLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.max_player_limit", "20")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MinSpectatorLimit { + } + + @Provides + @MinSpectatorLimit + Integer provideMinSpectatorLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.min_spectator_limit", "0")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface DefaultSpectatorLimit { + } + + @Provides + @DefaultSpectatorLimit + Integer provideDefaultSpectatorLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.default_spectator_limit", "10")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MaxSpectatorLimit { + } + + @Provides + @MaxSpectatorLimit + Integer provideMaxSpectatorLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.max_spectator_limit", "20")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MinBlankCardLimit { + } + + @Provides + @MinBlankCardLimit + Integer provideMinBlankCardLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.min_blank_card_limit", "0")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface DefaultBlankCardLimit { + } + + @Provides + @DefaultBlankCardLimit + Integer provideDefaultBlankCardLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.default_blank_card_limit", "0")); + } + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface MaxBlankCardLimit { + } + + @Provides + @MaxBlankCardLimit + Integer provideMaxBlankCardLimit() { + synchronized (properties) { + return Integer.valueOf(properties.getProperty("pyx.game.max_blank_card_limit", "30")); + } + } } diff --git a/src/main/java/net/socialgamer/cah/cardcast/CardcastModule.java b/src/main/java/net/socialgamer/cah/cardcast/CardcastModule.java index 849a83c..a3f3027 100644 --- a/src/main/java/net/socialgamer/cah/cardcast/CardcastModule.java +++ b/src/main/java/net/socialgamer/cah/cardcast/CardcastModule.java @@ -1,16 +1,16 @@ /** * Copyright (c) 2012-2018, Andy Janata * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: - * + *

* * Redistributions of source code must retain the above copyright notice, this list of conditions - * and the following disclaimer. + * 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. - * + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + *

* 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 @@ -23,34 +23,34 @@ package net.socialgamer.cah.cardcast; +import com.google.inject.*; +import net.socialgamer.cah.CahModule; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.concurrent.atomic.AtomicInteger; -import com.google.inject.AbstractModule; -import com.google.inject.BindingAnnotation; -import com.google.inject.Provides; - -import net.socialgamer.cah.data.GameOptions; - public class CardcastModule extends AbstractModule { - AtomicInteger cardId = new AtomicInteger(-(GameOptions.MAX_BLANK_CARD_LIMIT + 1)); + private AtomicInteger cardId; + private Provider maxBlankCardLimitProvider; - @Override - protected void configure() { - } + @Override + protected void configure() { + maxBlankCardLimitProvider = getProvider(Key.get(Integer.class, CahModule.MaxBlankCardLimit.class)); + } - @Provides - @CardcastCardId - Integer provideCardId() { - return cardId.decrementAndGet(); - } + @Provides + @CardcastCardId + Integer provideCardId() { + if (cardId == null) cardId = new AtomicInteger(-(maxBlankCardLimitProvider.get() + 1)); + return cardId.decrementAndGet(); + } - @BindingAnnotation - @Retention(RetentionPolicy.RUNTIME) - public @interface CardcastCardId { - /**/ - } + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface CardcastCardId { + /**/ + } } diff --git a/src/main/java/net/socialgamer/cah/data/Game.java b/src/main/java/net/socialgamer/cah/data/Game.java index 4924204..fe084ce 100644 --- a/src/main/java/net/socialgamer/cah/data/Game.java +++ b/src/main/java/net/socialgamer/cah/data/Game.java @@ -1,16 +1,16 @@ /** * Copyright (c) 2012-2018, Andy Janata * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: - * + *

* * Redistributions of source code must retain the above copyright notice, this list of conditions - * and the following disclaimer. + * 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. - * + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + *

* 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 @@ -23,54 +23,26 @@ package net.socialgamer.cah.data; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import org.apache.log4j.Logger; -import org.hibernate.Session; - import com.google.inject.Inject; import com.google.inject.Provider; - -import net.socialgamer.cah.CahModule.AllowBlankCards; -import net.socialgamer.cah.CahModule.GamePermalinkUrlFormat; -import net.socialgamer.cah.CahModule.RoundPermalinkUrlFormat; -import net.socialgamer.cah.CahModule.ShowGamePermalink; -import net.socialgamer.cah.CahModule.ShowRoundPermalink; -import net.socialgamer.cah.CahModule.UniqueId; -import net.socialgamer.cah.Constants.AjaxResponse; -import net.socialgamer.cah.Constants.BlackCardData; -import net.socialgamer.cah.Constants.ErrorCode; -import net.socialgamer.cah.Constants.GameInfo; -import net.socialgamer.cah.Constants.GamePlayerInfo; -import net.socialgamer.cah.Constants.GamePlayerStatus; -import net.socialgamer.cah.Constants.GameState; -import net.socialgamer.cah.Constants.LongPollEvent; -import net.socialgamer.cah.Constants.LongPollResponse; -import net.socialgamer.cah.Constants.ReturnableData; -import net.socialgamer.cah.Constants.WhiteCardData; +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; /** @@ -93,1502 +65,1497 @@ import net.socialgamer.cah.task.SafeTimerTask; * @author Andy Janata (ajanata@socialgamer.net) */ public class Game { - private static final Logger logger = Logger.getLogger(Game.class); + /** + * 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 FINITE_PLAYTIMES; - private final int id; - /** - * All players present in the game. - */ - private final List players = Collections.synchronizedList(new ArrayList(10)); - /** - * Players participating in the current round. - */ - private final List roundPlayers = Collections.synchronizedList(new ArrayList(9)); - private final PlayerPlayedCardsTracker playedCards = new PlayerPlayedCardsTracker(); - private final List spectators = Collections.synchronizedList(new ArrayList(10)); - private final ConnectedUsers connectedUsers; - private final GameManager gameManager; - private Player host; - private final Provider sessionProvider; - private BlackDeck blackDeck; - private BlackCard blackCard; - private final Object blackCardLock = new Object(); - private WhiteDeck whiteDeck; - private GameState state; - private final GameOptions options = new GameOptions(); - private final Set cardcastDeckIds = Collections.synchronizedSet(new HashSet()); - private final Metrics metrics; - private final Provider showGameLinkProvider; - private final Provider gamePermalinkFormatProvider; - private final Provider showRoundLinkProvider; - private final Provider roundPermalinkFormatProvider; - private final Provider allowBlankCardsProvider; - private final long created = System.currentTimeMillis(); - - private int judgeIndex = 0; - - /** - * 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; - - // All of these delays could be moved to pyx.properties. - /** - * 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 FINITE_PLAYTIMES; - static - { - final Set finitePlaytimes = new TreeSet(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); - } - - /** - * 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 volatile ScheduledFuture lastScheduledFuture; - private final ScheduledThreadPoolExecutor globalTimer; - private final Provider cardcastServiceProvider; - private final Provider uniqueIdProvider; - 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 sessionProvider, - final Provider cardcastServiceProvider, - @UniqueId final Provider uniqueIdProvider, - final Metrics metrics, @ShowRoundPermalink final Provider showRoundLinkProvider, - @RoundPermalinkUrlFormat final Provider roundPermalinkFormatProvider, - @ShowGamePermalink final Provider showGameLinkProvider, - @GamePermalinkUrlFormat final Provider gamePermalinkFormatProvider, - @AllowBlankCards final Provider allowBlankCardsProvider) { - this.id = id; - 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 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; - } + static { + final Set finitePlaytimes = new TreeSet(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); } - final HashMap data = getEventMap(); - data.put(LongPollResponse.EVENT, LongPollEvent.GAME_PLAYER_JOIN.toString()); - data.put(LongPollResponse.NICKNAME, user.getNickname()); - broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data); + private final int id; + /** + * All players present in the game. + */ + private final List players = Collections.synchronizedList(new ArrayList(10)); + /** + * Players participating in the current round. + */ + private final List roundPlayers = Collections.synchronizedList(new ArrayList(9)); + private final PlayerPlayedCardsTracker playedCards = new PlayerPlayedCardsTracker(); + private final List spectators = Collections.synchronizedList(new ArrayList(10)); + private final ConnectedUsers connectedUsers; + private final GameManager gameManager; + private final Provider sessionProvider; + private final Object blackCardLock = new Object(); + private final GameOptions options; + private final Set cardcastDeckIds = Collections.synchronizedSet(new HashSet()); + private final Metrics metrics; + private final Provider showGameLinkProvider; + private final Provider gamePermalinkFormatProvider; + private final Provider showRoundLinkProvider; + private final Provider roundPermalinkFormatProvider; - // Don't do this anymore, it was driving up a crazy amount of traffic. - // gameManager.broadcastGameListRefresh(); - } + // All of these delays could be moved to pyx.properties. + private final Provider 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 cardcastServiceProvider; + private final Provider 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; - /** - * Remove a player from the game. - *
- * 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); + /** + * 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 sessionProvider, + final Provider cardcastServiceProvider, + @UniqueId final Provider uniqueIdProvider, + final Metrics metrics, @ShowRoundPermalink final Provider showRoundLinkProvider, + @RoundPermalinkUrlFormat final Provider roundPermalinkFormatProvider, + @ShowGamePermalink final Provider showGameLinkProvider, + @GamePermalinkUrlFormat final Provider gamePermalinkFormatProvider, + @AllowBlankCards final Provider allowBlankCardsProvider, + final Provider 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; - if (null != player) { - HashMap data; - // If they played this round, remove card from played card list. - final List cards = playedCards.remove(player); - if (cards != null && cards.size() > 0) { - for (final WhiteCard card : cards) { - whiteDeck.discard(card); + 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 data) { + if (showGameLinkProvider.get() && null != currentUniqueId) { + data.put(AjaxResponse.GAME_PERMALINK, + String.format(gamePermalinkFormatProvider.get(), currentUniqueId)); } - } - // 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 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(); + /** + * 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; } - }; - 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 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(); } - final HashMap data = getEventMap(); - data.put(LongPollResponse.EVENT, LongPollEvent.GAME_SPECTATOR_JOIN.toString()); - data.put(LongPollResponse.NICKNAME, user.getNickname()); - broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data); + /** + * Remove a player from the game. + *
+ * 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); - gameManager.broadcastGameListRefresh(); - } + if (null != player) { + HashMap data; + // If they played this round, remove card from played card list. + final List 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 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--; + } - /** - * Remove a spectator from the game. - *
- * 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); - } + // 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 - final HashMap data = getEventMap(); - data.put(LongPollResponse.EVENT, LongPollEvent.GAME_SPECTATOR_LEAVE.toString()); - data.put(LongPollResponse.NICKNAME, user.getNickname()); - broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data); + // 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(); - } + // 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. - *
- * 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 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 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 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 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 getCardcastDeckIds() { - return cardcastDeckIds; - } - - /** - * Get information about this game, without the game's password. - *
- * Synchronizes on {@link #players}. - * @return This game's general information: ID, host, state, player list, etc. - */ - @Nullable - public Map getInfo() { - return getInfo(false); - } - - /** - * Get information about this game. - *
- * 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 getInfo(final boolean includePassword) { - final Map info = new HashMap(); - 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 playerNames = new ArrayList(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 spectatorNames = new ArrayList(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> getAllPlayerInfo() { - final List> info; - final Player[] playersCopy = players.toArray(new Player[players.size()]); - info = new ArrayList>(playersCopy.length); - for (final Player player : playersCopy) { - final Map playerInfo = getPlayerInfo(player); - info.add(playerInfo); - } - return info; - } - - public final List getPlayers() { - final List copy = new ArrayList(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 getPlayerInfo(final Player player) { - final Map playerInfo = new HashMap(); - // 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; + 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; } - break; - case PLAYING: - if (getJudge() == player) { - playerStatus = GamePlayerStatus.JUDGE; - } else { - if (!roundPlayers.contains(player)) { - playerStatus = GamePlayerStatus.IDLE; - break; - } - final List 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! - *
- * 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 cardSets; - synchronized (options.cardSetIds) { - cardSets = loadCardSets(session); - blackDeck = loadBlackDeck(cardSets); - whiteDeck = loadWhiteDeck(cardSets); + } + + /** + * 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); } - metrics.gameStart(currentUniqueId, cardSets, options.blanksInDeck, options.playerLimit, - options.scoreGoal, !StringUtils.isBlank(options.password)); - startNextRound(); + + final HashMap 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(); - } - return started; - } finally { - if (null != session) { - session.close(); - } } - } - public List loadCardSets(final Session session) { - synchronized (options.cardSetIds) { - try { - final List cardSets = new ArrayList<>(); - - if (!options.getPyxCardSetIds().isEmpty()) { - @SuppressWarnings("unchecked") - final List pyxCardSets = session - .createQuery("from PyxCardSet where id in (:ids)") - .setParameterList("ids", options.getPyxCardSetIds()).list(); - cardSets.addAll(pyxCardSets); + /** + * Remove a spectator from the game. + *
+ * 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); } - // 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(); + // do this down here so the person that left doesn't get the notice too + final HashMap data = getEventMap(); + data.put(LongPollResponse.EVENT, LongPollEvent.GAME_SPECTATOR_LEAVE.toString()); + data.put(LongPollResponse.NICKNAME, user.getNickname()); + broadcastToPlayers(MessageType.GAME_PLAYER_EVENT, data); - // 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 cardSets) { - return new BlackDeck(cardSets); - } - - public WhiteDeck loadWhiteDeck(final List cardSets) { - return new WhiteDeck(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 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. - *
- */ - private void dealState() { - final Player[] playersCopy = players.toArray(new Player[players.size()]); - for (final Player player : playersCopy) { - final List hand = player.getHand(); - final List newCards = new LinkedList(); - 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. - *
- * 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 cards = new ArrayList(newBlackCard.getDraw()); - for (int i = 0; i < newBlackCard.getDraw(); i++) { - cards.add(getNextWhiteCard()); - } - player.getHand().addAll(cards); - sendCardsToPlayer(player, cards); - } - } + // Don't do this anymore, it was driving up a crazy amount of traffic. + // gameManager.broadcastGameListRefresh(); } - // Perhaps figure out a better way to do this... - final int playTimer = calculateTime(PLAY_TIMEOUT_BASE + (PLAY_TIMEOUT_PER_CARD * blackCard.getPick())); - - final HashMap 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. - *
- * 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 cards = playedCards.getCards(player); - if (cards == null || cards.size() < blackCard.getPick()) { - final Map data = new HashMap(); - 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 data = new HashMap(); - 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 data = getEventMap(); - data.put(LongPollResponse.EVENT, LongPollEvent.GAME_JUDGE_SKIPPED.toString()); - broadcastToPlayers(MessageType.GAME_EVENT, data); - returnCardsToHand(); - startNextRound(); - } - } - - private void skipIdlePlayers() { - killRoundTimer(); - final List playersToRemove = new ArrayList(); - final List playersToUpdateStatus = new ArrayList(); - synchronized (roundPlayers) { - - for (final Player player : roundPlayers) { - final List 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 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 returnCards = playedCards.remove(player); - if (returnCards != null) { - player.getHand().addAll(returnCards); - sendCardsToPlayer(player, returnCards); - } - } - } - } - - for (final User user : playersToRemove) { - removePlayer(user); - final HashMap 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 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 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 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 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 getEventMap() { - final HashMap data = new HashMap(); - 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 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 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 getBlackCard() { - synchronized (blackCardLock) { - if (blackCard != null) { - return blackCard.getClientData(); - } else { - return null; - } - } - } - - /** - * @return The "real" white cards played. - */ - private List>> getWhiteCards() { - if (state != GameState.JUDGING) { - return new ArrayList>>(); - } else { - final List> shuffledPlayedCards; - shuffledPlayedCards = new ArrayList>(playedCards.cards()); - // list of all sets of cards played, which have data. this looks terrible... - final List>> cardData = - new ArrayList>>(shuffledPlayedCards.size()); - Collections.shuffle(shuffledPlayedCards); - for (final List 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>> 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>>(); - } 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>> cardData = - new ArrayList>>(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> getWhiteCardData(final List cards) { - final List> data = - new ArrayList>(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 cards) { - final Map 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> getHand(final User user) { - final Player player = getPlayerForUser(user); - if (player != null) { - final List hand = player.getHand(); - synchronized (hand) { - return getWhiteCardData(hand); - } - } else { - return new ArrayList>(0); - } - } - - /** - * @return A list of all {@code User}s in this game. - */ - private List playersToUsers() { - final List users; - final Player[] playersCopy = players.toArray(new Player[players.size()]); - users = new ArrayList(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 hand = player.getHand(); - WhiteCard playCard = null; - synchronized (hand) { - final Iterator 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); + /** + * Return all played cards to their respective player's hand. + *
+ * 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)); } - // 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; - } + // prevent startNextRound from discarding cards + playedCards.clear(); } - } - if (playCard != null) { - playedCards.addCard(player, playCard); - notifyPlayerInfoChange(player); + } - if (startJudging()) { - judgingState(); + /** + * 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 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 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 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 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 getCardcastDeckIds() { + return cardcastDeckIds; + } + + /** + * Get information about this game, without the game's password. + *
+ * Synchronizes on {@link #players}. + * @return This game's general information: ID, host, state, player list, etc. + */ + @Nullable + public Map getInfo() { + return getInfo(false); + } + + /** + * Get information about this game. + *
+ * 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 getInfo(final boolean includePassword) { + final Map info = new HashMap(); + 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 playerNames = new ArrayList(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 spectatorNames = new ArrayList(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> getAllPlayerInfo() { + final List> info; + final Player[] playersCopy = players.toArray(new Player[players.size()]); + info = new ArrayList>(playersCopy.length); + for (final Player player : playersCopy) { + final Map playerInfo = getPlayerInfo(player); + info.add(playerInfo); + } + return info; + } + + public final List getPlayers() { + final List copy = new ArrayList(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 getPlayerInfo(final Player player) { + final Map playerInfo = new HashMap(); + // 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 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! + *
+ * 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 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 loadCardSets(final Session session) { + synchronized (options.cardSetIds) { + try { + final List cardSets = new ArrayList<>(); + + if (!options.getPyxCardSetIds().isEmpty()) { + @SuppressWarnings("unchecked") final List 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 cardSets) { + return new BlackDeck(cardSets); + } + + public WhiteDeck loadWhiteDeck(final List 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 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. + *
+ */ + private void dealState() { + final Player[] playersCopy = players.toArray(new Player[players.size()]); + for (final Player player : playersCopy) { + final List hand = player.getHand(); + final List newCards = new LinkedList(); + 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. + *
+ * 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 cards = new ArrayList(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 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. + *
+ * 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 cards = playedCards.getCards(player); + if (cards == null || cards.size() < blackCard.getPick()) { + final Map data = new HashMap(); + 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 data = new HashMap(); + 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 data = getEventMap(); + data.put(LongPollResponse.EVENT, LongPollEvent.GAME_JUDGE_SKIPPED.toString()); + broadcastToPlayers(MessageType.GAME_EVENT, data); + returnCardsToHand(); + startNextRound(); + } + } + + private void skipIdlePlayers() { + killRoundTimer(); + final List playersToRemove = new ArrayList(); + final List playersToUpdateStatus = new ArrayList(); + synchronized (roundPlayers) { + + for (final Player player : roundPlayers) { + final List 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 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 returnCards = playedCards.remove(player); + if (returnCards != null) { + player.getHand().addAll(returnCards); + sendCardsToPlayer(player, returnCards); + } + } + } + } + + for (final User user : playersToRemove) { + removePlayer(user); + final HashMap 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 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 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 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 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 getEventMap() { + final HashMap data = new HashMap(); + 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 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 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; - } 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 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> cardsBySessionId = new HashMap<>(); - playedCards.cardsByUser().forEach( - (key, value) -> cardsBySessionId.put(key.getSessionId(), value)); - metrics.roundComplete(currentUniqueId, roundId, judge.getSessionId(), - cardPlayer.getUser().getSessionId(), blackCard, cardsBySessionId); + /** + * Synchronizes on {@link #blackCardLock}. + * + * @return Client data for the current {@code BlackCard}, or {@code null} if there is not a + * {@code BlackCard}. + */ + public Map getBlackCard() { + synchronized (blackCardLock) { + if (blackCard != null) { + return blackCard.getClientData(); + } else { + return null; + } + } + } - return null; - } + /** + * @return The "real" white cards played. + */ + private List>> getWhiteCards() { + if (state != GameState.JUDGING) { + return new ArrayList>>(); + } else { + final List> shuffledPlayedCards; + shuffledPlayedCards = new ArrayList>(playedCards.cards()); + // list of all sets of cards played, which have data. this looks terrible... + final List>> cardData = + new ArrayList>>(shuffledPlayedCards.size()); + Collections.shuffle(shuffledPlayedCards); + for (final List cards : shuffledPlayedCards) { + cardData.add(getWhiteCardData(cards)); + } + return cardData; + } + } - /** - * Exception to be thrown when there are too many players in a game. - */ - public class TooManyPlayersException extends Exception { - private static final long serialVersionUID = -6603422097641992017L; - } + /** + * @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>> 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>>(); + } 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>> cardData = + new ArrayList>>(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; + } + } + } - /** - * Exception to be thrown when there are too many spectators in a game. - */ - public class TooManySpectatorsException extends Exception { - private static final long serialVersionUID = -6603422097641992018L; - } + /** + * 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> getWhiteCardData(final List cards) { + final List> data = + new ArrayList>(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 cards) { + final Map 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> getHand(final User user) { + final Player player = getPlayerForUser(user); + if (player != null) { + final List hand = player.getHand(); + synchronized (hand) { + return getWhiteCardData(hand); + } + } else { + return new ArrayList>(0); + } + } + + /** + * @return A list of all {@code User}s in this game. + */ + private List playersToUsers() { + final List users; + final Player[] playersCopy = players.toArray(new Player[players.size()]); + users = new ArrayList(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 hand = player.getHand(); + WhiteCard playCard = null; + synchronized (hand) { + final Iterator 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 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> 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; + } } diff --git a/src/main/java/net/socialgamer/cah/data/GameOptions.java b/src/main/java/net/socialgamer/cah/data/GameOptions.java index db53dcf..7c2230e 100644 --- a/src/main/java/net/socialgamer/cah/data/GameOptions.java +++ b/src/main/java/net/socialgamer/cah/data/GameOptions.java @@ -1,16 +1,16 @@ /** * Copyright (c) 2012-2018, Andy Janata * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: - * + *

* * Redistributions of source code must retain the above copyright notice, this list of conditions - * and the following disclaimer. + * 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. - * + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + *

* 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 @@ -23,14 +23,17 @@ 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.GameOptionData; +import net.socialgamer.cah.JsonWrapper; + import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; -import net.socialgamer.cah.Constants.GameOptionData; -import net.socialgamer.cah.JsonWrapper; - /** * Options for an individual game. @@ -38,112 +41,129 @@ import net.socialgamer.cah.JsonWrapper; * @author Gavin Lambert (uecasm) */ public class GameOptions { - // TODO move these out to pyx.properties - public static final int MIN_SCORE_LIMIT = 4; - public static final int DEFAULT_SCORE_LIMIT = 8; - public static final int MAX_SCORE_LIMIT = 69; - public static final int MIN_PLAYER_LIMIT = 3; - public static final int DEFAULT_PLAYER_LIMIT = 10; - public static final int MAX_PLAYER_LIMIT = 20; - public static final int MIN_SPECTATOR_LIMIT = 0; - public static final int DEFAULT_SPECTATOR_LIMIT = 10; - public static final int MAX_SPECTATOR_LIMIT = 20; - public static final int MIN_BLANK_CARD_LIMIT = 0; - public static final int DEFAULT_BLANK_CARD_LIMIT = 0; - public static final int MAX_BLANK_CARD_LIMIT = 30; + public final Set cardSetIds = new HashSet(); + public final int maxPlayerLimit; + public final int defaultPlayerLimit; + public final int minPlayerLimit; + public final int maxSpectatorLimit; + public final int defaultSpectatorLimit; + public final int minSpectatorLimit; + public final int minScoreLimit; + public final int maxScoreLimit; + public final int defaultScoreLimit; + public final int minBlankCardLimit; + public final int defaultBlankCardLimit; + public final int maxBlankCardLimit; + // These are the default values new games get. + public int blanksInDeck; + public int playerLimit; + public int spectatorLimit; + public int scoreGoal; + public String password = ""; + public String timerMultiplier = "1x"; - // These are the default values new games get. - public int blanksInDeck = DEFAULT_BLANK_CARD_LIMIT; - public int playerLimit = DEFAULT_PLAYER_LIMIT; - public int spectatorLimit = DEFAULT_SPECTATOR_LIMIT; - public int scoreGoal = DEFAULT_SCORE_LIMIT; - public final Set cardSetIds = new HashSet(); - public String password = ""; - public String timerMultiplier = "1x"; - - /** - * Update the options in-place (so that the Game doesn't need more locks). - * - * @param newOptions - * The new options to use. - */ - public void update(final GameOptions newOptions) { - this.scoreGoal = newOptions.scoreGoal; - this.playerLimit = newOptions.playerLimit; - this.spectatorLimit = newOptions.spectatorLimit; - synchronized (this.cardSetIds) { - this.cardSetIds.clear(); - this.cardSetIds.addAll(newOptions.cardSetIds); - } - this.blanksInDeck = newOptions.blanksInDeck; - this.password = newOptions.password; - this.timerMultiplier = newOptions.timerMultiplier; - } - - /** - * Get the options in a form that can be sent to clients. - * - * @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. - */ - public Map serialize(final boolean includePassword) { - final Map info = new HashMap(); - - info.put(GameOptionData.CARD_SETS, cardSetIds); - info.put(GameOptionData.BLANKS_LIMIT, blanksInDeck); - info.put(GameOptionData.PLAYER_LIMIT, playerLimit); - info.put(GameOptionData.SPECTATOR_LIMIT, spectatorLimit); - info.put(GameOptionData.SCORE_LIMIT, scoreGoal); - info.put(GameOptionData.TIMER_MULTIPLIER, timerMultiplier); - if (includePassword) { - info.put(GameOptionData.PASSWORD, password); + @Inject + public GameOptions(@MaxPlayerLimit int maxPlayerLimit, @DefaultPlayerLimit int defaultPlayerLimit, @MinPlayerLimit int minPlayerLimit, + @MaxSpectatorLimit int maxSpectatorLimit, @DefaultSpectatorLimit int defaultSpectatorLimit, @MinSpectatorLimit int minSpectatorLimit, + @MinScoreLimit int minScoreLimit, @MaxScoreLimit int maxScoreLimit, @DefaultScoreLimit int defaultScoreLimit, + @MinBlankCardLimit int minBlankCardLimit, @DefaultBlankCardLimit int defaultBlankCardLimit, @MaxBlankCardLimit int maxBlankCardLimit) { + this.maxPlayerLimit = maxPlayerLimit; + this.defaultPlayerLimit = playerLimit = defaultPlayerLimit; + this.minPlayerLimit = minPlayerLimit; + this.maxSpectatorLimit = maxSpectatorLimit; + this.defaultSpectatorLimit = spectatorLimit = defaultSpectatorLimit; + this.minSpectatorLimit = minSpectatorLimit; + this.minScoreLimit = minScoreLimit; + this.maxScoreLimit = maxScoreLimit; + this.defaultScoreLimit = scoreGoal = defaultScoreLimit; + this.minBlankCardLimit = minBlankCardLimit; + this.defaultBlankCardLimit = blanksInDeck = defaultBlankCardLimit; + this.maxBlankCardLimit = maxBlankCardLimit; } - return info; - } + public static GameOptions deserialize(Provider provider, final String text) { + final GameOptions options = provider.get(); - public static GameOptions deserialize(final String text) { - final GameOptions options = new GameOptions(); + if (text == null || text.isEmpty()) { + return options; + } - if (text == null || text.isEmpty()) { - return options; + final JsonWrapper json = new JsonWrapper(text); + + final String[] cardSetsParsed = json.getString(GameOptionData.CARD_SETS, "").split(","); + for (final String cardSetId : cardSetsParsed) { + if (!cardSetId.isEmpty()) { + options.cardSetIds.add(Integer.parseInt(cardSetId)); + } + } + + options.blanksInDeck = Math.max(options.minBlankCardLimit, Math.min(options.maxBlankCardLimit, + json.getInteger(GameOptionData.BLANKS_LIMIT, options.blanksInDeck))); + options.playerLimit = Math.max(options.minPlayerLimit, Math.min(options.maxPlayerLimit, + json.getInteger(GameOptionData.PLAYER_LIMIT, options.playerLimit))); + options.spectatorLimit = Math.max(options.minSpectatorLimit, Math.min(options.maxSpectatorLimit, + json.getInteger(GameOptionData.SPECTATOR_LIMIT, options.spectatorLimit))); + options.scoreGoal = Math.max(options.minScoreLimit, Math.min(options.maxScoreLimit, + json.getInteger(GameOptionData.SCORE_LIMIT, options.scoreGoal))); + options.timerMultiplier = json.getString(GameOptionData.TIMER_MULTIPLIER, options.timerMultiplier); + options.password = json.getString(GameOptionData.PASSWORD, options.password); + + return options; } - final JsonWrapper json = new JsonWrapper(text); - - final String[] cardSetsParsed = json.getString(GameOptionData.CARD_SETS, "").split(","); - for (final String cardSetId : cardSetsParsed) { - if (!cardSetId.isEmpty()) { - options.cardSetIds.add(Integer.parseInt(cardSetId)); - } + /** + * Update the options in-place (so that the Game doesn't need more locks). + * + * @param newOptions + * The new options to use. + */ + public void update(final GameOptions newOptions) { + this.scoreGoal = newOptions.scoreGoal; + this.playerLimit = newOptions.playerLimit; + this.spectatorLimit = newOptions.spectatorLimit; + synchronized (this.cardSetIds) { + this.cardSetIds.clear(); + this.cardSetIds.addAll(newOptions.cardSetIds); + } + this.blanksInDeck = newOptions.blanksInDeck; + this.password = newOptions.password; + this.timerMultiplier = newOptions.timerMultiplier; } - options.blanksInDeck = Math.max(MIN_BLANK_CARD_LIMIT, Math.min(MAX_BLANK_CARD_LIMIT, - json.getInteger(GameOptionData.BLANKS_LIMIT, options.blanksInDeck))); - options.playerLimit = Math.max(MIN_PLAYER_LIMIT, Math.min(MAX_PLAYER_LIMIT, - json.getInteger(GameOptionData.PLAYER_LIMIT, options.playerLimit))); - options.spectatorLimit = Math.max(MIN_SPECTATOR_LIMIT, Math.min(MAX_SPECTATOR_LIMIT, - json.getInteger(GameOptionData.SPECTATOR_LIMIT, options.spectatorLimit))); - options.scoreGoal = Math.max(MIN_SCORE_LIMIT, Math.min(MAX_SCORE_LIMIT, - json.getInteger(GameOptionData.SCORE_LIMIT, options.scoreGoal))); - options.timerMultiplier = json.getString(GameOptionData.TIMER_MULTIPLIER, options.timerMultiplier); - options.password = json.getString(GameOptionData.PASSWORD, options.password); + /** + * Get the options in a form that can be sent to clients. + * + * @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. + */ + public Map serialize(final boolean includePassword) { + final Map info = new HashMap(); - return options; - } + info.put(GameOptionData.CARD_SETS, cardSetIds); + info.put(GameOptionData.BLANKS_LIMIT, blanksInDeck); + info.put(GameOptionData.PLAYER_LIMIT, playerLimit); + info.put(GameOptionData.SPECTATOR_LIMIT, spectatorLimit); + info.put(GameOptionData.SCORE_LIMIT, scoreGoal); + info.put(GameOptionData.TIMER_MULTIPLIER, timerMultiplier); + if (includePassword) { + info.put(GameOptionData.PASSWORD, password); + } - /** - * @return Selected card set IDs which are local to PYX, for querying the database. - */ - public Set getPyxCardSetIds() { - final Set pyxCardSetIds = new HashSet(); - for (final Integer cardSetId : cardSetIds) { - if (cardSetId > 0) { - pyxCardSetIds.add(cardSetId); - } + return info; + } + + /** + * @return Selected card set IDs which are local to PYX, for querying the database. + */ + public Set getPyxCardSetIds() { + final Set pyxCardSetIds = new HashSet(); + for (final Integer cardSetId : cardSetIds) { + if (cardSetId > 0) { + pyxCardSetIds.add(cardSetId); + } + } + return pyxCardSetIds; } - return pyxCardSetIds; - } } diff --git a/src/main/java/net/socialgamer/cah/data/WhiteDeck.java b/src/main/java/net/socialgamer/cah/data/WhiteDeck.java index 750e959..028dc3b 100644 --- a/src/main/java/net/socialgamer/cah/data/WhiteDeck.java +++ b/src/main/java/net/socialgamer/cah/data/WhiteDeck.java @@ -46,13 +46,13 @@ public class WhiteDeck { /** * Create a new white card deck, loading the cards from the database and shuffling them. */ - public WhiteDeck(final Collection cardSets, final int numBlanks) { + public WhiteDeck(int maxBlankCardLimit, final Collection cardSets, final int numBlanks) { final Set allCards = new HashSet(); for (final CardSet cardSet : cardSets) { allCards.addAll(cardSet.getWhiteCards()); } deck = new ArrayList(allCards); - for (int i = 0; i < numBlanks && i < GameOptions.MAX_BLANK_CARD_LIMIT; i++) { + for (int i = 0; i < numBlanks && i < maxBlankCardLimit; i++) { deck.add(createBlankCard()); } Collections.shuffle(deck); diff --git a/src/main/java/net/socialgamer/cah/handlers/ChangeGameOptionHandler.java b/src/main/java/net/socialgamer/cah/handlers/ChangeGameOptionHandler.java index 3813111..063c90d 100644 --- a/src/main/java/net/socialgamer/cah/handlers/ChangeGameOptionHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/ChangeGameOptionHandler.java @@ -5,6 +5,8 @@ import java.util.Map; import javax.servlet.http.HttpSession; +import com.google.inject.Provider; +import net.socialgamer.cah.CahModule; import net.socialgamer.cah.Constants.AjaxOperation; import net.socialgamer.cah.Constants.AjaxRequest; import net.socialgamer.cah.Constants.ErrorCode; @@ -22,10 +24,12 @@ import com.google.inject.Inject; public class ChangeGameOptionHandler extends GameWithPlayerHandler { public static final String OP = AjaxOperation.CHANGE_GAME_OPTIONS.toString(); + private Provider gameOptionsProvider; @Inject - public ChangeGameOptionHandler(final GameManager gameManager) { + public ChangeGameOptionHandler(final GameManager gameManager, Provider gameOptionsProvider) { super(gameManager); + this.gameOptionsProvider = gameOptionsProvider; } @Override @@ -40,7 +44,7 @@ public class ChangeGameOptionHandler extends GameWithPlayerHandler { } else { try { final String value = request.getParameter(AjaxRequest.GAME_OPTIONS); - final GameOptions options = GameOptions.deserialize(value); + final GameOptions options = GameOptions.deserialize(gameOptionsProvider, value); final String oldPassword = game.getPassword(); game.updateGameSettings(options); diff --git a/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java b/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java index a115443..4d451b4 100644 --- a/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java +++ b/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java @@ -36,6 +36,7 @@ import javax.servlet.http.HttpServletResponse; import com.google.inject.Injector; import com.google.inject.Key; +import net.socialgamer.cah.CahModule; import net.socialgamer.cah.CahModule.BroadcastConnectsAndDisconnects; import net.socialgamer.cah.CahModule.CookieDomain; import net.socialgamer.cah.CahModule.GameChatEnabled; diff --git a/src/test/java/net/socialgamer/cah/data/GameManagerTest.java b/src/test/java/net/socialgamer/cah/data/GameManagerTest.java index 2dd3280..85d6bde 100644 --- a/src/test/java/net/socialgamer/cah/data/GameManagerTest.java +++ b/src/test/java/net/socialgamer/cah/data/GameManagerTest.java @@ -79,6 +79,15 @@ public class GameManagerTest { private int gameId; private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); private Metrics metricsMock; + private final Provider gameOptionsProvider = new Provider() { + @Override + public GameOptions get() { + return new GameOptions(20, 10, 3, + 20, 10, 0, + 4, 69, 8, + 0, 0, 30); + } + }; private final Provider falseProvider = new Provider() { @Override public Boolean get() { @@ -123,6 +132,7 @@ public class GameManagerTest { bind(Boolean.class).annotatedWith(ShowGamePermalink.class).toProvider(falseProvider); bind(String.class).annotatedWith(GamePermalinkUrlFormat.class).toProvider(formatProvider); bind(Boolean.class).annotatedWith(AllowBlankCards.class).toProvider(falseProvider); + bind(GameOptions.class).toProvider(gameOptionsProvider); } @Provides @@ -173,15 +183,15 @@ public class GameManagerTest { assertEquals(0, gameManager.get().intValue()); gameManager.getGames().put(0, new Game(0, cuMock, gameManager, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider)); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider)); assertEquals(1, gameManager.get().intValue()); gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider)); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider)); assertEquals(2, gameManager.get().intValue()); gameManager.getGames().put(2, new Game(2, cuMock, gameManager, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider)); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider)); // make sure it says it can't make any more assertEquals(-1, gameManager.get().intValue()); @@ -191,7 +201,7 @@ public class GameManagerTest { assertEquals(1, gameManager.get().intValue()); gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider)); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider)); assertEquals(-1, gameManager.get().intValue()); // remove game 1 out from under it, to make sure it'll fix itself @@ -199,7 +209,7 @@ public class GameManagerTest { assertEquals(1, gameManager.get().intValue()); gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider)); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider)); assertEquals(-1, gameManager.get().intValue()); gameManager.destroyGame(2); diff --git a/src/test/java/net/socialgamer/cah/data/GameTest.java b/src/test/java/net/socialgamer/cah/data/GameTest.java index bccea41..b2eb3db 100644 --- a/src/test/java/net/socialgamer/cah/data/GameTest.java +++ b/src/test/java/net/socialgamer/cah/data/GameTest.java @@ -60,6 +60,15 @@ public class GameTest { private GameManager gmMock; private Metrics metricsMock; private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + private final Provider gameOptionsProvider = new Provider() { + @Override + public GameOptions get() { + return new GameOptions(20, 10, 3, + 20, 10, 0, + 4, 69, 8, + 0, 0, 30); + } + }; private final Provider falseProvider = new Provider() { @Override public Boolean get() { @@ -79,7 +88,7 @@ public class GameTest { gmMock = createMock(GameManager.class); metricsMock = createMock(Metrics.class); game = new Game(0, cuMock, gmMock, timer, null, null, null, metricsMock, falseProvider, - formatProvider, falseProvider, formatProvider, falseProvider); + formatProvider, falseProvider, formatProvider, falseProvider, gameOptionsProvider); } @SuppressWarnings("unchecked")