diff --git a/build.gradle b/build.gradle index f499379900..0a273ef21a 100644 --- a/build.gradle +++ b/build.gradle @@ -298,7 +298,7 @@ dependencies { implementation("org.springframework:spring-web") implementation("org.springframework:spring-websocket") - def commonsVersion = "b0a374fa17988e0a8249fcf9b6d3a511b476ef8d" + def commonsVersion = "aca27907ccd99917112e02901a6e80de0cc44684" implementation("com.github.FAForever.faf-java-commons:faf-commons-data:${commonsVersion}") { exclude module: 'guava' diff --git a/src/main/java/com/faforever/client/game/GameService.java b/src/main/java/com/faforever/client/game/GameService.java index 122b77f803..830e01d03f 100644 --- a/src/main/java/com/faforever/client/game/GameService.java +++ b/src/main/java/com/faforever/client/game/GameService.java @@ -41,6 +41,7 @@ import com.faforever.client.util.ConcurrentUtil; import com.faforever.client.util.MaskPatternLayout; import com.faforever.commons.lobby.GameInfo; +import com.faforever.commons.lobby.GameLaunchResponse; import com.faforever.commons.lobby.GameStatus; import com.faforever.commons.lobby.GameVisibility; import com.google.common.annotations.VisibleForTesting; @@ -514,18 +515,34 @@ public CompletableFuture startSearchMatchmaker() { return matchmakerFuture; } + matchmakerFuture = listenForServerInitiatedGame(FAF.getTechnicalName()); + return matchmakerFuture; + } + + public CompletableFuture startListeningForTournamentGame(String featuredModTechnicalName) { + if (isRunning()) { + log.info("Game is running, ignoring tournament search request"); + notificationService.addImmediateWarnNotification("game.gameRunning"); + return completedFuture(null); + } + + return listenForServerInitiatedGame(featuredModTechnicalName); + } + + private CompletableFuture listenForServerInitiatedGame(String featuredModTechnicalName) { if (!preferencesService.isGamePathValid()) { CompletableFuture gameDirectoryFuture = postGameDirectoryChooseEvent(); return gameDirectoryFuture.thenCompose(path -> startSearchMatchmaker()); } - log.info("Matchmaking search has been started"); + log.info("Started listening for game launch message"); - matchmakerFuture = modService.getFeaturedMod(FAF.getTechnicalName()) + final CompletableFuture gameLaunchMessageFuture = fafServerAccessor.getGameLaunchMessageFuture(); + final CompletableFuture matchFuture = modService.getFeaturedMod(featuredModTechnicalName) .thenAccept(featuredModBean -> updateGameIfNecessary(featuredModBean, Set.of())) - .thenCompose(aVoid -> fafServerAccessor.startSearchMatchmaker()) + .thenCompose(aVoid -> gameLaunchMessageFuture) .thenCompose(gameLaunchResponse -> downloadMapIfNecessary(gameLaunchResponse.getMapName()) - .thenCompose(aVoid -> leaderboardService.getActiveLeagueEntryForPlayer(playerService.getCurrentPlayer(), gameLaunchResponse.getLeaderboard())) + .thenCompose(aVoid -> leaderboardService.getActiveLeagueEntryForPlayer(playerService.getCurrentPlayer(),gameLaunchResponse.getLeaderboard())) .thenApply(leagueEntryOptional -> { GameParameters parameters = gameMapper.map(gameLaunchResponse); parameters.setDivision(leagueEntryOptional.map(bean -> bean.getSubdivision().getDivision().getNameKey()) @@ -536,25 +553,25 @@ public CompletableFuture startSearchMatchmaker() { }) .thenCompose(this::startGame)); - matchmakerFuture.whenComplete((aVoid, throwable) -> { + matchFuture.whenComplete((aVoid, throwable) -> { if (throwable != null) { throwable = ConcurrentUtil.unwrapIfCompletionException(throwable); if (throwable instanceof CancellationException) { - log.info("Matchmaking search has been cancelled"); + log.info("Listening to server made game has been cancelled"); if (isRunning()) { notificationService.addServerNotification(new ImmediateNotification(i18n.get("matchmaker.cancelled.title"), i18n.get("matchmaker.cancelled"), Severity.INFO)); gameKilled = true; process.destroy(); } } else { - log.warn("Matchmade game could not be started", throwable); + log.warn("Game could not be started", throwable); } } else { log.info("Matchmaker queue exited"); } }); - return matchmakerFuture; + return matchFuture; } /** diff --git a/src/main/java/com/faforever/client/leaderboard/LeaderboardService.java b/src/main/java/com/faforever/client/leaderboard/LeaderboardService.java index 0363effb60..d1f63a4b44 100644 --- a/src/main/java/com/faforever/client/leaderboard/LeaderboardService.java +++ b/src/main/java/com/faforever/client/leaderboard/LeaderboardService.java @@ -21,6 +21,7 @@ import com.faforever.commons.api.dto.LeagueSeasonScore; import com.faforever.commons.api.elide.ElideNavigator; import com.faforever.commons.api.elide.ElideNavigatorOnCollection; +import com.google.common.base.Strings; import javafx.scene.image.Image; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -174,6 +175,9 @@ public CompletableFuture> getHighestActiveLeagueEntryF @Cacheable(value = CacheNames.LEAGUE_ENTRIES, sync = true) public CompletableFuture> getActiveLeagueEntryForPlayer(PlayerBean player, String leaderboardName) { + if (Strings.isNullOrEmpty(leaderboardName)) { + return CompletableFuture.completedFuture(Optional.empty()); + } ElideNavigatorOnCollection navigator = ElideNavigator.of(LeagueSeasonScore.class).collection() .setFilter(qBuilder() .intNum("loginId").eq(player.getId()) diff --git a/src/main/java/com/faforever/client/main/MainController.java b/src/main/java/com/faforever/client/main/MainController.java index f319ee2670..c4e9405b77 100644 --- a/src/main/java/com/faforever/client/main/MainController.java +++ b/src/main/java/com/faforever/client/main/MainController.java @@ -472,7 +472,7 @@ private void makePopUpAskingForPreferenceInStartTab(WindowPrefs mainWindow) { }); ImmediateNotification notification = new ImmediateNotification(i18n.get("startTab.title"), i18n.get("startTab.message"), - Severity.INFO, null, Collections.singletonList(saveAction), startTabChooseController.getRoot()); + Severity.INFO, null, Collections.singletonList(saveAction), startTabChooseController.getRoot(), false); notificationService.addNotification(notification); } @@ -594,9 +594,11 @@ private void displayImmediateNotification(ImmediateNotification notification) { .setNotification(notification) .setCloseListener(dialog::close); + dialog.setOverlayClose(notification.isOverlayClose()); dialog.setContent(controller.getDialogLayout()); dialog.setAnimation(AlertAnimation.TOP_ANIMATION); dialog.show(); + } private void displayServerNotification(ImmediateNotification notification) { diff --git a/src/main/java/com/faforever/client/notification/ImmediateNotification.java b/src/main/java/com/faforever/client/notification/ImmediateNotification.java index 96e39117fd..2cb78c9af3 100644 --- a/src/main/java/com/faforever/client/notification/ImmediateNotification.java +++ b/src/main/java/com/faforever/client/notification/ImmediateNotification.java @@ -22,20 +22,37 @@ public class ImmediateNotification { private final Throwable throwable; private final List actions; private final Parent customUI; + /** + * If false notification will not close by clicking beside it. + */ + private final boolean overlayClose; + private Runnable dismissAction; public ImmediateNotification(String title, String text, Severity severity) { this(title, text, severity, null); } public ImmediateNotification(String title, String text, Severity severity, List actions) { - this(title, text, severity, null, actions, null); + this(title, text, severity, null, actions, null, true); } public ImmediateNotification(String title, String text, Severity severity, Throwable throwable, List actions) { - this(title, text, severity, throwable, actions, null); + this(title, text, severity, throwable, actions, null, true); } public ImmediateNotification(String title, String text, Severity severity, List actions, Parent customUI) { - this(title, text, severity, null, actions, customUI); + this(title, text, severity, null, actions, customUI, true); + } + + public ImmediateNotification(String title, String text, Severity severity, Throwable throwable, List actions, Parent customUI) { + this(title, text, severity, throwable, actions, customUI, true); + } + + public void setCloseAction(Runnable dismissAction) { + this.dismissAction = dismissAction; + } + + public void dismiss(){ + dismissAction.run(); } } diff --git a/src/main/java/com/faforever/client/notification/ImmediateNotificationController.java b/src/main/java/com/faforever/client/notification/ImmediateNotificationController.java index 5af3c08f9c..d53cff4d55 100644 --- a/src/main/java/com/faforever/client/notification/ImmediateNotificationController.java +++ b/src/main/java/com/faforever/client/notification/ImmediateNotificationController.java @@ -70,6 +70,7 @@ public ImmediateNotificationController setNotification(ImmediateNotification not if (notification.getCustomUI() != null) { immediateNotificationRoot.getChildren().add(notification.getCustomUI()); } + notification.setCloseAction(this::dismiss); return this; } diff --git a/src/main/java/com/faforever/client/remote/FafServerAccessor.java b/src/main/java/com/faforever/client/remote/FafServerAccessor.java index f35f3a4a82..1a4120a875 100644 --- a/src/main/java/com/faforever/client/remote/FafServerAccessor.java +++ b/src/main/java/com/faforever/client/remote/FafServerAccessor.java @@ -212,7 +212,7 @@ public void requestMatchmakerInfo() { lobbyClient.requestMatchmakerInfo(); } - public CompletableFuture startSearchMatchmaker() { + public CompletableFuture getGameLaunchMessageFuture() { return lobbyClient.getEvents() .filter(event -> event instanceof GameLaunchResponse) .next() @@ -228,6 +228,10 @@ public void removeFriend(int playerId) { lobbyClient.removeFriend(playerId); } + public void sendIsReady(String requestId) { + lobbyClient.sendReady(requestId); + } + public void removeFoe(int playerId) { lobbyClient.removeFoe(playerId); } diff --git a/src/main/java/com/faforever/client/theme/UiService.java b/src/main/java/com/faforever/client/theme/UiService.java index 224ed615ec..1c6bb84231 100644 --- a/src/main/java/com/faforever/client/theme/UiService.java +++ b/src/main/java/com/faforever/client/theme/UiService.java @@ -9,6 +9,7 @@ import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.i18n.I18n; import com.faforever.client.preferences.PreferencesService; +import com.faforever.client.ui.StageHolder; import com.faforever.client.ui.dialog.Dialog; import com.faforever.client.ui.dialog.Dialog.DialogTransition; import com.faforever.client.ui.dialog.DialogLayout; @@ -30,6 +31,7 @@ import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.web.WebView; +import javafx.stage.Stage; import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.utils.IOUtils; import org.springframework.beans.factory.DisposableBean; @@ -390,13 +392,22 @@ private String[] getStylesheets() { getThemeFile("theme/colors.css"), getThemeFile("theme/icons.css"), getSceneStyleSheet(), - getThemeFile("theme/style_extension.css") + getThemeFile("theme/style_extension.css"), + getThemeFile("theme/progress.css") }; } catch (IOException e) { throw new AssetLoadException("Could not retrieve stylesheets", e, "theme.stylesheets.couldNotGet"); } } + + public void bringMainStageToFront() { + Stage stage = StageHolder.getStage(); + if ((!stage.isFocused() || !stage.isShowing())) { + JavaFxUtil.runLater(stage::toFront); + } + } + /** * Registers a WebView against the theme service so it can be updated whenever the theme changes. */ diff --git a/src/main/java/com/faforever/client/tournament/game/IsReadyForGameController.java b/src/main/java/com/faforever/client/tournament/game/IsReadyForGameController.java new file mode 100644 index 0000000000..b920c522c8 --- /dev/null +++ b/src/main/java/com/faforever/client/tournament/game/IsReadyForGameController.java @@ -0,0 +1,104 @@ +package com.faforever.client.tournament.game; + +import com.faforever.client.fx.Controller; +import com.faforever.client.fx.JavaFxUtil; +import com.faforever.client.i18n.I18n; +import com.faforever.client.ui.progress.RingProgressIndicator; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.event.ActionEvent; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.util.StringConverter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.VisibleForTesting; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.OffsetDateTime; + + +@Slf4j +@Component +@RequiredArgsConstructor +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class IsReadyForGameController implements Controller { + private final I18n i18n; + public VBox root; + public Label description; + public RingProgressIndicator progressIndicator; + public Button isReadyButton; + private int timeLeft; + @VisibleForTesting + Timeline queuePopTimeUpdater; + @Setter + private Runnable readyCallback; + @Setter + private Runnable dismissCallBack; + private boolean clickedReady = false; + + + @Override + public void initialize() { + progressIndicator.setProgressLabelStringConverter(new StringConverter<>() { + @Override + public String toString(Integer object) { + return i18n.number(timeLeft); + } + + @Override + public Integer fromString(String string) { + throw new UnsupportedOperationException(); + } + }); + } + + + @Override + public Parent getRoot() { + return root; + } + + public void setTimeout(int responseTimeSeconds) { + OffsetDateTime start = OffsetDateTime.now(); + + queuePopTimeUpdater = new Timeline(1, new KeyFrame(javafx.util.Duration.seconds(0), (ActionEvent event) -> { + updateTimer(responseTimeSeconds, start); + }), new KeyFrame(javafx.util.Duration.seconds(1))); + queuePopTimeUpdater.setCycleCount(Timeline.INDEFINITE); + queuePopTimeUpdater.play(); + } + + private void updateTimer(int responseTimeSeconds, OffsetDateTime start) { + OffsetDateTime now = OffsetDateTime.now(); + Duration timeGone = Duration.between(start, now); + final var percent = timeGone.toSeconds() / (double) responseTimeSeconds; + this.timeLeft = (int) (responseTimeSeconds - timeGone.toSeconds()); + progressIndicator.setProgress((int) (percent * 100)); + if (timeLeft <= 0 && queuePopTimeUpdater != null) { + queuePopTimeUpdater.stop(); + JavaFxUtil.runLater(this::end); + } + } + + private void end() { + if (clickedReady) { + isReadyButton.setText(i18n.get("isReady.launching")); + } else { + dismissCallBack.run(); + } + } + + public void onReady() { + readyCallback.run(); + isReadyButton.setDisable(true); + clickedReady = true; + isReadyButton.setText(i18n.get("isReady.waiting")); + } +} diff --git a/src/main/java/com/faforever/client/tournament/game/TournamentGameService.java b/src/main/java/com/faforever/client/tournament/game/TournamentGameService.java new file mode 100644 index 0000000000..afb3ec932e --- /dev/null +++ b/src/main/java/com/faforever/client/tournament/game/TournamentGameService.java @@ -0,0 +1,122 @@ +package com.faforever.client.tournament.game; + +import com.faforever.client.fx.JavaFxUtil; +import com.faforever.client.game.GameService; +import com.faforever.client.i18n.I18n; +import com.faforever.client.notification.ImmediateNotification; +import com.faforever.client.notification.NotificationService; +import com.faforever.client.notification.Severity; +import com.faforever.client.remote.FafServerAccessor; +import com.faforever.client.theme.UiService; +import com.faforever.commons.lobby.GameLaunchResponse; +import com.faforever.commons.lobby.IsReadyRequest; +import com.faforever.commons.lobby.MatchmakerMatchCancelledResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CompletableFuture; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TournamentGameService implements InitializingBean { + private static final int SAFETY_CLOSE_NOTIFICATION_SECONDS = 200; + private final FafServerAccessor fafServerAccessor; + private final I18n i18n; + private final Timer timer = new Timer(true); + private final UiService uiService; + private final GameService gameService; + private final NotificationService notificationService; + private ImmediateNotification notification; + private CompletableFuture matchFuture; + + + @Override + public void afterPropertiesSet() throws Exception { + fafServerAccessor.addEventListener(IsReadyRequest.class, this::onReadRequest); + fafServerAccessor.addEventListener(MatchmakerMatchCancelledResponse.class, this::onMatchCanceled); + fafServerAccessor.addEventListener(GameLaunchResponse.class, this::onGameLaunch); + } + + private void onGameLaunch(GameLaunchResponse gameLaunchResponse) { + dismissNotification(); + } + + private void onMatchCanceled(MatchmakerMatchCancelledResponse matchmakerMatchCancelledResponse) { + dismissNotification(); + cancelMatch(); + notificationService.addImmediateInfoNotification( + "isReady.matchCanceled" + ); + } + + private void onReadRequest(IsReadyRequest isReadyRequest) { + log.info("Tournament game is ready, asking user."); + if (notification != null) { + log.warn("Tournament ready request ignored because tournament is already in progress."); + return; + } + uiService.bringMainStageToFront(); + final IsReadyForGameController controller = initializeIsReadyController(isReadyRequest); + notification = + new ImmediateNotification(i18n.get("isReady.title"), i18n.get("isReady.message", isReadyRequest.getGameName()), + Severity.INFO, null, List.of(), controller.getRoot(), false); + notificationService.addNotification(notification); + ensureNotificationCloses(); + } + + private void ensureNotificationCloses() { + final var currentNotification = notification; + timer.schedule(new TimerTask() { + @Override + public void run() { + if (notification != currentNotification) { + return; + } + JavaFxUtil.runLater(() -> dismissNotification()); + } + }, SAFETY_CLOSE_NOTIFICATION_SECONDS * 1000); + } + + private void dismissNotification() { + if (notification == null) { + return; + } + notification.dismiss(); + notification = null; + } + + @NotNull + private IsReadyForGameController initializeIsReadyController(IsReadyRequest isReadyRequest) { + IsReadyForGameController controller = uiService.loadFxml("theme/tournaments/is_ready_for_game.fxml"); + controller.setTimeout(isReadyRequest.getResponseTimeSeconds()); + controller.setReadyCallback(() -> respondToReadyRequest(isReadyRequest.getRequestId(), isReadyRequest)); + controller.setDismissCallBack(this::dismissNotification); + return controller; + } + + + private void respondToReadyRequest(String requestId, IsReadyRequest isReadyRequest) { + matchFuture = gameService.startListeningForTournamentGame(isReadyRequest.getFeaturedMod()); + try { + fafServerAccessor.sendIsReady(requestId); + } catch (Exception e) { + dismissNotification(); + cancelMatch(); + log.error("Could not send the server that player is ready for the tournament game", e); + notificationService.addImmediateErrorNotification(e, "isReady.readyUpFailed"); + } + } + + private void cancelMatch() { + if (matchFuture != null) { + matchFuture.cancel(false); + } + } +} diff --git a/src/main/java/com/faforever/client/ui/preferences/GameDirectoryRequiredHandler.java b/src/main/java/com/faforever/client/ui/preferences/GameDirectoryRequiredHandler.java index 089416195b..d623935f86 100644 --- a/src/main/java/com/faforever/client/ui/preferences/GameDirectoryRequiredHandler.java +++ b/src/main/java/com/faforever/client/ui/preferences/GameDirectoryRequiredHandler.java @@ -28,10 +28,10 @@ public void afterPropertiesSet() { @Subscribe public void onChooseGameDirectory(GameDirectoryChooseEvent event) { - platformService.askForPath(i18n.get("missingGamePath.chooserTitle")).ifPresent(gameDirectory -> { + platformService.askForPath(i18n.get("missingGamePath.chooserTitle")).ifPresentOrElse(gameDirectory -> { log.info("User selected game directory: {}", gameDirectory); eventBus.post(new GameDirectoryChosenEvent(gameDirectory, event.getFuture())); - }); + }, () -> event.getFuture().ifPresent(pathCompletableFuture -> pathCompletableFuture.completeExceptionally(new Exception(i18n.get("missingGamePath.noSelection"))))); } } diff --git a/src/main/java/com/faforever/client/ui/progress/ProgressCircleIndicator.java b/src/main/java/com/faforever/client/ui/progress/ProgressCircleIndicator.java new file mode 100644 index 0000000000..3b87534f6b --- /dev/null +++ b/src/main/java/com/faforever/client/ui/progress/ProgressCircleIndicator.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2014, Andrea Vacondio + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.faforever.client.ui.progress; + +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.ReadOnlyIntegerWrapper; +import javafx.css.CssMetaData; +import javafx.css.Styleable; +import javafx.css.StyleableDoubleProperty; +import javafx.css.StyleableProperty; +import javafx.css.converter.SizeConverter; +import javafx.scene.control.Control; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +/** + * Base class for the progress indicator controls represented by circular shapes + * * + * @author Andrea Vacondio + */ +abstract class ProgressCircleIndicator extends Control { + private static final int INDETERMINATE_PROGRESS = -1; + + private final ReadOnlyIntegerWrapper progress = new ReadOnlyIntegerWrapper(0); + private final ReadOnlyBooleanWrapper indeterminate = new ReadOnlyBooleanWrapper(false); + + public ProgressCircleIndicator() { + } + + public int getProgress() { + return progress.get(); + } + + /** + * Set the value for the progress, it cannot be more then 100 (meaning 100%). A negative value means indeterminate + * progress. + * + * @param progressValue + * @see ProgressCircleIndicator#makeIndeterminate() + */ + public void setProgress(int progressValue) { + progress.set(defaultToHundred(progressValue)); + indeterminate.set(progressValue < 0); + } + + public ReadOnlyIntegerProperty progressProperty() { + return progress.getReadOnlyProperty(); + } + + public boolean isIndeterminate() { + return indeterminate.get(); + } + + public void makeIndeterminate() { + setProgress(INDETERMINATE_PROGRESS); + } + + public ReadOnlyBooleanProperty indeterminateProperty() { + return indeterminate.getReadOnlyProperty(); + } + + private int defaultToHundred(int value) { + if (value > 100) { + return 100; + } + return value; + } + + public final void setInnerCircleRadius(int value) { + innerCircleRadiusProperty().set(value); + } + + public final DoubleProperty innerCircleRadiusProperty() { + return innerCircleRadius; + } + + public final double getInnerCircleRadius() { + return innerCircleRadiusProperty().get(); + } + + /** + * radius of the inner circle + */ + private final DoubleProperty innerCircleRadius = new StyleableDoubleProperty(60) { + @Override + public Object getBean() { + return ProgressCircleIndicator.this; + } + + @Override + public String getName() { + return "innerCircleRadius"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.INNER_CIRCLE_RADIUS; + } + }; + + private static class StyleableProperties { + private static final CssMetaData INNER_CIRCLE_RADIUS = new CssMetaData( + "-fx-inner-radius", SizeConverter.getInstance(), 60) { + + @Override + public boolean isSettable(ProgressCircleIndicator n) { + return n.innerCircleRadiusProperty() == null || !n.innerCircleRadiusProperty().isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(ProgressCircleIndicator n) { + return (StyleableProperty) n.innerCircleRadiusProperty(); + } + }; + + public static final List> STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.add(INNER_CIRCLE_RADIUS); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + /** + * @return The CssMetaData associated with this class, which may include the CssMetaData of its super classes. + */ + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + @Override + public List> getControlCssMetaData() { + return StyleableProperties.STYLEABLES; + } +} \ No newline at end of file diff --git a/src/main/java/com/faforever/client/ui/progress/RingProgressIndicator.java b/src/main/java/com/faforever/client/ui/progress/RingProgressIndicator.java new file mode 100644 index 0000000000..4a65364806 --- /dev/null +++ b/src/main/java/com/faforever/client/ui/progress/RingProgressIndicator.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2014, Andrea Vacondio + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.faforever.client.ui.progress; + +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.CssMetaData; +import javafx.css.Styleable; +import javafx.css.StyleableDoubleProperty; +import javafx.css.StyleableProperty; +import javafx.css.converter.SizeConverter; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; +import javafx.util.StringConverter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +/** + * Progress indicator showing a filling arc. + * + * @author Andrea Vacondio + */ +public class RingProgressIndicator extends ProgressCircleIndicator { + public ObjectProperty> progressLabelStringConverter = new SimpleObjectProperty<>(new StringConverter<>() { + @Override + public String toString(Integer object) { + return String.format("%d%%", object); + } + + @Override + public Integer fromString(String string) { + throw new UnsupportedOperationException(); + } + }); + + public RingProgressIndicator() { + this.getStyleClass().add("ringindicator"); + } + + @Override + protected Skin createDefaultSkin() { + return new RingProgressIndicatorSkin(this); + } + + public final void setRingWidth(int value) { + ringWidthProperty().set(value); + } + + public final DoubleProperty ringWidthProperty() { + return ringWidth; + } + + public final double getRingWidth() { + return ringWidthProperty().get(); + } + + /** + * thickness of the ring indicator. + */ + private final DoubleProperty ringWidth = new StyleableDoubleProperty(22) { + @Override + public Object getBean() { + return RingProgressIndicator.this; + } + + @Override + public String getName() { + return "ringWidth"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.RING_WIDTH; + } + }; + + private static class StyleableProperties { + private static final CssMetaData RING_WIDTH = new CssMetaData( + "-fx-ring-width", SizeConverter.getInstance(), 22) { + + @Override + public boolean isSettable(RingProgressIndicator n) { + return n.ringWidth == null || !n.ringWidth.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RingProgressIndicator n) { + return (StyleableProperty) n.ringWidth; + } + }; + + public static final List> STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.addAll(ProgressCircleIndicator.getClassCssMetaData()); + styleables.add(RING_WIDTH); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + @Override + public List> getControlCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + public StringConverter getProgressLabelStringConverter() { + return progressLabelStringConverter.get(); + } + + public ObjectProperty> progressLabelStringConverterProperty() { + return progressLabelStringConverter; + } + + public void setProgressLabelStringConverter(StringConverter progressLabelStringConverter) { + this.progressLabelStringConverter.set(progressLabelStringConverter); + } +} \ No newline at end of file diff --git a/src/main/java/com/faforever/client/ui/progress/RingProgressIndicatorSkin.java b/src/main/java/com/faforever/client/ui/progress/RingProgressIndicatorSkin.java new file mode 100644 index 0000000000..8a38f3ad73 --- /dev/null +++ b/src/main/java/com/faforever/client/ui/progress/RingProgressIndicatorSkin.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2014, Andrea Vacondio + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.faforever.client.ui.progress; + +import javafx.animation.Animation; +import javafx.animation.Interpolator; +import javafx.animation.RotateTransition; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.Skin; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Arc; +import javafx.scene.shape.Circle; +import javafx.util.Duration; + +/** + * Skin of the ring progress indicator where an arc grows and by the progress value up to 100% where the arc becomes a + * ring. + * + * @author Andrea Vacondio + */ +public class RingProgressIndicatorSkin implements Skin { + + private final RingProgressIndicator indicator; + private final Label percentLabel = new Label(); + private final Circle innerCircle = new Circle(); + private final Circle outerCircle = new Circle(); + private final StackPane container = new StackPane(); + private final Arc fillerArc = new Arc(); + private final RotateTransition transition = new RotateTransition(Duration.millis(2000), fillerArc); + + public RingProgressIndicatorSkin(final RingProgressIndicator indicator) { + this.indicator = indicator; + initContainer(indicator); + initFillerArc(); + container.widthProperty().addListener((o, oldVal, newVal) -> { + fillerArc.setCenterX(newVal.intValue() / 2); + }); + container.heightProperty().addListener((o, oldVal, newVal) -> { + fillerArc.setCenterY(newVal.intValue() / 2); + }); + innerCircle.getStyleClass().add("ringindicator-inner-circle"); + outerCircle.getStyleClass().add("ringindicator-outer-circle-secondary"); + updateRadii(); + + this.indicator.indeterminateProperty().addListener((o, oldVal, newVal) -> { + initIndeterminate(newVal); + }); + this.indicator.progressProperty().addListener((o, oldVal, newVal) -> { + if (newVal.intValue() >= 0) { + setProgressLabel(newVal.intValue()); + fillerArc.setLength(newVal.intValue() * -3.6); + } + }); + this.indicator.ringWidthProperty().addListener((o, oldVal, newVal) -> { + updateRadii(); + }); + innerCircle.strokeWidthProperty().addListener((e) -> { + updateRadii(); + }); + innerCircle.radiusProperty().addListener((e) -> { + updateRadii(); + }); + initTransition(); + initIndeterminate(indicator.isIndeterminate()); + initLabel(indicator.getProgress()); + indicator.visibleProperty().addListener((o, oldVal, newVal) -> { + if (newVal && this.indicator.isIndeterminate()) { + transition.play(); + } else { + transition.pause(); + } + }); + container.getChildren().addAll(fillerArc, outerCircle, innerCircle, percentLabel); + } + + private void setProgressLabel(int value) { + if (value >= 0) { + percentLabel.setText(indicator.getProgressLabelStringConverter().toString(value)); + } + } + + private void initTransition() { + transition.setAutoReverse(false); + transition.setCycleCount(Animation.INDEFINITE); + transition.setDelay(Duration.ZERO); + transition.setInterpolator(Interpolator.LINEAR); + transition.setByAngle(360); + } + + private void initFillerArc() { + fillerArc.setManaged(false); + fillerArc.getStyleClass().add("ringindicator-filler"); + fillerArc.setStartAngle(90); + fillerArc.setLength(indicator.getProgress() * -3.6); + } + + private void initContainer(final RingProgressIndicator indicator) { + container.getStylesheets().addAll(indicator.getStylesheets()); + container.getStyleClass().addAll("circleindicator-container"); + container.setMaxHeight(Region.USE_PREF_SIZE); + container.setMaxWidth(Region.USE_PREF_SIZE); + } + + private void updateRadii() { + double ringWidth = indicator.getRingWidth(); + double innerCircleHalfStrokeWidth = innerCircle.getStrokeWidth() / 2; + double innerCircleRadius = indicator.getInnerCircleRadius(); + outerCircle.setRadius(innerCircleRadius + innerCircleHalfStrokeWidth + ringWidth); + fillerArc.setRadiusY(innerCircleRadius + innerCircleHalfStrokeWidth - 1 + (ringWidth / 2)); + fillerArc.setRadiusX(innerCircleRadius + innerCircleHalfStrokeWidth - 1 + (ringWidth / 2)); + fillerArc.setStrokeWidth(ringWidth); + innerCircle.setRadius(innerCircleRadius); + } + + private void initLabel(int value) { + setProgressLabel(value); + percentLabel.getStyleClass().add("circleindicator-label"); + } + + private void initIndeterminate(boolean newVal) { + percentLabel.setVisible(!newVal); + if (newVal) { + fillerArc.setLength(360); + fillerArc.getStyleClass().add("indeterminate"); + if (indicator.isVisible()) { + transition.play(); + } + } else { + fillerArc.getStyleClass().remove("indeterminate"); + fillerArc.setRotate(0); + transition.stop(); + } + } + + @Override + public RingProgressIndicator getSkinnable() { + return indicator; + } + + @Override + public Node getNode() { + return container; + } + + @Override + public void dispose() { + transition.stop(); + } + +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index da45f0595d..e2bbcc2077 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -33,7 +33,6 @@ faf-client: base-url: http://localhost:4444 scopes: openid offline public_profile upload_map upload_mod lobby client-id: faf-java-client - timeout-milliseconds: 1500000 logging: level: diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 8f54c0af16..99cc4bda71 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -403,6 +403,7 @@ downloadingModTask.unzipping = Unzipping mod to {0} missingGamePath.notification = Forged Alliance could not be located missingGamePath.locate = Locateā€¦ missingGamePath.chooserTitle = Locate the Forged Alliance directory +missingGamePath.noSelection = Can not continue without a path to Supreme Commander Forged Alliance game path notifications.empty = You don't have any notifications stats.air = Air stats.land = Land @@ -1240,4 +1241,11 @@ blacklist.mapFolderName.promptText = Full or partial map folder name... hideSingleGames = Hide games with 1 player showGamesWithFriends = Show only games with friends showGeneratedMaps = Show only generated maps -filteredOutItemsCount = {0} from {1} items are filtered out \ No newline at end of file +filteredOutItemsCount = {0} from {1} items are filtered out +isReady.title=Tournament game ready for you +isReady.message=Click the button below to join the upcoming game ''{0}'' +isReady.iAmReady=I am ready +isReady.matchCanceled=The tournament match was canceled because other players where not ready +isReady.readyUpFailed=Could not ready up for an unknown reason +isReady.waiting=Waiting for others +isReady.launching=Launching game \ No newline at end of file diff --git a/src/main/resources/theme/progress.css b/src/main/resources/theme/progress.css new file mode 100644 index 0000000000..0c04090b2c --- /dev/null +++ b/src/main/resources/theme/progress.css @@ -0,0 +1,42 @@ +.circleindicator-container { + circleindicator-color: -fx-accent; + -fx-padding: 5.0; + -fx-background-color: -fx-background; +} + +.circleindicator-container > .circleindicator-label { + -fx-font-weight: bold; + -fx-font-size: 2.5em; + -fx-text-fill: circleindicator-color; + -fx-padding: 5.0; +} + +.ringindicator { + -fx-ring-width: 22.0; + -fx-inner-radius: 60.0; +} + +.ringindicator-inner-circle { + -fx-opacity: 0.55; + -fx-stroke: circleindicator-color; + -fx-stroke-width: 8.0px; + -fx-fill: -fx-background; +} + +.ringindicator-filler { + -fx-stroke: circleindicator-color; + -fx-fill: -fx-background; + -fx-stroke-line-cap: butt; +} + +.ringindicator-outer-circle-secondary { + -fx-opacity: 0.1; + -fx-stroke: circleindicator-color; + -fx-stroke-width: 2.0px; + -fx-fill: -fx-background; +} + +.indeterminate { + -fx-opacity: 0.55; + -fx-stroke: linear-gradient(from 0.0% 0.0% to 70.0% 70.0%, circleindicator-color 70.0%, white 75.0%, white); +} \ No newline at end of file diff --git a/src/main/resources/theme/tournaments/is_ready_for_game.fxml b/src/main/resources/theme/tournaments/is_ready_for_game.fxml new file mode 100644 index 0000000000..b359e31b7e --- /dev/null +++ b/src/main/resources/theme/tournaments/is_ready_for_game.fxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/faforever/client/game/GameServiceTest.java b/src/test/java/com/faforever/client/game/GameServiceTest.java index 54585ab853..0e38c960a3 100644 --- a/src/test/java/com/faforever/client/game/GameServiceTest.java +++ b/src/test/java/com/faforever/client/game/GameServiceTest.java @@ -205,7 +205,7 @@ private void mockMatchmakerChain() { when(modService.getFeaturedMod(FAF.getTechnicalName())) .thenReturn(completedFuture(FeaturedModBeanBuilder.create().defaultValues().get())); when(gameUpdater.update(any(), any(), any(), any())).thenReturn(completedFuture(null)); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(new CompletableFuture<>()); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(new CompletableFuture<>()); } @Test @@ -536,7 +536,7 @@ public void testStartSearchLadder1v1() throws Exception { mockStartGameProcess(gameParameters); when(leaderboardService.getActiveLeagueEntryForPlayer(junitPlayer, LADDER_1v1_RATING_TYPE)).thenReturn(completedFuture(Optional.empty())); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); when(gameUpdater.update(featuredMod, Set.of(),null, null)).thenReturn(completedFuture(null)); when(mapService.isInstalled(map)).thenReturn(false); when(mapService.download(map)).thenReturn(completedFuture(null)); @@ -545,7 +545,7 @@ public void testStartSearchLadder1v1() throws Exception { instance.startSearchMatchmaker().join(); - verify(fafServerAccessor).startSearchMatchmaker(); + verify(fafServerAccessor).getGameLaunchMessageFuture(); verify(mapService).download(map); verify(replayServer).start(eq(uid), any()); verify(forgedAllianceService).startGameOnline(gameParameters); @@ -573,7 +573,7 @@ public void testStartSearchLadder1v1WithLeagueEntry() throws Exception { mockStartGameProcess(gameParameters); LeagueEntryBean leagueEntry = LeagueEntryBeanBuilder.create().defaultValues().get(); when(leaderboardService.getActiveLeagueEntryForPlayer(junitPlayer, LADDER_1v1_RATING_TYPE)).thenReturn(completedFuture(Optional.of(leagueEntry))); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); when(gameUpdater.update(featuredMod, Set.of(),null, null)).thenReturn(completedFuture(null)); when(mapService.isInstalled(map)).thenReturn(false); when(mapService.download(map)).thenReturn(completedFuture(null)); @@ -582,7 +582,7 @@ public void testStartSearchLadder1v1WithLeagueEntry() throws Exception { instance.startSearchMatchmaker().join(); - verify(fafServerAccessor).startSearchMatchmaker(); + verify(fafServerAccessor).getGameLaunchMessageFuture(); verify(mapService).download(map); verify(replayServer).start(eq(uid), any()); verify(forgedAllianceService).startGameOnline(gameParameters); @@ -608,7 +608,7 @@ public void testStartSearchLadderTwiceReturnsSameFutureWhenSearching() throws Ex mockStartGameProcess(gameParameters); when(leaderboardService.getActiveLeagueEntryForPlayer(junitPlayer, LADDER_1v1_RATING_TYPE)).thenReturn(completedFuture(Optional.empty())); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); when(gameUpdater.update(featuredMod, Set.of(), null, null)).thenReturn(completedFuture(null)); when(mapService.isInstalled(map)).thenReturn(false); when(mapService.download(map)).thenReturn(completedFuture(null)); @@ -776,7 +776,7 @@ public void startSearchMatchmakerWithGameOptions() throws IOException { when(leaderboardService.getActiveLeagueEntryForPlayer(junitPlayer, "global")).thenReturn(completedFuture(Optional.empty())); when(gameUpdater.update(any(), any(), any(), any())).thenReturn(completedFuture(null)); when(mapService.download(gameLaunchMessage.getMapName())).thenReturn(completedFuture(null)); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); instance.startSearchMatchmaker().join(); verify(forgedAllianceService).startGameOnline(gameParameters); } @@ -799,7 +799,7 @@ public void startSearchMatchmakerThenCancelledWithGame() throws IOException { when(leaderboardService.getActiveLeagueEntryForPlayer(junitPlayer, "global")).thenReturn(completedFuture(Optional.empty())); when(gameUpdater.update(any(), any(), any(), any())).thenReturn(completedFuture(null)); when(mapService.download(gameLaunchMessage.getMapName())).thenReturn(completedFuture(null)); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(CompletableFuture.completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); CompletableFuture future = instance.startSearchMatchmaker(); when(process.isAlive()).thenReturn(true); future.cancel(false); @@ -821,7 +821,7 @@ public void startSearchMatchmakerThenCancelledNoGame() throws IOException { mockStartGameProcess(gameMapper.map(gameLaunchMessage)); when(gameUpdater.update(any(), any(), any(), any())).thenReturn(completedFuture(null)); when(mapService.download(gameLaunchMessage.getMapName())).thenReturn(completedFuture(null)); - when(fafServerAccessor.startSearchMatchmaker()).thenReturn(CompletableFuture.completedFuture(gameLaunchMessage)); + when(fafServerAccessor.getGameLaunchMessageFuture()).thenReturn(completedFuture(gameLaunchMessage)); instance.startSearchMatchmaker().cancel(false); verify(notificationService, never()).addServerNotification(any()); } diff --git a/src/test/java/com/faforever/client/remote/ServerAccessorTest.java b/src/test/java/com/faforever/client/remote/ServerAccessorTest.java index 756e4f9697..054c9e552c 100644 --- a/src/test/java/com/faforever/client/remote/ServerAccessorTest.java +++ b/src/test/java/com/faforever/client/remote/ServerAccessorTest.java @@ -655,7 +655,7 @@ public void testOnGameLaunch() throws InterruptedException, JsonProcessingExcept .initMode(LobbyMode.AUTO_LOBBY) .get(); - instance.startSearchMatchmaker(); + instance.getGameLaunchMessageFuture(); sendFromServer(gameLaunchMessage); assertTrue(messageReceivedByClientLatch.await(TIMEOUT, TIMEOUT_UNIT)); assertThat(receivedMessage, is(gameLaunchMessage)); diff --git a/src/test/java/com/faforever/client/tournament/game/IsReadyForGameControllerTest.java b/src/test/java/com/faforever/client/tournament/game/IsReadyForGameControllerTest.java new file mode 100644 index 0000000000..8a44a487a0 --- /dev/null +++ b/src/test/java/com/faforever/client/tournament/game/IsReadyForGameControllerTest.java @@ -0,0 +1,47 @@ +package com.faforever.client.tournament.game; + +import com.faforever.client.i18n.I18n; +import com.faforever.client.test.UITest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.testfx.util.WaitForAsyncUtils; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class IsReadyForGameControllerTest extends UITest { + @InjectMocks + private IsReadyForGameController instance; + @Mock + private I18n i18n; + + @BeforeEach + public void setUp() throws Exception { + loadFxml("theme/tournaments/is_ready_for_game.fxml", clazz -> instance); + } + + @Test + public void testTimeOut() { + AtomicBoolean dismissCalled = new AtomicBoolean(false); + instance.setDismissCallBack(() -> dismissCalled.set(true)); + instance.setTimeout(0); + final var time = instance.queuePopTimeUpdater.getKeyFrames().get(1).getTime(); + WaitForAsyncUtils.sleep((long) time.toMillis()+1000, TimeUnit.MILLISECONDS); + assertTrue(dismissCalled.get()); + } + + @Test + public void testClickReady() { + AtomicBoolean readyCallback = new AtomicBoolean(false); + instance.setReadyCallback(() -> readyCallback.set(true)); + instance.isReadyButton.getOnAction().handle(null); + instance.setTimeout(0); + final var time = instance.queuePopTimeUpdater.getKeyFrames().get(1).getTime(); + WaitForAsyncUtils.sleep((long) time.toMillis()+1000, TimeUnit.MILLISECONDS); + assertTrue(readyCallback.get()); + } +} \ No newline at end of file diff --git a/src/test/java/com/faforever/client/tournament/game/TournamentGameServiceTest.java b/src/test/java/com/faforever/client/tournament/game/TournamentGameServiceTest.java new file mode 100644 index 0000000000..340f9a6c0e --- /dev/null +++ b/src/test/java/com/faforever/client/tournament/game/TournamentGameServiceTest.java @@ -0,0 +1,68 @@ +package com.faforever.client.tournament.game; + +import com.faforever.client.game.GameService; +import com.faforever.client.i18n.I18n; +import com.faforever.client.notification.ImmediateNotification; +import com.faforever.client.notification.NotificationService; +import com.faforever.client.remote.FafServerAccessor; +import com.faforever.client.test.ServiceTest; +import com.faforever.client.theme.UiService; +import com.faforever.commons.lobby.IsReadyRequest; +import javafx.scene.layout.Region; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.function.Consumer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class TournamentGameServiceTest extends ServiceTest { + @InjectMocks + private TournamentGameService instance; + @Mock + private FafServerAccessor fafServerAccessor; + @Mock + private I18n i18n; + @Mock + private UiService uiService; + @Mock + private GameService gameService; + @Mock + private IsReadyForGameController isReadyForGameController; + @Mock + private NotificationService notificationService; + @Captor + private ArgumentCaptor> eventListenerCapture; + @Captor + private ArgumentCaptor isReadyCallbackCapture; + + @Test + public void testReceivingIsReadyMessageAndClickingReady() throws Exception { + instance.afterPropertiesSet(); + verify(fafServerAccessor).addEventListener(eq(IsReadyRequest.class), eventListenerCapture.capture()); + + doReturn(isReadyForGameController).when(uiService).loadFxml("theme/tournaments/is_ready_for_game.fxml"); + doReturn(new Region()).when(isReadyForGameController).getRoot(); + + eventListenerCapture.getValue().accept(new IsReadyRequest("name", "faf", 1, "abc")); + + verify(uiService).bringMainStageToFront(); + verify(notificationService).addNotification(any(ImmediateNotification.class)); + verify(isReadyForGameController).setTimeout(1); + verify(isReadyForGameController).setReadyCallback(isReadyCallbackCapture.capture()); + + isReadyCallbackCapture.getValue().run(); + + verify(fafServerAccessor).sendIsReady("abc"); + verify(gameService).startListeningForTournamentGame("faf"); + } +} \ No newline at end of file