Add user client information to user metrics info (language, device class, name).

Add metrics logging for server start up, user disconnect, and card judging events.
This commit is contained in:
Andy Janata 2017-02-23 22:32:22 -08:00
parent e1bc2c4176
commit 45690c1914
16 changed files with 252 additions and 59 deletions

View File

@ -108,6 +108,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
<h2>Server</h2>
This product includes GeoLite2 data created by MaxMind, available from
<a href="http://www.maxmind.com">http://www.maxmind.com</a>.
<h3>ANTLR</h3>
Copyright (c) 2010 Terence Parr
<br /> All rights reserved.

10
pom.xml
View File

@ -353,5 +353,15 @@
<artifactId>geoip2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>net.sf.uadetector</groupId>
<artifactId>uadetector-core</artifactId>
<version>0.9.22</version>
</dependency>
<dependency>
<groupId>net.sf.uadetector</groupId>
<artifactId>uadetector-resources</artifactId>
<version>2014.10</version>
</dependency>
</dependencies>
</project>

View File

@ -1,5 +1,5 @@
/**
* Copyright (c) 2012, Andy Janata
* Copyright (c) 2012-2017, Andy Janata
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted
@ -57,6 +57,18 @@ public class RequestWrapper {
return request.getParameter(parameter.toString());
}
/**
* Returns the value of a request header as a String, or {@code null} if the header does not
* exist.
*
* @param header
* Header to get.
* @return Value of header, or {@code null} if header does not exist.
*/
public String getHeader(final String header) {
return request.getHeader(header);
}
/**
* If there is an {@code X-Forwarded-For} header, the <strong>first</strong> entry in that list
* is returned instead.

View File

@ -34,8 +34,10 @@ import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import net.socialgamer.cah.CahModule.ServerStarted;
import net.socialgamer.cah.CahModule.UniqueId;
import net.socialgamer.cah.cardcast.CardcastModule;
import net.socialgamer.cah.cardcast.CardcastService;
import net.socialgamer.cah.metrics.Metrics;
import net.socialgamer.cah.task.BroadcastGameListUpdateTask;
import net.socialgamer.cah.task.UserPingTask;
@ -131,6 +133,10 @@ public class StartupUtils extends GuiceServletContextListener {
reconfigureLogging(contextEvent.getServletContext());
reloadProperties(contextEvent.getServletContext());
CardcastService.hackSslVerifier();
// log that the server (re-)started to metrics logging (to flush all old games and users)
injector.getInstance(Metrics.class).serverStarted(
injector.getInstance(Key.get(String.class, UniqueId.class)));
}
public static void reloadProperties(final ServletContext context) {

View File

@ -142,7 +142,8 @@ public class ConnectedUsers {
logger.warn(String.format("Unable to get address for user %s (hostname: %s)",
user.getNickname(), user.getHostname()), e);
}
metrics.newUser(user.getPersistentId(), user.getSessionId(), geo);
metrics.newUser(user.getPersistentId(), user.getSessionId(), geo, user.getAgentName(),
user.getAgentType(), user.getAgentOs(), user.getAgentLanguage());
return null;
}
@ -162,7 +163,7 @@ public class ConnectedUsers {
synchronized (users) {
if (users.containsKey(user.getNickname())) {
logger.info(String.format("Removing user %s because %s", user.toString(), reason));
user.noLongerVaild();
user.noLongerValid();
users.remove(user.getNickname().toLowerCase());
notifyRemoveUser(user, reason);
}
@ -181,7 +182,7 @@ public class ConnectedUsers {
}
/**
* Broadcast to all remaining users that a user has left.
* Broadcast to all remaining users that a user has left. Also logs for metrics.
*
* @param user
* User that has left.
@ -197,6 +198,8 @@ public class ConnectedUsers {
data.put(LongPollResponse.REASON, reason.toString());
broadcastToAll(MessageType.PLAYER_EVENT, data);
}
metrics.userDisconnect(user.getSessionId());
}
/**
@ -226,7 +229,7 @@ public class ConnectedUsers {
// Do this later to not keep users locked
for (final Entry<User, DisconnectReason> entry : removedUsers.entrySet()) {
try {
entry.getKey().noLongerVaild();
entry.getKey().noLongerValid();
notifyRemoveUser(entry.getKey(), entry.getValue());
logger.info(String.format("Automatically kicking user %s due to %s", entry.getKey(),
entry.getValue()));

View File

@ -56,8 +56,10 @@ 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;
@ -109,6 +111,7 @@ public class Game {
private GameState state;
private final GameOptions options = new GameOptions();
private final Set<String> cardcastDeckIds = Collections.synchronizedSet(new HashSet<String>());
private final Metrics metrics;
private int judgeIndex = 0;
@ -194,7 +197,8 @@ public class Game {
final GameManager gameManager, final ScheduledThreadPoolExecutor globalTimer,
final Provider<Session> sessionProvider,
final Provider<CardcastService> cardcastServiceProvider,
@UniqueId final Provider<String> uniqueIdProvider) {
@UniqueId final Provider<String> uniqueIdProvider,
final Metrics metrics) {
this.id = id;
this.connectedUsers = connectedUsers;
this.gameManager = gameManager;
@ -202,6 +206,7 @@ public class Game {
this.sessionProvider = sessionProvider;
this.cardcastServiceProvider = cardcastServiceProvider;
this.uniqueIdProvider = uniqueIdProvider;
this.metrics = metrics;
state = GameState.LOBBY;
}
@ -683,10 +688,14 @@ public class Game {
options.spectatorLimit, options.scoreGoal, players, currentUniqueId));
// do this stuff outside the players lock; they will lock players again later for much less
// time, and not at the same time as trying to lock users, which has caused deadlocks
final List<CardSet> cardSets;
synchronized (options.cardSetIds) {
blackDeck = loadBlackDeck(session);
whiteDeck = loadWhiteDeck(session);
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();
}
@ -698,7 +707,7 @@ public class Game {
}
}
private List<CardSet> loadCardSets(final Session session) {
public List<CardSet> loadCardSets(final Session session) {
synchronized (options.cardSetIds) {
try {
final List<CardSet> cardSets = new ArrayList<>();
@ -738,12 +747,12 @@ public class Game {
}
}
public BlackDeck loadBlackDeck(final Session session) {
return new BlackDeck(loadCardSets(session));
public BlackDeck loadBlackDeck(final List<CardSet> cardSets) {
return new BlackDeck(cardSets);
}
public WhiteDeck loadWhiteDeck(final Session session) {
return new WhiteDeck(loadCardSets(session), options.blanksInDeck);
public WhiteDeck loadWhiteDeck(final List<CardSet> cardSets) {
return new WhiteDeck(cardSets, options.blanksInDeck);
}
public int getRequiredWhiteCardCount() {
@ -752,21 +761,21 @@ public class Game {
/**
* Determine if there are sufficient cards in the selected card sets to start the game.
* <p>This could be done more efficiently as we're ending up loading the decks multiple times
* with different Sessions, so caching wouldn't help local decks.
*/
public boolean hasEnoughCards(final Session session) {
synchronized (options.cardSetIds) {
if (options.cardSetIds.isEmpty() && cardcastDeckIds.isEmpty()) {
final List<CardSet> cardSets = loadCardSets(session);
if (cardSets.isEmpty()) {
return false;
}
final BlackDeck tempBlackDeck = loadBlackDeck(session);
final BlackDeck tempBlackDeck = loadBlackDeck(cardSets);
if (tempBlackDeck.totalCount() < MINIMUM_BLACK_CARDS) {
return false;
}
final WhiteDeck tempWhiteDeck = loadWhiteDeck(session);
final WhiteDeck tempWhiteDeck = loadWhiteDeck(cardSets);
if (tempWhiteDeck.totalCount() < getRequiredWhiteCardCount()) {
return false;
}
@ -1503,6 +1512,9 @@ public class Game {
rescheduleTimer(task, ROUND_INTERMISSION);
}
metrics.roundJudged(currentUniqueId, user.getSessionId(), cardPlayer.getUser().getSessionId(),
playedCards.cardsByUser());
return null;
}

View File

@ -1,5 +1,5 @@
/**
* Copyright (c) 2012, Andy Janata
* Copyright (c) 2012-2017, Andy Janata
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted
@ -25,13 +25,12 @@ package net.socialgamer.cah.data;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.socialgamer.cah.data.WhiteCard;
/**
* Class to track which card(s) have been played by players. Can get the card(s) for a player, and
@ -143,4 +142,16 @@ public class PlayerPlayedCardsTracker {
public synchronized Collection<List<WhiteCard>> cards() {
return playerCardMap.values();
}
/**
* @return A {@code Map} of users to a {@code List} of the cards they played.
*/
public synchronized Map<User, List<WhiteCard>> cardsByUser() {
final Map<User, List<WhiteCard>> cardsByUser = new HashMap<>();
// TODO java8: streams
for (final Map.Entry<Player, List<WhiteCard>> entry : playerCardMap.entrySet()) {
cardsByUser.put(entry.getKey().getUser(), entry.getValue());
}
return Collections.unmodifiableMap(cardsByUser);
}
}

View File

@ -30,6 +30,8 @@ import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.PriorityBlockingQueue;
import net.sf.uadetector.ReadableUserAgent;
import net.sf.uadetector.service.UADetectorServiceFactory;
import net.socialgamer.cah.CahModule.UniqueId;
import com.google.inject.Inject;
@ -63,6 +65,10 @@ public class User {
private final String sessionId;
private final String clientLanguage;
private final ReadableUserAgent agent;
private final List<Long> lastMessageTimes = Collections.synchronizedList(new LinkedList<Long>());
/**
@ -89,18 +95,24 @@ public class User {
@Assisted("hostname") final String hostname,
@Assisted final boolean isAdmin,
@Assisted("persistentId") final String persistentId,
@UniqueId final String sessionId) {
@UniqueId final String sessionId,
@Assisted("clientLanguage") final String clientLanguage,
@Assisted("clientAgent") final String clientAgent) {
this.nickname = nickname;
this.hostname = hostname;
this.isAdmin = isAdmin;
this.persistentId = persistentId;
this.sessionId = sessionId;
this.clientLanguage = clientLanguage;
agent = UADetectorServiceFactory.getResourceModuleParser().parse(clientAgent);
queuedMessages = new PriorityBlockingQueue<QueuedMessage>();
}
public interface Factory {
User create(@Assisted("nickname") String nickname, @Assisted("hostname") String hostname,
boolean isAdmin, @Assisted("persistentId") String persistentId);
boolean isAdmin, @Assisted("persistentId") String persistentId,
@Assisted("clientLanguage") String clientLanguage,
@Assisted("clientAgent") String clientAgent);
}
/**
@ -193,6 +205,22 @@ public class User {
return hostname;
}
public String getAgentName() {
return agent.getName();
}
public String getAgentType() {
return agent.getDeviceCategory().getName();
}
public String getAgentOs() {
return agent.getOperatingSystem().getName();
}
public String getAgentLanguage() {
return clientLanguage.split(",")[0];
}
@Override
public String toString() {
return getNickname();
@ -230,7 +258,7 @@ public class User {
/**
* Mark this user as no longer valid, probably because they pinged out.
*/
public void noLongerVaild() {
public void noLongerValid() {
if (currentGame != null) {
currentGame.removePlayer(this);
}

View File

@ -63,7 +63,7 @@ public class LogoutHandler extends Handler {
final User user = (User) session.getAttribute(SessionAttribute.USER);
assert (user != null);
user.noLongerVaild();
user.noLongerValid();
users.removeUser(user, DisconnectReason.MANUAL);
session.invalidate();
return data;

View File

@ -44,6 +44,7 @@ import net.socialgamer.cah.data.ConnectedUsers;
import net.socialgamer.cah.data.User;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpHeaders;
import com.google.inject.Inject;
import com.google.inject.Provider;
@ -99,7 +100,9 @@ public class RegisterHandler extends Handler {
}
final User user = userFactory.create(nick, request.getRemoteAddr(),
Constants.ADMIN_IP_ADDRESSES.contains(request.getRemoteAddr()), persistentId);
Constants.ADMIN_IP_ADDRESSES.contains(request.getRemoteAddr()), persistentId,
request.getHeader(HttpHeaders.ACCEPT_LANGUAGE),
request.getHeader(HttpHeaders.USER_AGENT));
final ErrorCode errorCode = users.checkAndAdd(user);
if (null == errorCode) {
// There is a findbugs warning on this line:

View File

@ -1,5 +1,5 @@
/**
* Copyright (c) 2012, Andy Janata
* Copyright (c) 2012-2017, Andy Janata
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted
@ -24,6 +24,7 @@
package net.socialgamer.cah.handlers;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpSession;
@ -34,6 +35,7 @@ import net.socialgamer.cah.Constants.ErrorInformation;
import net.socialgamer.cah.Constants.GameState;
import net.socialgamer.cah.Constants.ReturnableData;
import net.socialgamer.cah.RequestWrapper;
import net.socialgamer.cah.data.CardSet;
import net.socialgamer.cah.data.Game;
import net.socialgamer.cah.data.GameManager;
import net.socialgamer.cah.data.User;
@ -72,11 +74,10 @@ public class StartGameHandler extends GameWithPlayerHandler {
} else if (game.getState() != GameState.LOBBY) {
return error(ErrorCode.ALREADY_STARTED);
} else if (!game.hasEnoughCards(hibernateSession)) {
data.put(ErrorInformation.BLACK_CARDS_PRESENT, game.loadBlackDeck(hibernateSession)
.totalCount());
final List<CardSet> cardSets = game.loadCardSets(hibernateSession);
data.put(ErrorInformation.BLACK_CARDS_PRESENT, game.loadBlackDeck(cardSets).totalCount());
data.put(ErrorInformation.BLACK_CARDS_REQUIRED, Game.MINIMUM_BLACK_CARDS);
data.put(ErrorInformation.WHITE_CARDS_PRESENT, game.loadWhiteDeck(hibernateSession)
.totalCount());
data.put(ErrorInformation.WHITE_CARDS_PRESENT, game.loadWhiteDeck(cardSets).totalCount());
data.put(ErrorInformation.WHITE_CARDS_REQUIRED, game.getRequiredWhiteCardCount());
return error(ErrorCode.NOT_ENOUGH_CARDS, data);
} else if (!game.start()) {

View File

@ -23,6 +23,14 @@
package net.socialgamer.cah.metrics;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import net.socialgamer.cah.data.CardSet;
import net.socialgamer.cah.data.User;
import net.socialgamer.cah.data.WhiteCard;
import org.apache.log4j.Logger;
import com.google.inject.Singleton;
@ -40,7 +48,35 @@ public class KafkaMetrics implements Metrics {
private static final Logger LOG = Logger.getLogger(KafkaMetrics.class);
@Override
public void newUser(final String guid, final String sessionId, final CityResponse geoIp) {
LOG.trace(String.format("newUser(%s, %s, %s)", guid, sessionId, geoIp));
public void serverStarted(final String startupId) {
LOG.trace(String.format("serverStarted(%s)", startupId));
}
@Override
public void newUser(final String guid, final String sessionId, final CityResponse geoIp,
final String agentName, final String agentType, final String agentOs,
final String agentLanguage) {
LOG.trace(String.format("newUser(%s, %s, %s, %s, %s, %s, %s)", guid, sessionId, geoIp,
agentName, agentType, agentOs, agentLanguage));
}
@Override
public void userDisconnect(final String sessionId) {
LOG.trace(String.format("userDisconnect(%s)", sessionId));
}
@Override
public void gameStart(final String gameId, final Collection<CardSet> decks, final int blanks,
final int maxPlayers, final int scoreGoal, final boolean hasPassword) {
LOG.trace(String.format("gameStart(%s, %s, %d, %d, %d, %s)", gameId, decks.toArray(), blanks,
maxPlayers, scoreGoal, hasPassword));
}
@Override
public void roundJudged(final String gameId, final String judgeSessionId,
final String winnerSessionId,
final Map<User, List<WhiteCard>> cards) {
LOG.trace(String.format("roundJudged(%s, %s, %s, %s)", gameId, judgeSessionId, winnerSessionId,
cards));
}
}

View File

@ -23,8 +23,16 @@
package net.socialgamer.cah.metrics;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import net.socialgamer.cah.data.CardSet;
import net.socialgamer.cah.data.User;
import net.socialgamer.cah.data.WhiteCard;
import com.maxmind.geoip2.model.CityResponse;
@ -34,5 +42,18 @@ import com.maxmind.geoip2.model.CityResponse;
* @author Andy Janata (ajanata@socialgamer.net)
*/
public interface Metrics {
void newUser(String persistentId, String sessionId, @Nullable CityResponse geoIp);
void serverStarted(String startupId);
void newUser(String persistentId, String sessionId, @Nullable CityResponse geoIp,
String agentName, String agentType, String agentOs, String agentLanguage);
void userDisconnect(String sessionId);
// The card data is way too complicated to dictate the format it should be in, so let
// implementations deal with the structured data.
void roundJudged(String gameId, String judgeSessionId, String winnerSessionId,
Map<User, List<WhiteCard>> cards);
void gameStart(String gameId, Collection<CardSet> decks, int blanks, int maxPlayers,
int scoreGoal, boolean hasPassword);
}

View File

@ -23,6 +23,14 @@
package net.socialgamer.cah.metrics;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import net.socialgamer.cah.data.CardSet;
import net.socialgamer.cah.data.User;
import net.socialgamer.cah.data.WhiteCard;
import org.apache.log4j.Logger;
import com.google.inject.Singleton;
@ -40,7 +48,35 @@ public class NoOpMetrics implements Metrics {
private static final Logger LOG = Logger.getLogger(NoOpMetrics.class);
@Override
public void newUser(final String guid, final String sessionId, final CityResponse geoIp) {
LOG.trace(String.format("newUser(%s, %s, %s)", guid, sessionId, geoIp));
public void serverStarted(final String startupId) {
LOG.trace(String.format("serverStarted(%s)", startupId));
}
@Override
public void newUser(final String guid, final String sessionId, final CityResponse geoIp,
final String agentName, final String agentType, final String agentOs,
final String agentLanguage) {
LOG.trace(String.format("newUser(%s, %s, %s, %s, %s, %s, %s)", guid, sessionId, geoIp,
agentName, agentType, agentOs, agentLanguage));
}
@Override
public void userDisconnect(final String sessionId) {
LOG.trace(String.format("userDisconnect(%s)", sessionId));
}
@Override
public void gameStart(final String gameId, final Collection<CardSet> decks, final int blanks,
final int maxPlayers, final int scoreGoal, final boolean hasPassword) {
LOG.trace(String.format("gameStart(%s, %s, %d, %d, %d, %s)", gameId, decks.toArray(), blanks,
maxPlayers, scoreGoal, hasPassword));
}
@Override
public void roundJudged(final String gameId, final String judgeSessionId,
final String winnerSessionId,
final Map<User, List<WhiteCard>> cards) {
LOG.trace(String.format("roundJudged(%s, %s, %s, %s)", gameId, judgeSessionId, winnerSessionId,
cards));
}
}

View File

@ -45,6 +45,7 @@ import net.socialgamer.cah.cardcast.CardcastModule.CardcastCardId;
import net.socialgamer.cah.data.GameManager.GameId;
import net.socialgamer.cah.data.GameManager.MaxGames;
import net.socialgamer.cah.data.QueuedMessage.MessageType;
import net.socialgamer.cah.metrics.Metrics;
import org.hibernate.Session;
import org.junit.After;
@ -70,11 +71,13 @@ public class GameManagerTest {
private User userMock;
private int gameId;
private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1);
private Metrics metricsMock;
@Before
public void setUp() throws Exception {
cuMock = createMock(ConnectedUsers.class);
userMock = createMock(User.class);
metricsMock = createMock(Metrics.class);
injector = Guice.createInjector(new AbstractModule() {
@Override
@ -143,11 +146,14 @@ public class GameManagerTest {
// fill it up with 3 games
assertEquals(0, gameManager.get().intValue());
gameManager.getGames().put(0, new Game(0, cuMock, gameManager, timer, null, null, null));
gameManager.getGames().put(0,
new Game(0, cuMock, gameManager, timer, null, null, null, metricsMock));
assertEquals(1, gameManager.get().intValue());
gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null));
gameManager.getGames().put(1,
new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock));
assertEquals(2, gameManager.get().intValue());
gameManager.getGames().put(2, new Game(2, cuMock, gameManager, timer, null, null, null));
gameManager.getGames().put(2,
new Game(2, cuMock, gameManager, timer, null, null, null, metricsMock));
// make sure it says it can't make any more
assertEquals(-1, gameManager.get().intValue());
@ -155,13 +161,15 @@ public class GameManagerTest {
gameManager.destroyGame(1);
// make sure it re-uses that id
assertEquals(1, gameManager.get().intValue());
gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null));
gameManager.getGames().put(1,
new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock));
assertEquals(-1, gameManager.get().intValue());
// remove game 1 out from under it, to make sure it'll fix itself
gameManager.getGames().remove(1);
assertEquals(1, gameManager.get().intValue());
gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null, null));
gameManager.getGames().put(1,
new Game(1, cuMock, gameManager, timer, null, null, null, metricsMock));
assertEquals(-1, gameManager.get().intValue());
gameManager.destroyGame(2);

View File

@ -40,6 +40,7 @@ import java.util.concurrent.ScheduledThreadPoolExecutor;
import net.socialgamer.cah.data.Game.TooManyPlayersException;
import net.socialgamer.cah.data.QueuedMessage.MessageType;
import net.socialgamer.cah.metrics.Metrics;
import org.junit.Before;
import org.junit.Test;
@ -55,13 +56,15 @@ public class GameTest {
private Game game;
private ConnectedUsers cuMock;
private GameManager gmMock;
private Metrics metricsMock;
private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1);
@Before
public void setUp() throws Exception {
cuMock = createMock(ConnectedUsers.class);
gmMock = createMock(GameManager.class);
game = new Game(0, cuMock, gmMock, timer, null, null, null);
metricsMock = createMock(Metrics.class);
game = new Game(0, cuMock, gmMock, timer, null, null, null, metricsMock);
}
@SuppressWarnings("unchecked")
@ -75,8 +78,8 @@ public class GameTest {
expectLastCall().once();
replay(gmMock);
final User user1 = new User("test1", "test.lan", false, "1", "1");
final User user2 = new User("test2", "test.lan", false, "2", "2");
final User user1 = new User("test1", "test.lan", false, "1", "1", "en-US", "JUnit");
final User user2 = new User("test2", "test.lan", false, "2", "2", "en-US", "JUnit");
game.addPlayer(user1);
game.addPlayer(user2);