package com.faforever.client.game;
import com.faforever.client.connectivity.ConnectivityService;
import com.faforever.client.fa.ForgedAllianceService;
import com.faforever.client.fa.RatingMode;
import com.faforever.client.i18n.I18n;
import com.faforever.client.map.MapService;
import com.faforever.client.notification.Action;
import com.faforever.client.notification.DismissAction;
import com.faforever.client.notification.ImmediateNotification;
import com.faforever.client.notification.NotificationService;
import com.faforever.client.notification.ReportAction;
import com.faforever.client.notification.Severity;
import com.faforever.client.patch.GameUpdateService;
import com.faforever.client.player.PlayerService;
import com.faforever.client.preferences.PreferencesService;
import com.faforever.client.rankedmatch.MatchmakerMessage;
import com.faforever.client.relay.LocalRelayServer;
import com.faforever.client.relay.event.RehostRequestEvent;
import com.faforever.client.remote.FafService;
import com.faforever.client.remote.domain.GameInfoMessage;
import com.faforever.client.remote.domain.GameLaunchMessage;
import com.faforever.client.remote.domain.GameState;
import com.faforever.client.remote.domain.GameTypeMessage;
import com.faforever.client.replay.ReplayService;
import com.faforever.client.reporting.ReportingService;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
import static java.util.Collections.emptyMap;
import static java.util.Collections.emptySet;
import static java.util.Collections.singletonList;
import static java.util.Collections.synchronizedList;
import static java.util.Collections.synchronizedMap;
public class GameServiceImpl implements GameService {
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@VisibleForTesting
final BooleanProperty gameRunning;
@VisibleForTesting
final SimpleObjectProperty<GameInfoBean> currentGame;
private final ObservableMap<String, GameTypeBean> gameTypeBeans;
// It is indeed ugly to keep references in both, a list and a map, however I don't see how I can populate the map
// values as an observable list (in order to display it in the games table)
private final ObservableList<GameInfoBean> gameInfoBeans;
private final Map<Integer, GameInfoBean> uidToGameInfoBean;
@Resource
FafService fafService;
@Resource
ForgedAllianceService forgedAllianceService;
@Resource
MapService mapService;
@Resource
PreferencesService preferencesService;
@Resource
GameUpdateService gameUpdateService;
@Resource
NotificationService notificationService;
@Resource
I18n i18n;
@Resource
ApplicationContext applicationContext;
@Resource
ScheduledExecutorService scheduledExecutorService;
@Resource
PlayerService playerService;
@Resource
ConnectivityService connectivityService;
@Resource
LocalRelayServer localRelayServer;
@Resource
ReportingService reportingService;
@Resource
ReplayService replayService;
@Resource
EventBus eventBus;
@VisibleForTesting
RatingMode ratingMode;
private Process process;
private BooleanProperty searching1v1;
private boolean rehostRequested;
public GameServiceImpl() {
gameTypeBeans = FXCollections.observableHashMap();
uidToGameInfoBean = synchronizedMap(new HashMap<>());
searching1v1 = new SimpleBooleanProperty();
gameRunning = new SimpleBooleanProperty();
currentGame = new SimpleObjectProperty<>();
gameInfoBeans = FXCollections.observableList(synchronizedList(new ArrayList<>()),
item -> new Observable[]{item.statusProperty()}
);
}
@Override
public ReadOnlyBooleanProperty gameRunningProperty() {
return gameRunning;
}
@Override
public void addOnGameInfoBeansChangeListener(ListChangeListener<GameInfoBean> listener) {
gameInfoBeans.addListener(listener);
}
@Override
public CompletionStage<Void> hostGame(NewGameInfo newGameInfo) {
if (isRunning()) {
logger.debug("Game is running, ignoring host request");
return CompletableFuture.completedFuture(null);
}
stopSearchRanked1v1();
return updateGameIfNecessary(newGameInfo.getGameType(), null, emptyMap(), newGameInfo.getSimMods())
.thenRun(() -> connectivityService.connect())
.thenRun(() -> localRelayServer.start(connectivityService))
.thenCompose(aVoid -> fafService.requestHostGame(newGameInfo))
.thenAccept(gameLaunchInfo -> {
replayService.startReplayServer(gameLaunchInfo.getUid());
startGame(gameLaunchInfo, null, RatingMode.GLOBAL, localRelayServer.getPort());
});
}
@Override
public CompletionStage<Void> joinGame(GameInfoBean gameInfoBean, String password) {
if (isRunning()) {
logger.debug("Game is running, ignoring join request");
return CompletableFuture.completedFuture(null);
}
logger.info("Joining game: {} ({})", gameInfoBean.getTitle(), gameInfoBean.getUid());
stopSearchRanked1v1();
Map<String, Integer> simModVersions = gameInfoBean.getFeaturedModVersions();
Set<String> simModUIds = gameInfoBean.getSimMods().keySet();
return updateGameIfNecessary(gameInfoBean.getFeaturedMod(), null, simModVersions, simModUIds)
.thenCompose(aVoid -> downloadMapIfNecessary(gameInfoBean.getMapFolderName()))
.thenRun(() -> connectivityService.connect())
.thenRun(() -> localRelayServer.start(connectivityService))
.thenCompose(aVoid -> fafService.requestJoinGame(gameInfoBean.getUid(), password))
.thenAccept(gameLaunchInfo -> {
synchronized (currentGame) {
// Store password in case we rehost
gameInfoBean.setPassword(password);
currentGame.set(gameInfoBean);
}
replayService.startReplayServer(gameLaunchInfo.getUid());
startGame(gameLaunchInfo, null, RatingMode.GLOBAL, localRelayServer.getPort());
});
}
private CompletionStage<Void> downloadMapIfNecessary(String mapFolderName) {
CompletableFuture<Void> future = new CompletableFuture<>();
if (mapService.isInstalled(mapFolderName)) {
future.complete(null);
return future;
}
return mapService.download(mapFolderName);
}
@Override
public List<GameTypeBean> getGameTypes() {
return new ArrayList<>(gameTypeBeans.values());
}
@Override
public void addOnGameTypesChangeListener(MapChangeListener<String, GameTypeBean> changeListener) {
gameTypeBeans.addListener(changeListener);
}
@Override
public void runWithReplay(Path path, @Nullable Integer replayId, String gameType, Integer version, Map<String, Integer> modVersions, Set<String> simMods, String mapName) {
if (isRunning()) {
logger.warn("Forged Alliance is already running, not starting replay");
return;
}
updateGameIfNecessary(gameType, version, modVersions, simMods)
.thenCompose(aVoid -> downloadMapIfNecessary(mapName))
.thenRun(() -> {
try {
process = forgedAllianceService.startReplay(path, replayId, gameType);
setGameRunning(true);
this.ratingMode = RatingMode.NONE;
spawnTerminationListener(process);
} catch (IOException e) {
notifyCantPlayReplay(replayId, e);
}
})
.exceptionally(throwable -> {
notifyCantPlayReplay(replayId, throwable);
return null;
});
}
private void notifyCantPlayReplay(@Nullable Integer replayId, Throwable throwable) {
notificationService.addNotification(new ImmediateNotification(
i18n.get("errorTitle"),
i18n.get("replayCouldNotBeStarted", replayId),
Severity.ERROR, throwable,
singletonList(new Action(i18n.get("report"))))
);
}
@Override
public CompletionStage<Void> runWithLiveReplay(URI replayUrl, Integer gameId, String gameType, String mapName) throws IOException {
if (isRunning()) {
logger.warn("Forged Alliance is already running, not starting live replay");
return CompletableFuture.completedFuture(null);
}
GameInfoBean gameBean = getByUid(gameId);
Map<String, Integer> modVersions = gameBean.getFeaturedModVersions();
Set<String> simModUids = gameBean.getSimMods().keySet();
return updateGameIfNecessary(gameType, null, modVersions, simModUids)
.thenCompose(aVoid -> downloadMapIfNecessary(mapName))
.thenRun(() -> {
try {
process = forgedAllianceService.startReplay(replayUrl, gameId, gameType);
setGameRunning(true);
this.ratingMode = RatingMode.NONE;
spawnTerminationListener(process);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
@Override
public ObservableList<GameInfoBean> getGameInfoBeans() {
return FXCollections.unmodifiableObservableList(gameInfoBeans);
}
@Override
public GameTypeBean getGameTypeByString(String gameTypeName) {
return gameTypeBeans.get(gameTypeName);
}
@Override
public GameInfoBean getByUid(int uid) {
GameInfoBean gameInfoBean = uidToGameInfoBean.get(uid);
if (gameInfoBean == null) {
logger.warn("Can't find {} in gameInfoBean map", uid);
}
return gameInfoBean;
}
@Override
public void addOnRankedMatchNotificationListener(Consumer<MatchmakerMessage> listener) {
fafService.addOnMessageListener(MatchmakerMessage.class, listener);
}
@Override
public CompletionStage<Void> startSearchRanked1v1(Faction faction) {
if (isRunning()) {
logger.debug("Game is running, ignoring 1v1 search request");
return CompletableFuture.completedFuture(null);
}
searching1v1.set(true);
int port = preferencesService.getPreferences().getForgedAlliance().getPort();
return updateGameIfNecessary(GameType.LADDER_1V1.getString(), null, emptyMap(), emptySet())
.thenRun(() -> localRelayServer.start(connectivityService))
.thenCompose(aVoid -> fafService.startSearchRanked1v1(faction, port))
.thenAccept((gameLaunchInfo) -> downloadMapIfNecessary(gameLaunchInfo.getMapname())
.thenRun(() -> {
// TODO this should be sent by the server!
gameLaunchInfo.setArgs(new ArrayList<>(gameLaunchInfo.getArgs()));
gameLaunchInfo.getArgs().add("/team 1");
gameLaunchInfo.getArgs().add("/players 2");
replayService.startReplayServer(gameLaunchInfo.getUid());
startGame(gameLaunchInfo, faction, RatingMode.RANKED_1V1, localRelayServer.getPort());
}))
.exceptionally(throwable -> {
if (throwable instanceof CancellationException) {
logger.info("Ranked1v1 search has been cancelled");
} else {
logger.warn("Ranked1v1 could not be started", throwable);
}
return null;
});
}
@Override
public void stopSearchRanked1v1() {
if (searching1v1.get()) {
fafService.stopSearchingRanked();
searching1v1.set(false);
}
}
@Override
public BooleanProperty searching1v1Property() {
return searching1v1;
}
@Nullable
@Override
public GameInfoBean getCurrentGame() {
synchronized (currentGame) {
return currentGame.get();
}
}
private boolean isRunning() {
return process != null && process.isAlive();
}
private CompletionStage<Void> updateGameIfNecessary(@NotNull String gameType, @Nullable Integer version, @NotNull Map<String, Integer> modVersions, @NotNull Set<String> simModUids) {
return gameUpdateService.updateInBackground(gameType, version, modVersions, simModUids);
}
@Override
public boolean isGameRunning() {
synchronized (gameRunning) {
return gameRunning.get();
}
}
private void setGameRunning(boolean running) {
synchronized (gameRunning) {
gameRunning.set(running);
}
}
/**
* Actually starts the game. Call this method when everything else is prepared (mod/map download, connectivity check
* etc.)
*/
private void startGame(GameLaunchMessage gameLaunchMessage, Faction faction, RatingMode ratingMode, Integer localRelayPort) {
if (isRunning()) {
logger.warn("Forged Alliance is already running, not starting game");
return;
}
stopSearchRanked1v1();
List<String> args = fixMalformedArgs(gameLaunchMessage.getArgs());
try {
localRelayServer.getPort();
process = forgedAllianceService.startGame(gameLaunchMessage.getUid(), gameLaunchMessage.getMod(), faction, args, ratingMode, localRelayPort, rehostRequested);
setGameRunning(true);
this.ratingMode = ratingMode;
spawnTerminationListener(process);
} catch (IOException e) {
logger.warn("Game could not be started", e);
notificationService.addNotification(
new ImmediateNotification(i18n.get("errorTitle"),
i18n.get("game.start.couldNotStart"), Severity.ERROR, e, Arrays.asList(
new ReportAction(i18n, reportingService, e), new DismissAction(i18n)))
);
}
}
/**
* A correct argument list looks like ["/ratingcolor", "d8d8d8d8", "/numgames", "236"]. However, the FAF server sends
* it as ["/ratingcolor d8d8d8d8", "/numgames 236"]. This method fixes this.
*/
private List<String> fixMalformedArgs(List<String> gameLaunchMessage) {
ArrayList<String> fixedArgs = new ArrayList<>();
for (String combinedArg : gameLaunchMessage) {
String[] split = combinedArg.split(" ");
Collections.addAll(fixedArgs, split);
}
return fixedArgs;
}
@VisibleForTesting
void spawnTerminationListener(Process process) {
CompletableFuture.runAsync(() -> {
try {
rehostRequested = false;
int exitCode = process.waitFor();
logger.info("Forged Alliance terminated with exit code {}", exitCode);
synchronized (gameRunning) {
gameRunning.set(false);
localRelayServer.close();
fafService.notifyGameEnded();
replayService.stopReplayServer();
if (rehostRequested) {
rehost();
}
}
} catch (InterruptedException e) {
logger.warn("Error during post-game processing", e);
}
}, scheduledExecutorService);
}
private void rehost() {
GameInfoBean gameInfoBean = currentGame.get();
hostGame(new NewGameInfo(
gameInfoBean.getTitle(),
gameInfoBean.getPassword(),
gameInfoBean.getFeaturedMod(),
gameInfoBean.getMapFolderName(),
new HashSet<>(gameInfoBean.getSimMods().values())));
}
@Subscribe
public void onRehostRequest(RehostRequestEvent event) {
this.rehostRequested = true;
synchronized (gameRunning) {
if (!gameRunning.get()) {
// If the game already has terminated, the rehost is issued here. Otherwise it will be issued after termination
rehost();
}
}
}
@PostConstruct
void postConstruct() {
eventBus.register(this);
fafService.addOnMessageListener(GameTypeMessage.class, this::onGameTypeInfo);
fafService.addOnMessageListener(GameInfoMessage.class, this::onGameInfo);
}
private void onGameTypeInfo(GameTypeMessage gameTypeMessage) {
if (!gameTypeMessage.isPublish() || gameTypeBeans.containsKey(gameTypeMessage.getName())) {
return;
}
gameTypeBeans.put(gameTypeMessage.getName(), new GameTypeBean(gameTypeMessage));
}
private void onGameInfo(GameInfoMessage gameInfoMessage) {
if (gameInfoMessage.getGames() != null) {
gameInfoMessage.getGames().forEach(this::onGameInfo);
return;
}
if (GameState.CLOSED == gameInfoMessage.getState()) {
gameInfoBeans.remove(uidToGameInfoBean.remove(gameInfoMessage.getUid()));
return;
}
final GameInfoBean gameInfoBean;
if (!uidToGameInfoBean.containsKey(gameInfoMessage.getUid())) {
gameInfoBean = new GameInfoBean(gameInfoMessage);
gameInfoBeans.add(gameInfoBean);
uidToGameInfoBean.put(gameInfoMessage.getUid(), gameInfoBean);
} else {
gameInfoBean = uidToGameInfoBean.get(gameInfoMessage.getUid());
Platform.runLater(() -> gameInfoBean.updateFromGameInfo(gameInfoMessage));
}
boolean currentPlayerInGame = gameInfoMessage.getTeams().values().stream()
.anyMatch(team -> team.contains(playerService.getCurrentPlayer().getUsername()));
if (currentPlayerInGame && GameState.OPEN == gameInfoMessage.getState()) {
synchronized (currentGame) {
currentGame.set(gameInfoBean);
}
}
}
}