Initial commit
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -0,0 +1,34 @@
|
||||
HELP.md
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
.gigaide
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -0,0 +1,19 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you 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.
|
||||
wrapperVersion=3.3.2
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.4</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>ru.fokinatorr</groupId>
|
||||
<artifactId>chess</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>Ch4ss</name>
|
||||
<description>Chess game in JavaFX</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>23</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-rsocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ru.fokinatorr</groupId>
|
||||
<artifactId>chess.api</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.rgielen</groupId>
|
||||
<artifactId>javafx-weaver-spring-boot-starter</artifactId>
|
||||
<version>1.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
<version>26.0.1</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
<exclude>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,84 @@
|
||||
package ru.fokinatorr.chess;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.media.AudioClip;
|
||||
import javafx.util.Pair;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.piece.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static ru.fokinatorr.chess.impl.PieceColor.BLACK;
|
||||
import static ru.fokinatorr.chess.impl.PieceColor.WHITE;
|
||||
|
||||
/// Several constants for this app.
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class Constants {
|
||||
public static final Image
|
||||
WHITE_PAWN = pieceImage(WHITE, Pawn.class),
|
||||
BLACK_PAWN = pieceImage(BLACK, Pawn.class),
|
||||
WHITE_BISHOP = pieceImage(WHITE, Bishop.class),
|
||||
BLACK_BISHOP = pieceImage(BLACK, Bishop.class),
|
||||
WHITE_KNIGHT = pieceImage(WHITE, Knight.class),
|
||||
BLACK_KNIGHT = pieceImage(BLACK, Knight.class),
|
||||
WHITE_KING = pieceImage(WHITE, King.class),
|
||||
BLACK_KING = pieceImage(BLACK, King.class),
|
||||
WHITE_ROOK = pieceImage(WHITE, Rook.class),
|
||||
BLACK_ROOK = pieceImage(BLACK, Rook.class),
|
||||
WHITE_QUEEN = pieceImage(WHITE, Queen.class),
|
||||
BLACK_QUEEN = pieceImage(BLACK, Queen.class);
|
||||
|
||||
public static final Image
|
||||
UNDO = image("undo", 20),
|
||||
REDO = image("redo", 20);
|
||||
|
||||
public static final AudioClip
|
||||
MOVE_SOUND = audioClip("move"),
|
||||
CAPTURE_SOUND = audioClip("capture"),
|
||||
CASTLING_SOUND = audioClip("castling"),
|
||||
CHECK_SOUND = audioClip("check"),
|
||||
PROMOTION_SOUND = audioClip("promotion"),
|
||||
CHECKMATE_SOUND = audioClip("promotion");
|
||||
|
||||
private static Map<Pair<PieceColor, Class<? extends Piece>>, Image> PIECE_IMAGE_BY_COLOR_AND_TYPE;
|
||||
|
||||
private Image pieceImage(PieceColor color, Class<? extends Piece> type) {
|
||||
Image result = image("piece/" + type.getSimpleName().toLowerCase(Locale.ENGLISH) + "/" + color.name().toLowerCase(Locale.ENGLISH), 30);
|
||||
if (PIECE_IMAGE_BY_COLOR_AND_TYPE == null) {
|
||||
PIECE_IMAGE_BY_COLOR_AND_TYPE = HashMap.newHashMap(12);
|
||||
}
|
||||
PIECE_IMAGE_BY_COLOR_AND_TYPE.put(new Pair<>(color, type), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousMethodCalls")
|
||||
public Image getPieceImage(PieceColor color, Class<? extends Piece> type) {
|
||||
return PIECE_IMAGE_BY_COLOR_AND_TYPE.get(new Pair<>(color, type));
|
||||
}
|
||||
|
||||
private Image image(String basePath) {
|
||||
return image(basePath, 0);
|
||||
}
|
||||
|
||||
private Image image(String basePath, double requestedSize) {
|
||||
log.info("Loading image at base path /images/{}.png", basePath);
|
||||
String fullPath = Objects.requireNonNull(Constants.class.getResource("/images/" + basePath + ".png")).toExternalForm()
|
||||
.replace("file:", "");
|
||||
log.info("Full path to image: {}", fullPath);
|
||||
return new Image(Constants.class.getResourceAsStream("/images/" + basePath + ".png"), requestedSize, requestedSize, true, true);
|
||||
}
|
||||
|
||||
private AudioClip audioClip(String basePath) {
|
||||
log.info("Loading audio clip at base path /audio/{}.png", basePath);
|
||||
String fullPath = Objects.requireNonNull(Constants.class.getResource("/audio/" + basePath + ".wav")).toExternalForm()
|
||||
.replace("file:", "");
|
||||
log.info("Full path to audio clip: {}", fullPath);
|
||||
return new AudioClip("file://" + fullPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.fokinatorr.chess;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@UtilityClass
|
||||
public class Launcher { // required by JavaFX
|
||||
|
||||
public static void main(String[] args) {
|
||||
Main.main(args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package ru.fokinatorr.chess;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.ExtensionMethod;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.rgielen.fxweaver.core.FxWeaver;
|
||||
import net.rgielen.fxweaver.spring.boot.autoconfigure.FxWeaverAutoConfiguration;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import reactor.core.Exceptions;
|
||||
import ru.fokinatorr.chess.gui.MainController;
|
||||
import ru.fokinatorr.chess.util.ThrowingRunnable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
@SpringBootApplication
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Slf4j
|
||||
public class Main extends Application {
|
||||
|
||||
/// Supported font formats to load.
|
||||
private static final Set<String> SUPPORTED_FONT_FORMATS = Set.of("ttf", "otf");
|
||||
|
||||
FxWeaver fxWeaver;
|
||||
ExecutorService executorService;
|
||||
ConfigurableApplicationContext applicationContext;
|
||||
@Getter
|
||||
private static Stage primaryStage;
|
||||
|
||||
public static void main(String[] args) {
|
||||
log.info("Command-line arguments: {}. These will be used for booting both JavaFX and Spring", Arrays.toString(args));
|
||||
log.info("Booting JavaFX...");
|
||||
Application.launch(args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init() throws Exception {
|
||||
log.info("Booting Spring context...");
|
||||
this.applicationContext = SpringApplication.run(
|
||||
new Class[]{Main.class, FxWeaverAutoConfiguration.class},
|
||||
getParameters().getRaw().toArray(String[]::new)
|
||||
);
|
||||
log.info("Spring context ({}) initialized.", applicationContext);
|
||||
this.fxWeaver = applicationContext.getBean(FxWeaver.class);
|
||||
log.info("FxWeaver ({}) loaded.", fxWeaver);
|
||||
|
||||
this.executorService = applicationContext.getBean(ExecutorService.class);
|
||||
log.info("ExecutorService ({}) loaded. All further operations will be executed in it.", executorService);
|
||||
List<ThrowingRunnable<Exception>> tasks = new ArrayList<>();
|
||||
tasks.add(() -> loadCustomFont("Times New Roman"));
|
||||
for (Future<?> future : executorService.invokeAll(tasks.stream().map(ThrowingRunnable::toCallable).toList())) {
|
||||
future.get();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Stage primaryStage) throws Exception {
|
||||
log.info("Preparing to show the window...");
|
||||
primaryStage.setScene(new Scene(fxWeaver.loadView(MainController.class)));
|
||||
primaryStage.setTitle("Матшахы");
|
||||
primaryStage.show();
|
||||
Main.primaryStage = primaryStage;
|
||||
log.info("All done.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
log.info("Stopping!");
|
||||
applicationContext.close();
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
|
||||
/// @throws NullPointerException if the directory is not found
|
||||
private static void loadCustomFont(String name) throws IOException, URISyntaxException {
|
||||
URL fileURL = Objects.requireNonNull(Main.class.getResource("/customfonts/" + name), "Directory /customfonts/" + name + " not found");
|
||||
Path basePath = Path.of(fileURL.toURI());
|
||||
Files.walkFileTree(basePath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
super.visitFile(file, attrs); // the super method does null checks
|
||||
|
||||
String[] fileNameSplitByDots = file.getFileName().toString().split("\\.");
|
||||
if (SUPPORTED_FONT_FORMATS.contains(fileNameSplitByDots[fileNameSplitByDots.length - 1])) { // (there are copyright files as well)
|
||||
try (InputStream stream = Files.newInputStream(file)) {
|
||||
log.info("Loading font {}...", file.getFileName());
|
||||
Font.loadFont(stream, 0);
|
||||
log.info("Done");
|
||||
}
|
||||
}
|
||||
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.fokinatorr.chess.config;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.engine.impl.stockfish.StockfishChessEngine;
|
||||
import ru.fokinatorr.chess.engine.impl.stockfish.StockfishManager;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class ChessEngineConfig {
|
||||
|
||||
ObjectProvider<StockfishManager> stockfishManagerProvider;
|
||||
|
||||
@Bean
|
||||
@Scope(SCOPE_PROTOTYPE)
|
||||
public ChessEngine stockfish(int skillLevel) throws IOException {
|
||||
return new StockfishChessEngine(stockfishManagerProvider.getObject(), skillLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.fokinatorr.chess.config;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
|
||||
/// Creates some miscellaneous beans that can't be classified.
|
||||
@Configuration
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class MiscellaneousBeansConfig {
|
||||
|
||||
@Bean
|
||||
public ExecutorService executorService() {
|
||||
return new ForkJoinPool();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Random random() {
|
||||
return new Random();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DTOService dtoService() {
|
||||
return new DTOService();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ru.fokinatorr.chess.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.codec.cbor.Jackson2CborDecoder;
|
||||
import org.springframework.http.codec.cbor.Jackson2CborEncoder;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.util.pattern.PathPatternRouteMatcher;
|
||||
import ru.fokinatorr.chess.misc.ChessServerProps;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient webClient(ChessServerProps chessServerProps) {
|
||||
return WebClient.create(chessServerProps.getBasePath("http") + "/");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RSocketRequester.Builder rsocketRequesterBuilder(RSocketStrategies rsocketStrategies) {
|
||||
return RSocketRequester.builder()
|
||||
.rsocketStrategies(rsocketStrategies);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RSocketStrategies rsocketStrategies() {
|
||||
return RSocketStrategies.builder()
|
||||
.encoder(new Jackson2CborEncoder())
|
||||
.decoder(new Jackson2CborDecoder())
|
||||
.routeMatcher(new PathPatternRouteMatcher())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.fokinatorr.chess.engine;
|
||||
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/// Defines an interface of a chess engine.
|
||||
public interface ChessEngine extends AutoCloseable {
|
||||
|
||||
// Setters
|
||||
/// Sets the position to analyze.
|
||||
void setChessBoardAndMoveStack(ChessBoardAndMoveStack chessBoardAndMoveStack);
|
||||
|
||||
/// Returns the [PieceColor] that this engine plays as.
|
||||
PieceColor getMyColor();
|
||||
|
||||
/// Sets the [PieceColor] that this engine plays as.
|
||||
void setMyColor(PieceColor color);
|
||||
|
||||
/// Sets an option for this chess engine (optional operation).
|
||||
/// @throws UnsupportedOperationException if options are not supported
|
||||
default CompletableFuture<Void> setOption(String name, Object value) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
/// Notifies the engine that the chess board and move stack have been updated.
|
||||
/// The engine should synchronize its internal state with the new board state.
|
||||
/// <p>This method is preferred to {@link #setChessBoardAndMoveStack(ChessBoardAndMoveStack)} to indicate update,
|
||||
/// because that method usually assumes that it's a new game.
|
||||
default CompletableFuture<Void> positionUpdated() {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
// Find methods
|
||||
|
||||
/// Find the best move on the current position for the given color, with indefinite timeout.
|
||||
/// By default, this method blocks the current thread.
|
||||
default Move findBestMove() {
|
||||
return findBestMove(Duration.ZERO);
|
||||
}
|
||||
|
||||
/// Find the best move on the current position for the given color, with the given timeout.
|
||||
/// By default, this method blocks the current thread.
|
||||
/// If the timeout is `null` or less than or equal to {@link Duration#ZERO}, it is considered indefinite.
|
||||
default Move findBestMove(Duration timeout) {
|
||||
return findBestMoveAsync(timeout).join();
|
||||
}
|
||||
|
||||
/// Find the best move on the current position for the given color, with the given timeout, asynchronously.
|
||||
/// If the timeout is `null` or less than or equal to {@link Duration#ZERO}, it is considered indefinite.
|
||||
CompletableFuture<Move> findBestMoveAsync(Duration timeout);
|
||||
|
||||
|
||||
/// Analyze the current position with indefinite timeout.
|
||||
/// By default, this method blocks the current thread.
|
||||
/// @see PositionAnalysis
|
||||
default PositionAnalysis analyzePosition() {
|
||||
return analyzePosition(Duration.ZERO);
|
||||
}
|
||||
|
||||
/// Analyze on the current position with the given timeout.
|
||||
/// By default, this method blocks the current thread.
|
||||
/// If the timeout is `null` or less than or equal to {@link Duration#ZERO}, it is considered indefinite.
|
||||
/// @see PositionAnalysis
|
||||
default PositionAnalysis analyzePosition(Duration timeout) {
|
||||
return analyzePositionAsync(timeout).join();
|
||||
}
|
||||
|
||||
/// Analyze the current position with the given timeout, asynchronously.
|
||||
/// If the timeout is `null` or less than or equal to {@link Duration#ZERO}, it is considered indefinite.
|
||||
/// @see PositionAnalysis
|
||||
CompletableFuture<PositionAnalysis> analyzePositionAsync(Duration timeout);
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ru.fokinatorr.chess.engine;
|
||||
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/// The evaluation.
|
||||
/// Note: Advantage scores are relative to the White player.
|
||||
/// @see CheckmateIn
|
||||
/// @see Advantage
|
||||
/// @see Undefined
|
||||
public sealed interface Evaluation permits Evaluation.CheckmateIn, Evaluation.Advantage, Evaluation.Undefined {
|
||||
|
||||
/// Returns the [PieceColor] that has the most advantage. `null` means the advantage is equal.
|
||||
default PieceColor getAdvantagedColor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns a human-friendly string representation for this evaluation score.
|
||||
/// @return a friendly string representation of this evaluation score
|
||||
@Override
|
||||
@NonNull
|
||||
String toString();
|
||||
|
||||
/// Forced checkmate sequence.
|
||||
/// @param moves the count of doneMoves after which the checkmate will occur. Positive if white wins, negative if black wins. Never equals `0`.
|
||||
record CheckmateIn(int moves) implements Evaluation {
|
||||
/// @see CheckmateIn
|
||||
public CheckmateIn {
|
||||
// sanity check
|
||||
if (moves == 0) {
|
||||
throw new IllegalArgumentException("CheckmateIn moves cannot be 0.");
|
||||
}
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public PieceColor getAdvantagedColor() {
|
||||
return moves < 0 ? PieceColor.BLACK : PieceColor.WHITE;
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
/// @return {@inheritDoc}
|
||||
@Override
|
||||
@NonNull
|
||||
public String toString() {
|
||||
return moves > 0 ? "#+" + moves : "#" + moves;
|
||||
}
|
||||
}
|
||||
|
||||
/// Some piece color might or might not have advantage over the other.
|
||||
/// @param score how much advantage some piece color has. Negative score means black has advantage,
|
||||
/// positive - white, `0` - none.
|
||||
record Advantage(double score) implements Evaluation {
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public PieceColor getAdvantagedColor() {
|
||||
if (score < 0) {
|
||||
return PieceColor.BLACK;
|
||||
} else if (score == 0 || Double.isNaN(score)) {
|
||||
return null;
|
||||
} else {
|
||||
return PieceColor.WHITE;
|
||||
}
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
/// @return {@inheritDoc}
|
||||
@Override
|
||||
@NonNull
|
||||
public String toString() {
|
||||
if (score == 0 || Double.isNaN(score)) {
|
||||
return "0.0";
|
||||
} else if (score > 0) {
|
||||
return "+" + score;
|
||||
} else {
|
||||
return Double.toString(score);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The evaluation score is unclear or some error occurred when evaluating.
|
||||
record Undefined(Optional<Throwable> error) implements Evaluation {
|
||||
|
||||
/// {@inheritDoc}
|
||||
/// @return {@inheritDoc}
|
||||
@Override
|
||||
@NonNull
|
||||
public String toString() {
|
||||
return error.map(s -> "[error] " + s)
|
||||
.orElse("[unknown error]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.fokinatorr.chess.engine;
|
||||
|
||||
import lombok.Builder;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.SequencedCollection;
|
||||
|
||||
/// Holds data of position analysis. Yielded by [ChessEngine] from `analyzePosition...(...)` methods.
|
||||
/// @param chessBoardAndMoveStack the analyzed chess board
|
||||
/// @param evaluation the evaluation score
|
||||
/// @param principalVariation the best sequence of doneMoves
|
||||
/// @param depth the depth of analysis
|
||||
/// @param nodesSearched count of nodes searched
|
||||
/// @param timeElapsed how much time elapsed to analyze
|
||||
/// @see Evaluation
|
||||
@Builder
|
||||
public record PositionAnalysis(
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack,
|
||||
Evaluation evaluation,
|
||||
SequencedCollection<Move> principalVariation,
|
||||
int depth,
|
||||
long nodesSearched,
|
||||
Duration timeElapsed
|
||||
) {}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.fokinatorr.chess.engine.impl.stockfish;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/// Base for `StockfishManager`s that work with a locally installed Stockfish.
|
||||
@FieldDefaults(level = AccessLevel.PROTECTED)
|
||||
@RequiredArgsConstructor
|
||||
public abstract class LocalStockfishManager implements StockfishManager {
|
||||
|
||||
final String pathToExecutable;
|
||||
final ExecutorService executorService;
|
||||
PrintWriter input;
|
||||
Process process;
|
||||
boolean closed = true;
|
||||
final BlockingQueue<String> lines = new LinkedBlockingQueue<>();
|
||||
|
||||
/// Runs the Stockfish binary at the path given in the constructor and sets up the streams.
|
||||
@Override
|
||||
public synchronized void open() {
|
||||
if (!closed) {
|
||||
throw new StockfishException("Already opened");
|
||||
}
|
||||
try {
|
||||
process = Runtime.getRuntime().exec(new String[]{pathToExecutable});
|
||||
} catch (IOException e) {
|
||||
throw new StockfishException("Unable to start the process", e);
|
||||
}
|
||||
input = new PrintWriter(process.getOutputStream(), true);
|
||||
closed = false;
|
||||
executorService.submit(() -> {
|
||||
try (var output = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
while (!closed) {
|
||||
var line = output.readLine();
|
||||
System.out.println("[stockfish " + process.toHandle().pid() + "] " + line);
|
||||
lines.put(line);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new StockfishException("Could not read the output", ex);
|
||||
} catch (InterruptedException _) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public CompletableFuture<Void> sendCommand(String command) {
|
||||
ensureOpen();
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
synchronized (this) {
|
||||
System.out.println("Sending command " + command + " to [stockfish " + process.toHandle().pid() + "]");
|
||||
input.println(command);
|
||||
if (input.checkError()) {
|
||||
throw new StockfishException("Could not send the command");
|
||||
}
|
||||
}
|
||||
}, executorService);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public CompletableFuture<List<String>> sendCommand(String command, String expected) {
|
||||
ensureOpen();
|
||||
var threadName = Thread.currentThread().getName();
|
||||
return sendCommand(command).thenApply(_ -> {
|
||||
List<String> result = new ArrayList<>();
|
||||
do {
|
||||
try {
|
||||
var line = lines.poll(3000L, TimeUnit.MILLISECONDS);
|
||||
System.out.println("Thread " + threadName + " waiting " + expected + " took line " + line);
|
||||
result.add(line);
|
||||
} catch (InterruptedException e) {
|
||||
throw new StockfishException("Thread interrupted while reading", e);
|
||||
}
|
||||
} while (result.getLast() == null || !result.getLast().startsWith(expected));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// Sends "quit" command, then destroys the Stockfish process and closes the streams.
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
sendCommand("quit").thenRun(() -> {
|
||||
input.close();
|
||||
process.destroy();
|
||||
input = null;
|
||||
process = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the process is running and wasn't killed by some other program.
|
||||
protected final void ensureOpen() {
|
||||
if (closed) {
|
||||
throw new StockfishException("Already closed");
|
||||
}
|
||||
if (!isConnected()) {
|
||||
disconnectBehaviour();
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do when the process was suddenly killed. Might recreate the process or throw an exception.
|
||||
protected abstract void disconnectBehaviour();
|
||||
|
||||
/// Try to find the path to the Stockfish binary on this computer. Returns `null` if nothing was found.
|
||||
public static String findStockfishLocation() {
|
||||
String searchCommand = System.getProperty("os.name").startsWith("Windows") ? "where" : "which";
|
||||
try {
|
||||
Process process = Runtime.getRuntime().exec(new String[]{searchCommand, "stockfish"});
|
||||
if (process.waitFor() == 0) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
return reader.readLine();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new StockfishException("Unexpected I/O Error when searching for stockfish", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new StockfishException(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConnected() {
|
||||
return process != null && process.isAlive();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package ru.fokinatorr.chess.engine.impl.stockfish;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.engine.Evaluation;
|
||||
import ru.fokinatorr.chess.engine.PositionAnalysis;
|
||||
import ru.fokinatorr.chess.engine.PositionAnalysis.PositionAnalysisBuilder;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.ChessUtil;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.SequencedCollection;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/// A `ChessEngine` adapter that uses Stockfish.
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class StockfishChessEngine implements ChessEngine {
|
||||
|
||||
final StockfishManager stockfishManager;
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack;
|
||||
@Getter
|
||||
@Setter
|
||||
PieceColor myColor;
|
||||
|
||||
/// Constructs a new `StockfishChessEngine` with the given manager and skill level.
|
||||
public StockfishChessEngine(StockfishManager stockfishManager, int skillLevel) {
|
||||
this.stockfishManager = stockfishManager;
|
||||
stockfishManager.open();
|
||||
if (skillLevel < 0 || skillLevel > 20) { // as in Stockfish specification
|
||||
throw new StockfishException("Unexpected skill level: " + skillLevel);
|
||||
}
|
||||
setOption("Skill Level", skillLevel).join();
|
||||
}
|
||||
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void setChessBoardAndMoveStack(ChessBoardAndMoveStack chessBoardAndMoveStack) {
|
||||
this.chessBoardAndMoveStack = chessBoardAndMoveStack;
|
||||
// assuming new game
|
||||
stockfishManager.waitForReady()
|
||||
.thenCompose(_ -> stockfishManager.sendCommand("ucinewgame"))
|
||||
.thenCompose(_ -> syncPosition());
|
||||
}
|
||||
|
||||
/// Sets an option for this chess engine.
|
||||
@Override
|
||||
public CompletableFuture<Void> setOption(String name, Object value) {
|
||||
return stockfishManager.waitForReady()
|
||||
.thenCompose(_ -> stockfishManager.sendCommand("setoption name " + name + " value " + value));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> syncPosition() {
|
||||
return stockfishManager.waitForReady()
|
||||
.thenCompose(_ -> stockfishManager.sendCommand("position fen " + chessBoardAndMoveStack.toFEN()));
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public CompletableFuture<Void> positionUpdated() {
|
||||
return syncPosition();
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public CompletableFuture<Move> findBestMoveAsync(Duration timeout) {
|
||||
String command = "go";
|
||||
if (timeout != null && timeout.isPositive()) {
|
||||
command += " movetime " + timeout.toMillis();
|
||||
}
|
||||
String finalCommand = command;
|
||||
return stockfishManager.waitForReady()
|
||||
.thenCompose(_ -> stockfishManager.sendCommand(finalCommand, "bestmove"))
|
||||
.thenApply(List::getLast)
|
||||
.thenApply(s -> s.split("\\s")[1]) // format: "bestmove [suggested move] ponder [expected opponent's next move]" or "bestmove (none)" if game ended
|
||||
.thenApply(s -> ChessUtil.parseUCIMove(s, chessBoardAndMoveStack));
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public CompletableFuture<PositionAnalysis> analyzePositionAsync(Duration timeout) {
|
||||
String command = "go";
|
||||
if (timeout != null && timeout.isPositive()) {
|
||||
command += " movetime " + timeout.toMillis();
|
||||
}
|
||||
String finalCommand = command;
|
||||
return stockfishManager.waitForReady()
|
||||
.thenCompose(_ -> stockfishManager.sendCommand(finalCommand, "bestmove"))
|
||||
.thenApply(list -> list.get(list.size() - 2)) // pre-last line is the final analysis
|
||||
.thenApply(s -> s.substring(5)) // remove the "info " prefix
|
||||
.thenApply(s -> {
|
||||
String[] split = s.split("\\s");
|
||||
// format: depth [depth] seldepth [selective depth] multipv [variation number] score [cp | mate] [value] nodes [nodes] nps [nodes/sec] hashfull [hashtable fullness] tbhits [tablebase hits] time [time in millis] pv [move1] [move2] ... [movei]
|
||||
PositionAnalysisBuilder builder = PositionAnalysis.builder().chessBoardAndMoveStack(chessBoardAndMoveStack);
|
||||
for (int i = 0; i < split.length; i++) {
|
||||
switch (split[i]) {
|
||||
case "depth" -> builder.depth(Integer.parseInt(split[++i]));
|
||||
case "score" -> {
|
||||
String type = split[++i];
|
||||
if (type.equals("cp")) {
|
||||
builder.evaluation(new Evaluation.Advantage((double) Integer.parseInt(split[++i]) / 100)); // centipawns to pawns
|
||||
} else if (type.equals("mate")) {
|
||||
builder.evaluation(new Evaluation.CheckmateIn(Integer.parseInt(split[++i])));
|
||||
}
|
||||
}
|
||||
case "nodes" -> builder.nodesSearched(Long.parseLong(split[++i]));
|
||||
case "time" -> builder.timeElapsed(Duration.ofMillis(Long.parseLong(split[++i])));
|
||||
case "pv" -> {
|
||||
ChessBoardAndMoveStack copy = chessBoardAndMoveStack.clone();
|
||||
SequencedCollection<Move> moves = new LinkedList<>();
|
||||
try {
|
||||
for (i++; i < split.length; i++) {
|
||||
Move move = ChessUtil.parseUCIMove(split[i], copy);
|
||||
moves.add(move);
|
||||
copy.perform(move);
|
||||
}
|
||||
} catch (Exception _) {}
|
||||
for (int j = 0; j < moves.size(); j++) {
|
||||
copy.undo();
|
||||
}
|
||||
builder.principalVariation(moves);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PositionAnalysis result = builder.build();
|
||||
if (result.evaluation() == null) {
|
||||
builder.evaluation(new Evaluation.Undefined(Optional.empty()));
|
||||
result = builder.build();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void close() {
|
||||
stockfishManager.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.fokinatorr.chess.engine.impl.stockfish;
|
||||
|
||||
/// Indicates some error happened with Stockfish.
|
||||
public class StockfishException extends RuntimeException {
|
||||
public StockfishException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public StockfishException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public StockfishException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.fokinatorr.chess.engine.impl.stockfish;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/// A manager of a Stockfish chess engine instance, running either locally or remotely.
|
||||
public interface StockfishManager extends AutoCloseable {
|
||||
|
||||
/// Opens a connection to Stockfish. This method might establish a local process
|
||||
/// or connect to a remote Stockfish server.
|
||||
///
|
||||
/// @throws StockfishException if an I/O error occurs
|
||||
void open();
|
||||
|
||||
/// Sends the given UCI command to the Stockfish engine using "fire and forget" behaviour.
|
||||
/// @param command The UCI command to send.
|
||||
/// @throws StockfishException if there's an error sending or receiving data.
|
||||
CompletableFuture<Void> sendCommand(String command);
|
||||
|
||||
/// Sends the given UCI command to the Stockfish engine and collect everything sent by it until a string with the given beginning is met.
|
||||
///
|
||||
/// @param command The UCI command to send.
|
||||
/// @param expected The beginning of a line after which the command will stop execution.
|
||||
/// @throws StockfishException if there's an error sending or receiving data.
|
||||
CompletableFuture<List<String>> sendCommand(String command, String expected);
|
||||
|
||||
/// Returns a `CompletableFuture` that completes when the engine is ready.
|
||||
/// @throws StockfishException if there's an error sending or receiving data.
|
||||
default CompletableFuture<Void> waitForReady() {
|
||||
// @formatter:off
|
||||
return sendCommand("isready", "readyok").thenRun(() -> {});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
/// Destroys the connection to Stockfish. This method might destroy a local process
|
||||
/// or disconnect from a remote Stockfish server.
|
||||
/// @throws StockfishException if something goes wrong
|
||||
void close();
|
||||
|
||||
/// Returns `true` if the engine is running and ready to accept commands.
|
||||
/// @return `true` if the engine is running, `false` otherwise.
|
||||
boolean isConnected();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ru.fokinatorr.chess.engine.service;
|
||||
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/// Manages at most two chess engines for each color.
|
||||
/// It is guaranteed that an engine for at least one piece color is present.
|
||||
public interface ChessEngineService extends AutoCloseable {
|
||||
|
||||
/// Returns whether there is a chess engine for white color.
|
||||
boolean hasWhiteEngine();
|
||||
|
||||
/// Returns whether there is a chess engine for black color.
|
||||
boolean hasBlackEngine();
|
||||
|
||||
/// Returns whether there is an engine for the specified color.
|
||||
/// @implNote The default implementation is exactly as follows: <blockquote><pre>
|
||||
/// return color != null && (color == PieceColor.WHITE ? hasWhiteEngine() : hasBlackEngine());
|
||||
/// </pre></blockquote>
|
||||
default boolean hasEngineOfColor(PieceColor color) {
|
||||
return color != null && (color == PieceColor.WHITE ? hasWhiteEngine() : hasBlackEngine());
|
||||
}
|
||||
|
||||
/// Call {@link ChessEngine#setChessBoardAndMoveStack(ChessBoardAndMoveStack)} with the given position on both engines.
|
||||
/// Implementations should **NOT** call {@link ChessEngine#positionUpdated()} in this method.
|
||||
void setChessBoardAndMoveStack(ChessBoardAndMoveStack chessBoardAndMoveStack);
|
||||
|
||||
/// Sets the option for the chess engine of the given color. If `engineColor` is `null` then the option is set for both engines.
|
||||
/// @throws UnsupportedOperationException if options are not supported by the target engine
|
||||
/// @throws NoSuchElementException if there is no engine of the given color
|
||||
CompletableFuture<Void> setOption(PieceColor engineColor, String name, Object value);
|
||||
|
||||
/// Notify both engines that position was updated, via {@link ChessEngine#positionUpdated()}.
|
||||
CompletableFuture<Void> positionUpdated();
|
||||
|
||||
/// Request an engine with the given color to find the best move with the given timeout.
|
||||
/// If the timeout is `null` or less than or equal to {@link Duration#ZERO}, it is considered indefinite.
|
||||
/// @throws NullPointerException if `engineColor` is `null`
|
||||
/// @throws NoSuchElementException if there is no engine of the given color
|
||||
CompletableFuture<Move> requestMove(@NonNull PieceColor engineColor, Duration timeout);
|
||||
|
||||
/// Closes both engines, with effects as specified in {@link AutoCloseable#close()}.
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.fokinatorr.chess.engine.service;
|
||||
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
|
||||
/// Creates a [ChessEngineService].
|
||||
public interface ChessEngineServiceFactory {
|
||||
|
||||
/// Creates a no-op [ChessEngineService]. May be useful if chess engines are not required.
|
||||
ChessEngineService none();
|
||||
|
||||
/// Creates a [ChessEngineService] with a chess engine for only white color.
|
||||
/// @throws NullPointerException if `engine` is `null`
|
||||
ChessEngineService white(@NonNull ChessEngine engine);
|
||||
|
||||
/// Creates a [ChessEngineService] with a chess engine for only black color.
|
||||
/// @throws NullPointerException if `engine` is `null`
|
||||
ChessEngineService black(@NonNull ChessEngine engine);
|
||||
|
||||
/// Creates a [ChessEngineService] with a chess engine for only the given color.
|
||||
/// @throws NullPointerException if any argument is `null`
|
||||
default ChessEngineService color(@NonNull PieceColor color, @NonNull ChessEngine engine) {
|
||||
return switch (color) {
|
||||
case BLACK -> black(engine);
|
||||
case WHITE -> white(engine);
|
||||
case null -> throw new NullPointerException("color");
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a [ChessEngineService] with chess engines for both colors.
|
||||
/// @throws NullPointerException if any argument is `null`
|
||||
ChessEngineService both(@NonNull ChessEngine whiteEngine, @NonNull ChessEngine blackEngine);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ru.fokinatorr.chess.engine.service;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.BinaryOperator;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/// Default impl of [ChessEngineService].
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
class DefaultChessEngineService implements ChessEngineService {
|
||||
|
||||
ChessEngine whiteEngine;
|
||||
ChessEngine blackEngine;
|
||||
|
||||
@Override
|
||||
public boolean hasWhiteEngine() {
|
||||
return whiteEngine != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBlackEngine() {
|
||||
return blackEngine != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChessBoardAndMoveStack(ChessBoardAndMoveStack chessBoardAndMoveStack) {
|
||||
applyToBothEngines(fromConsumer(engine -> engine.setChessBoardAndMoveStack(chessBoardAndMoveStack)), COMPOSE_VOID, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> setOption(PieceColor engineColor, String name, Object value) {
|
||||
if (engineColor != null) {
|
||||
return applyToEngineIfPresent(engineColor, engine -> engine.setOption(name, value), true, null);
|
||||
} else {
|
||||
return applyToBothEngines(engine -> engine.setOption(name, value), COMPOSE_VOID_FUTURE, noOpFuture());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> positionUpdated() {
|
||||
return applyToBothEngines(ChessEngine::positionUpdated, COMPOSE_VOID_FUTURE, noOpFuture());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Move> requestMove(@NonNull PieceColor engineColor, Duration timeout) {
|
||||
return applyToEngineIfPresent(engineColor, engine -> engine.findBestMoveAsync(timeout), true, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
applyToBothEngines(fromConsumer(ChessEngine::close), COMPOSE_VOID, null);
|
||||
}
|
||||
|
||||
private <T> T applyToEngineIfPresent(PieceColor engineColor, Function<? super ChessEngine, T> action, boolean throwException, T defaultValue) {
|
||||
Objects.requireNonNull(engineColor, "engine color"); // The only argument not checked for nullability
|
||||
ChessEngine engine = switch (engineColor) {
|
||||
case BLACK -> blackEngine;
|
||||
case WHITE -> whiteEngine;
|
||||
};
|
||||
if (engine != null) {
|
||||
return action.apply(engine);
|
||||
} else if (throwException) {
|
||||
throw new NoSuchElementException("Engine not available for color: " + engineColor);
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T applyToBothEngines(Function<? super ChessEngine, T> action, BinaryOperator<T> reducer, T defaultValue) {
|
||||
return reducer.apply(
|
||||
applyToEngineIfPresent(PieceColor.WHITE, action, false, defaultValue),
|
||||
applyToEngineIfPresent(PieceColor.BLACK, action, false, defaultValue)
|
||||
);
|
||||
}
|
||||
|
||||
private static final BinaryOperator<Void> COMPOSE_VOID = (_, _) -> null;
|
||||
private static final BinaryOperator<CompletableFuture<Void>> COMPOSE_VOID_FUTURE = (f1, f2) -> f1.thenCompose(_ -> f2);
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static final CompletableFuture NOOP_FUTURE = CompletableFuture.completedFuture(null);
|
||||
|
||||
private static <T> Function<T, Void> fromConsumer(Consumer<? super T> consumer) {
|
||||
return t -> {
|
||||
consumer.accept(t);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> CompletableFuture<T> noOpFuture() {
|
||||
return (CompletableFuture<T>) NOOP_FUTURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.fokinatorr.chess.engine.service;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.stereotype.Service;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/// Default implementation of [ChessEngineServiceFactory].
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
class DefaultChessEngineServiceFactory implements ChessEngineServiceFactory {
|
||||
|
||||
@Override
|
||||
public ChessEngineService none() {
|
||||
return NullChessEngineService.INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChessEngineService white(@NonNull ChessEngine engine) {
|
||||
Objects.requireNonNull(engine, "white engine");
|
||||
engine.setMyColor(PieceColor.WHITE);
|
||||
return new DefaultChessEngineService(engine, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChessEngineService black(@NonNull ChessEngine engine) {
|
||||
Objects.requireNonNull(engine, "black engine");
|
||||
engine.setMyColor(PieceColor.BLACK);
|
||||
return new DefaultChessEngineService(null, engine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChessEngineService both(@NonNull ChessEngine whiteEngine, @NonNull ChessEngine blackEngine) {
|
||||
Objects.requireNonNull(whiteEngine, "white engine");
|
||||
Objects.requireNonNull(blackEngine, "black engine");
|
||||
whiteEngine.setMyColor(PieceColor.WHITE);
|
||||
blackEngine.setMyColor(PieceColor.BLACK);
|
||||
return new DefaultChessEngineService(whiteEngine, blackEngine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package ru.fokinatorr.chess.engine.service;
|
||||
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/// Null (no-op) implementation of [ChessEngineService].
|
||||
enum NullChessEngineService implements ChessEngineService {
|
||||
INSTANCE;
|
||||
|
||||
private static final CompletableFuture<Void> NOOP_FUTURE = CompletableFuture.completedFuture(null);
|
||||
private static final CompletableFuture<Move> CANCELLED_FUTURE = new CompletableFuture<>();
|
||||
|
||||
static {
|
||||
CANCELLED_FUTURE.cancel(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasWhiteEngine() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBlackEngine() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChessBoardAndMoveStack(ChessBoardAndMoveStack chessBoardAndMoveStack) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> setOption(PieceColor engineColor, String name, Object value) {
|
||||
return NOOP_FUTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> positionUpdated() {
|
||||
return NOOP_FUTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Move> requestMove(@NonNull PieceColor engineColor, Duration timeout) {
|
||||
return CANCELLED_FUTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.NonFinal;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Operators;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import ru.fokinatorr.chess.Constants;
|
||||
import ru.fokinatorr.chess.engine.service.ChessEngineService;
|
||||
import ru.fokinatorr.chess.gui.game.ChessBoardButton;
|
||||
import ru.fokinatorr.chess.gui.game.ChessBoardView;
|
||||
import ru.fokinatorr.chess.impl.MoveEntry;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.Position;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.*;
|
||||
import ru.fokinatorr.chess.impl.piece.Piece;
|
||||
import ru.fokinatorr.chess.server.exception.GameCancelledException;
|
||||
import ru.fokinatorr.chess.server.game.event.*;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
/// Controller class that manages the chess game lifecycle, handles user input,
|
||||
/// integrates with the chess engine, and updates the game view accordingly.
|
||||
/// It processes player moves, handles engine responses, manages move history,
|
||||
/// and triggers appropriate UI updates and sound effects.
|
||||
@Slf4j
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class ChessGameController implements AutoCloseable {
|
||||
|
||||
private static final Duration DEFAULT_ENGINE_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
@NonNull
|
||||
@Getter
|
||||
ChessEngineService chessEngineService;
|
||||
@NonNull
|
||||
DTOService dtoService;
|
||||
@NonNull
|
||||
@Getter
|
||||
ChessGameService chessGameService;
|
||||
@NonNull
|
||||
@Getter
|
||||
ObservableChessGame chessGame;
|
||||
@Getter
|
||||
ChessBoardView chessBoardView;
|
||||
Scheduler scheduler;
|
||||
|
||||
@NonFinal
|
||||
@Getter
|
||||
GUIEventCallback guiEventCallback = GUIEventCallback.NO_OP;
|
||||
|
||||
@NonFinal
|
||||
GameEventSubscriber connectionHandler;
|
||||
@NonFinal
|
||||
boolean closed;
|
||||
/// -- GETTER --
|
||||
///
|
||||
/// Returns the client's color in the current game, if any, otherwise `null`.
|
||||
@Getter
|
||||
@NonFinal
|
||||
PieceColor myColor = null; // changes at most once
|
||||
/// Creates a new `ChessGameController`.
|
||||
public ChessGameController(@NonNull ChessBoardAndMoveStack chessBoardAndMoveStack,
|
||||
@NonNull ChessGameService chessGameService,
|
||||
@NonNull ChessEngineService chessEngineService,
|
||||
@NonNull DTOService dtoService,
|
||||
@NonNull ExecutorService executorService) {
|
||||
this.chessEngineService = chessEngineService;
|
||||
this.chessGameService = chessGameService;
|
||||
this.chessGame = new ObservableChessGame(chessBoardAndMoveStack);
|
||||
this.chessBoardView = new ChessBoardView(chessBoardAndMoveStack);
|
||||
this.dtoService = dtoService;
|
||||
this.scheduler = Schedulers.fromExecutorService(executorService);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
chessBoardView.setOnAction((position, _) -> onAction(position));
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGame.getChessBoardAndMoveStack();
|
||||
chessEngineService.setChessBoardAndMoveStack(chessBoardAndMoveStack);
|
||||
chessGame.addObserver(new SimpleChessGameObserver() {
|
||||
volatile boolean inUndoMoveLoop;
|
||||
volatile boolean inRedoMoveLoop;
|
||||
|
||||
@Override
|
||||
public void afterMovePerformed() {
|
||||
chessBoardView.unselectAllButtons();
|
||||
chessBoardView.updateView();
|
||||
playSound();
|
||||
chessEngineService.positionUpdated()
|
||||
.thenCompose(_ -> {
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
return chessEngineService.hasEngineOfColor(currentlyMoving)
|
||||
? chessEngineService.requestMove(currentlyMoving, DEFAULT_ENGINE_TIMEOUT)
|
||||
: CompletableFuture.completedFuture(null);
|
||||
})
|
||||
.thenAccept(engineMove -> {
|
||||
if (engineMove != null) {
|
||||
sendMove(engineMove);
|
||||
}
|
||||
})
|
||||
.exceptionally(t -> {
|
||||
log.error("Unexpected exception", t);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeUndoMove() {
|
||||
return !(chessEngineService.hasWhiteEngine() && chessEngineService.hasBlackEngine());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterMoveUndone() {
|
||||
if (!inUndoMoveLoop) {
|
||||
inUndoMoveLoop = true;
|
||||
do {
|
||||
chessGame.undoMove();
|
||||
} while (chessEngineService.hasEngineOfColor(chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving()));
|
||||
inUndoMoveLoop = false;
|
||||
chessBoardView.unselectAllButtons();
|
||||
chessBoardView.updateView();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeRedoMove() {
|
||||
return !(chessEngineService.hasWhiteEngine() && chessEngineService.hasBlackEngine());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterMoveRedone() {
|
||||
if (!inRedoMoveLoop) {
|
||||
inRedoMoveLoop = true;
|
||||
do {
|
||||
chessGame.redoMove();
|
||||
} while (chessEngineService.hasEngineOfColor(chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving()));
|
||||
inRedoMoveLoop = false;
|
||||
chessBoardView.unselectAllButtons();
|
||||
chessBoardView.updateView();
|
||||
playSound();
|
||||
}
|
||||
}
|
||||
|
||||
private void playSound() {
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
Move lastMove = chessBoardAndMoveStack.peekDone();
|
||||
|
||||
if (chessBoardAndMoveStack.isCheckmate(currentlyMoving)) {
|
||||
Constants.CHECKMATE_SOUND.play();
|
||||
} else if (chessBoardAndMoveStack.isCheck(currentlyMoving)) {
|
||||
Constants.CHECK_SOUND.play();
|
||||
} else if (lastMove instanceof PawnPromotionMove) {
|
||||
Constants.PROMOTION_SOUND.play();
|
||||
} else if (lastMove instanceof EnPassantMove
|
||||
|| lastMove instanceof SimpleMove simpleMove && simpleMove.getCapturedPiece() != null) {
|
||||
Constants.CAPTURE_SOUND.play();
|
||||
} else if (lastMove instanceof CastlingMove) {
|
||||
Constants.CASTLING_SOUND.play();
|
||||
} else if (!(lastMove instanceof NoOpMove)) {
|
||||
Constants.MOVE_SOUND.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
connectionHandler = new GameEventSubscriber(chessGame, guiEventCallback);
|
||||
defaultSubscribeOn(chessGameService.waitForOpponentToJoin()).subscribe(e -> {
|
||||
log.info(
|
||||
"{} joined the game {} as {}",
|
||||
e.playingWith().username(),
|
||||
e.startPosition(),
|
||||
e.playingAs().reverse().name().toLowerCase(Locale.ENGLISH) // playingAs stores the owner's color
|
||||
);
|
||||
myColor = e.playingAs();
|
||||
}, ex -> {
|
||||
if (ex instanceof GameCancelledException) {
|
||||
log.info("The game got cancelled before an opponent joined");
|
||||
connectionHandler.onSubscribe(Operators.emptySubscription());
|
||||
connectionHandler.onNext(new GameFinishedEvent(
|
||||
null,
|
||||
GameFinishedEvent.GameFinishCause.CANCELLED,
|
||||
dtoService.toDTO(chessBoardAndMoveStack, chessBoardAndMoveStack.getChessBoard())
|
||||
));
|
||||
connectionHandler.onComplete();
|
||||
close();
|
||||
} else {
|
||||
log.error("Connection exception", ex);
|
||||
}
|
||||
}, () -> {
|
||||
// If white is an engine make it move
|
||||
chessEngineService.positionUpdated()
|
||||
.thenCompose(_ -> chessEngineService.hasWhiteEngine()
|
||||
? chessEngineService.requestMove(PieceColor.WHITE, DEFAULT_ENGINE_TIMEOUT)
|
||||
: CompletableFuture.completedFuture(null))
|
||||
.thenAccept(engineMove -> {
|
||||
if (engineMove != null) {
|
||||
sendMove(engineMove);
|
||||
}
|
||||
})
|
||||
.exceptionally(t -> {
|
||||
log.error("Unexpected exception", t);
|
||||
return null;
|
||||
});
|
||||
log.info("Connecting to the game...");
|
||||
defaultSubscribeOn(chessGameService.connectToGame()).subscribe(connectionHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private void onAction(Position position) {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGame.getChessBoardAndMoveStack();
|
||||
// Don't allow to move if current move is engine's
|
||||
if (chessEngineService.hasEngineOfColor(chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving())) {
|
||||
return;
|
||||
}
|
||||
ChessBoardButton chessBoardButton = chessBoardView.getButton(position);
|
||||
switch (chessBoardButton.getState()) {
|
||||
case NORMAL -> {
|
||||
if (chessBoardView.isPieceSelected()) {
|
||||
chessBoardView.unselectAllButtons();
|
||||
} else {
|
||||
Piece piece = chessBoardAndMoveStack.getPiece(position);
|
||||
if (piece != null && piece.getColor() == chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving()) {
|
||||
chessBoardButton.setState(ChessBoardButton.State.SELECTED_PIECE_TO_MOVE);
|
||||
for (MoveEntry moveEntry : chessBoardAndMoveStack.getMoves(position)) {
|
||||
chessBoardView.getButton(moveEntry.destPos()).setState(ChessBoardButton.State.SELECTED_POSSIBLE_MOVE);
|
||||
chessBoardView.getButton(moveEntry.destPos()).setMove(moveEntry.move());
|
||||
}
|
||||
chessBoardView.setPieceSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
case SELECTED_PIECE_TO_MOVE -> chessBoardView.unselectAllButtons();
|
||||
case SELECTED_POSSIBLE_MOVE -> sendMove(chessBoardButton.getMove());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMove(Move move) {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.sendMove(move)).subscribe();
|
||||
}
|
||||
|
||||
/// Sets the [GameEvent] callback to run on the GUI thread.
|
||||
/// @param guiEventCallback the new callback
|
||||
public void setGuiEventCallback(GUIEventCallback guiEventCallback) {
|
||||
this.guiEventCallback = guiEventCallback;
|
||||
connectionHandler.setEventCallback(guiEventCallback);
|
||||
}
|
||||
|
||||
/// Attempts to undo a move; returns whether the action succeeded or not.
|
||||
public boolean undoMove() {
|
||||
ensureOpen();
|
||||
if (chessGameService instanceof LocalChessGameService localGameService) {
|
||||
defaultSubscribeOn(localGameService.undoMove()).subscribe();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Attempts to redo a move; returns whether the action succeeded or not.
|
||||
public boolean redoMove() {
|
||||
ensureOpen();
|
||||
if (chessGameService instanceof LocalChessGameService localGameService) {
|
||||
defaultSubscribeOn(localGameService.redoMove()).subscribe();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Attempts to cancel this game.
|
||||
public void cancelGame() {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.cancelGame()).subscribe();
|
||||
}
|
||||
|
||||
/// Offers a draw *to* the opponent.
|
||||
public void offerDraw() {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.offerDraw()).subscribe();
|
||||
}
|
||||
|
||||
/// Accepts a draw *from* the opponent.
|
||||
public void acceptDraw() {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.acceptDraw()).subscribe();
|
||||
}
|
||||
|
||||
/// Refuses a draw *from* the opponent.
|
||||
public void refuseDraw() {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.refuseDraw()).subscribe();
|
||||
}
|
||||
|
||||
/// Try to give up.
|
||||
public void giveUp() {
|
||||
ensureOpen();
|
||||
defaultSubscribeOn(chessGameService.giveUp()).subscribe();
|
||||
}
|
||||
|
||||
/// {@inheritDoc} This object should **NOT** be used after call to this method.
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed) {
|
||||
chessEngineService.close();
|
||||
if (connectionHandler != null) {
|
||||
connectionHandler.cancel();
|
||||
connectionHandler = null;
|
||||
}
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("This controller is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Flux<T> defaultSubscribeOn(Publisher<T> publisher) {
|
||||
return Flux.from(publisher);
|
||||
//.subscribeOn(scheduler);
|
||||
}
|
||||
|
||||
/// A collection of callbacks that are called when a [GameEvent] is received, on the GUI thread.
|
||||
/// (For JavaFX, it's the `JavaFX Application Thread`.)
|
||||
public interface GUIEventCallback {
|
||||
|
||||
/// Common instance which ignores all events.
|
||||
GUIEventCallback NO_OP = new SimpleGUIEventCallback();
|
||||
|
||||
/// When a move was performed...
|
||||
void movePerformed(MovePerformedEvent event);
|
||||
|
||||
/// When the game finishes...
|
||||
void gameFinished(GameFinishedEvent event);
|
||||
|
||||
/// When the opponent offers draw...
|
||||
void drawOffered(DrawOfferedEvent event);
|
||||
|
||||
/// When the opponent refuses draw...
|
||||
void drawRefused(DrawRefusedEvent event);
|
||||
|
||||
/// When a move was undone...
|
||||
void moveUndone(MoveUndoneEvent event);
|
||||
|
||||
/// When a move was redone...
|
||||
void moveRedone(MoveRedoneEvent event);
|
||||
}
|
||||
|
||||
/// Class providing default (no-op) implementations of [GameEvent]`s`' handlers.
|
||||
public static class SimpleGUIEventCallback implements GUIEventCallback {
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void movePerformed(MovePerformedEvent event) {
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void gameFinished(GameFinishedEvent event) {
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void drawOffered(DrawOfferedEvent event) {
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void drawRefused(DrawRefusedEvent event) {
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void moveUndone(MoveUndoneEvent event) {
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public void moveRedone(MoveRedoneEvent event) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
|
||||
/// Observes events emitted by [ObservableChessGame].
|
||||
public interface ChessGameObserver {
|
||||
|
||||
/// Executes before a move is performed. Returns `true` if allow performing move, `false` otherwise.
|
||||
boolean beforePerformMove();
|
||||
|
||||
/// Executes after a move is performed.
|
||||
void afterMovePerformed();
|
||||
|
||||
/// Executes before a move is undone. Returns `true` if allow undoing move, `false` otherwise.
|
||||
boolean beforeUndoMove();
|
||||
|
||||
/// Executes after a move is undone.
|
||||
void afterMoveUndone();
|
||||
|
||||
/// Executes before a move is redone. Returns `true` if allow redoing move, `false` otherwise.
|
||||
boolean beforeRedoMove();
|
||||
|
||||
/// Executes after a move is redone.
|
||||
void afterMoveRedone();
|
||||
|
||||
/// Executes after a check was done by the given color.
|
||||
void onCheck(PieceColor checker);
|
||||
|
||||
/// Executes after a checkmate was done by the given color.
|
||||
void onCheckmate(PieceColor winner);
|
||||
|
||||
/// Executes after a stalemate occurs.
|
||||
void onStalemate();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.server.exception.CannotAcceptOrRefuseDrawException;
|
||||
import ru.fokinatorr.chess.server.exception.DrawAlreadyOfferedException;
|
||||
import ru.fokinatorr.chess.server.exception.DrawNotOfferedException;
|
||||
import ru.fokinatorr.chess.server.exception.GameCancelledException;
|
||||
import ru.fokinatorr.chess.server.exception.MoveAlreadyDoneException;
|
||||
import ru.fokinatorr.chess.server.exception.MoveOrderException;
|
||||
import ru.fokinatorr.chess.server.exception.NotParticipantException;
|
||||
import ru.fokinatorr.chess.server.game.event.GameEvent;
|
||||
import ru.fokinatorr.chess.server.game.event.GameJoinedEvent;
|
||||
|
||||
/// Handles submitting/receiving game events.
|
||||
public interface ChessGameService {
|
||||
|
||||
/// Returns a `Publisher` that emits events related to the game, completing after a {@link GameEvent#isTerminal() terminal} event was sent.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
Flux<GameEvent> connectToGame();
|
||||
|
||||
/// Returns a `Publisher` that emits a single [GameJoinedEvent] once an opponent joins. If the game is offline, the `Publisher` completes
|
||||
/// without a value.
|
||||
///
|
||||
/// It can also emit [GameCancelledException] if the game gets cancelled before an opponent joined.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws GameCancelledException {@literal (emitted by the `Publisher`)} if the game gets canceled before an opponent joins.
|
||||
Mono<GameJoinedEvent> waitForOpponentToJoin();
|
||||
|
||||
|
||||
/// Send a move to the game. The resulting `Publisher` will signal when the move submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws MoveOrderException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials HAS access to the game, but it's not their turn
|
||||
Mono<Void> sendMove(Move move);
|
||||
|
||||
/// Try to cancel the game. The resulting `Publisher` will signal when the event submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws MoveAlreadyDoneException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials HAS access to the game, but a move was already performed in the game
|
||||
Mono<Void> cancelGame();
|
||||
|
||||
|
||||
/// Offer a draw. The resulting `Publisher` will signal when the event submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws MoveOrderException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials HAS access to the game, but it's not their turn
|
||||
/// @throws DrawAlreadyOfferedException {@literal (emitted by the `Publisher`)} if a draw was already offered
|
||||
Mono<Void> offerDraw();
|
||||
|
||||
|
||||
/// Try to accept a draw. The resulting `Publisher` will signal when the event submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws DrawNotOfferedException {@literal (emitted by the `Publisher`)} if no draw was offered
|
||||
/// @throws CannotAcceptOrRefuseDrawException {@literal (emitted by the `Publisher`)} if the game is online, a draw WAS offered and the user represented by the produced credentials tried to accept their own draw
|
||||
Mono<Void> acceptDraw();
|
||||
|
||||
/// Try to refuse a draw. The resulting `Publisher` will signal when the event submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws DrawNotOfferedException {@literal (emitted by the `Publisher`)} if no draw was offered
|
||||
/// @throws CannotAcceptOrRefuseDrawException {@literal (emitted by the `Publisher`)} if the game is online, a draw WAS offered and the user represented by the produced credentials tried to refuse their own draw
|
||||
Mono<Void> refuseDraw();
|
||||
|
||||
/// Try to give up. The resulting `Publisher` will signal when the event submission completes.
|
||||
/// @throws BadCredentialsException {@literal (emitted by the `Publisher`)} if the game is online and credentials produced by the factory are invalid
|
||||
/// @throws NotParticipantException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials does not have access to the game
|
||||
/// @throws MoveOrderException {@literal (emitted by the `Publisher`)} if the game is online and the user represented by the produced credentials HAS access to the game, but it's not their turn
|
||||
Mono<Void> giveUp();
|
||||
|
||||
/// Returns the color assigned to the current client in this game session, if applicable. The resulting `Publisher`
|
||||
/// emits the owned color if there's any; otherwise, completes without an element.
|
||||
/// <p>
|
||||
/// In online games, this returns the color (white/black) associated with the authenticated user's participation.
|
||||
/// In local/offline games, this returns empty because both players may use the same device and no "owned" color exists.
|
||||
/// </p>
|
||||
/// @return {@link Mono} emitting the client's color if the game assigns one, nothing otherwise
|
||||
Mono<PieceColor> getMyColor();
|
||||
|
||||
/// Checks whether the current client has an assigned color in this game.
|
||||
/// Useful for UI decisions like disabling engine control or hiding "your turn" indicators.
|
||||
/// @return The `Publisher` emits `true` if the client has a designated color (typically online games), `false` otherwise (e.g., offline/local)
|
||||
default Mono<Boolean> hasMyColor() {
|
||||
return getMyColor().hasElement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.impl.ChessBoard;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.security.UserCredentialsFactory;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/// Creates a [ChessGameService].
|
||||
public interface ChessGameServiceFactory {
|
||||
|
||||
/// Creates and returns an offline chess game service.
|
||||
/// @param chessBoardAndMoveStack the {@link ChessBoardAndMoveStack} representing the game {@literal (UNCLONED)}, must not be `null`. Required for event creation.
|
||||
/// @param initialBoard the initial position {@literal (can be cloned)}, must not be `null`. Required for event creation.
|
||||
/// @throws NullPointerException if any argument is `null`.
|
||||
LocalChessGameService offline(@NonNull ChessBoardAndMoveStack chessBoardAndMoveStack, @NonNull ChessBoard initialBoard);
|
||||
|
||||
/// Creates and returns an online chess game service.
|
||||
/// @param gameId the game's ID, must not be `null`.
|
||||
/// @param credentialsFactory the factory for producing credentials, must not be `null` or return `null`.
|
||||
/// @throws NullPointerException if any argument is `null`.
|
||||
ChessGameService online(@NonNull UUID gameId, @NonNull UserCredentialsFactory credentialsFactory);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import ru.fokinatorr.chess.impl.ChessBoard;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.misc.ChessServerProps;
|
||||
import ru.fokinatorr.chess.security.UserCredentialsFactory;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
class DefaultChessGameServiceFactory implements ChessGameServiceFactory {
|
||||
|
||||
DTOService dtoService;
|
||||
RSocketRequester.Builder rsocket;
|
||||
WebClient http;
|
||||
ChessServerProps chessServerProps;
|
||||
|
||||
@Override
|
||||
public LocalChessGameService offline(@NonNull ChessBoardAndMoveStack chessBoardAndMoveStack, @NonNull ChessBoard initialBoard) {
|
||||
return new DefaultOfflineChessGameService(chessBoardAndMoveStack, initialBoard.clone(), dtoService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChessGameService online(@NonNull UUID gameId, @NonNull UserCredentialsFactory credentialsFactory) {
|
||||
return new DefaultOnlineChessGameService(gameId, credentialsFactory, rsocket, http, chessServerProps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.util.function.Tuples;
|
||||
import ru.fokinatorr.chess.impl.ChessBoard;
|
||||
import ru.fokinatorr.chess.impl.ChessUtil;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.server.exception.*;
|
||||
import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO;
|
||||
import ru.fokinatorr.chess.server.game.event.*;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static reactor.core.publisher.Mono.fromRunnable;
|
||||
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
class DefaultOfflineChessGameService implements LocalChessGameService {
|
||||
|
||||
Sinks.Many<GameEvent> selfSink = Sinks.many().replay().all();
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack;
|
||||
ChessBoard initialBoard;
|
||||
DTOService dtoService;
|
||||
AtomicReference<PieceColor> drawOfferedBy = new AtomicReference<>();
|
||||
|
||||
private static final Sinks.EmitFailureHandler CONTINUE_IF_NON_SERIAL = (_, emitResult) -> emitResult != Sinks.EmitResult.FAIL_NON_SERIALIZED;
|
||||
|
||||
@Override
|
||||
public Mono<Void> undoMove() {
|
||||
return fromRunnable(() -> {
|
||||
if (chessBoardAndMoveStack.peekDone() != null) {
|
||||
selfSink.emitNext(new MoveUndoneEvent(createPositionDTO(ChessBoardAndMoveStack::undo)), CONTINUE_IF_NON_SERIAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> redoMove() {
|
||||
return fromRunnable(() -> {
|
||||
if (chessBoardAndMoveStack.peekUndone() != null) {
|
||||
selfSink.emitNext(new MoveRedoneEvent(createPositionDTO(ChessBoardAndMoveStack::redo)), CONTINUE_IF_NON_SERIAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GameEvent> connectToGame() {
|
||||
return selfSink.asFlux();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMove(Move move) {
|
||||
return Mono.just(ChessUtil.isValidMove(chessBoardAndMoveStack, move))
|
||||
.filter(b -> b)
|
||||
.switchIfEmpty(Mono.error(() -> new InvalidMoveException(move.toUCI(), createPositionDTO())))
|
||||
.flatMap(_ -> fromRunnable(() -> selfSink.emitNext(new MovePerformedEvent(move.toUCI(), createPositionDTO(cb -> {
|
||||
cb.perform(move.deepCopy());
|
||||
cb.reverseCurrentlyMoving();
|
||||
})), CONTINUE_IF_NON_SERIAL)))
|
||||
.then(checkTerminalState())
|
||||
.flatMap(e -> fromRunnable(() -> {
|
||||
selfSink.emitNext(e, CONTINUE_IF_NON_SERIAL);
|
||||
selfSink.emitComplete(CONTINUE_IF_NON_SERIAL);
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> offerDraw() {
|
||||
return Mono.justOrEmpty(drawOfferedBy.get())
|
||||
.filter(Objects::isNull)
|
||||
.switchIfEmpty(Mono.error(() -> new DrawAlreadyOfferedException(null)))
|
||||
.flatMap(_ -> fromRunnable(() -> {
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
drawOfferedBy.lazySet(currentlyMoving);
|
||||
selfSink.emitNext(new DrawOfferedEvent(currentlyMoving), CONTINUE_IF_NON_SERIAL);
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> acceptDraw() {
|
||||
return Mono.justOrEmpty(drawOfferedBy.get())
|
||||
.filter(Objects::nonNull)
|
||||
.switchIfEmpty(Mono.error(() -> new DrawNotOfferedException(null)))
|
||||
.filter(drawOfferedBy -> drawOfferedBy != chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving())
|
||||
.switchIfEmpty(Mono.error(() -> new CannotAcceptOrRefuseDrawException(-1, null)))
|
||||
.flatMap(_ -> fromRunnable(() -> {
|
||||
drawOfferedBy.lazySet(null);
|
||||
selfSink.emitNext(new GameFinishedEvent(null, GameFinishedEvent.GameFinishCause.DRAW_ACCEPTED, createPositionDTO()), CONTINUE_IF_NON_SERIAL);
|
||||
selfSink.emitComplete(CONTINUE_IF_NON_SERIAL);
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> refuseDraw() {
|
||||
return Mono.justOrEmpty(drawOfferedBy.get())
|
||||
.filter(Objects::nonNull)
|
||||
.switchIfEmpty(Mono.error(() -> new DrawNotOfferedException(null)))
|
||||
.filter(drawOfferedBy -> drawOfferedBy != chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving())
|
||||
.switchIfEmpty(Mono.error(() -> new CannotAcceptOrRefuseDrawException(-1, null)))
|
||||
.flatMap(_ -> fromRunnable(() -> selfSink.emitNext(new DrawRefusedEvent(drawOfferedBy.getAndSet(null).reverse()), CONTINUE_IF_NON_SERIAL)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> giveUp() {
|
||||
return fromRunnable(() -> {
|
||||
selfSink.emitNext(new GameFinishedEvent(
|
||||
chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving().reverse(),
|
||||
GameFinishedEvent.GameFinishCause.GIVE_UP,
|
||||
createPositionDTO()
|
||||
), CONTINUE_IF_NON_SERIAL);
|
||||
selfSink.emitComplete(CONTINUE_IF_NON_SERIAL);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PieceColor> getMyColor() {
|
||||
return Mono.empty(); // Two players can play in the same client instance so there's no "my color"
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> hasMyColor() {
|
||||
return Mono.just(false); // Two players can play in the same client instance so there's never a "my color"
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> cancelGame() {
|
||||
return Mono.defer(() -> {
|
||||
if (chessBoardAndMoveStack.peekDone() == null && chessBoardAndMoveStack.peekUndone() == null) {
|
||||
return fromRunnable(() -> {
|
||||
selfSink.emitNext(new GameFinishedEvent(
|
||||
null,
|
||||
GameFinishedEvent.GameFinishCause.CANCELLED,
|
||||
createPositionDTO()
|
||||
), CONTINUE_IF_NON_SERIAL);
|
||||
selfSink.emitComplete(CONTINUE_IF_NON_SERIAL);
|
||||
});
|
||||
} else {
|
||||
return Mono.error(() -> new MoveAlreadyDoneException(null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ChessBoardAndMoveStackDTO createPositionDTO() {
|
||||
return createPositionDTO(NO_OP);
|
||||
}
|
||||
|
||||
private ChessBoardAndMoveStackDTO createPositionDTO(Consumer<? super ChessBoardAndMoveStack> beforeCreation) {
|
||||
ChessBoardAndMoveStack clone = chessBoardAndMoveStack.clone();
|
||||
beforeCreation.accept(clone);
|
||||
return dtoService.toDTO(clone, initialBoard);
|
||||
}
|
||||
|
||||
private static final Consumer<? super ChessBoardAndMoveStack> NO_OP = _ -> {
|
||||
};
|
||||
|
||||
|
||||
private Mono<GameFinishedEvent> checkTerminalState() {
|
||||
//noinspection OptionalGetWithoutIsPresent
|
||||
return Mono.fromCallable(() -> {
|
||||
PieceColor winner = null;
|
||||
GameFinishedEvent.GameFinishCause gameFinishCause = null;
|
||||
if (chessBoardAndMoveStack.isCheckmate(chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving())) {
|
||||
gameFinishCause = GameFinishedEvent.GameFinishCause.CHECKMATE;
|
||||
winner = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving().reverse();
|
||||
} else if (chessBoardAndMoveStack.isStalemate(chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving())) {
|
||||
gameFinishCause = GameFinishedEvent.GameFinishCause.STALEMATE;
|
||||
}
|
||||
return Tuples.of(Optional.ofNullable(winner), Optional.ofNullable(gameFinishCause));
|
||||
})
|
||||
.filter(tuple -> tuple.getT2().isPresent())
|
||||
.map(tuple -> new GameFinishedEvent(
|
||||
tuple.getT1().orElse(null),
|
||||
tuple.getT2().get(), // should not be null
|
||||
createPositionDTO()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.misc.ChessServerProps;
|
||||
import ru.fokinatorr.chess.security.UserCredentials;
|
||||
import ru.fokinatorr.chess.security.UserCredentialsFactory;
|
||||
import ru.fokinatorr.chess.server.game.event.GameEvent;
|
||||
import ru.fokinatorr.chess.server.game.event.GameJoinedEvent;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
class DefaultOnlineChessGameService implements ChessGameService {
|
||||
|
||||
@NonNull
|
||||
UUID gameId;
|
||||
@NonNull
|
||||
UserCredentialsFactory credentialsFactory;
|
||||
RSocketRequester.Builder rsocket;
|
||||
WebClient http;
|
||||
ChessServerProps chessServerProps;
|
||||
AtomicReference<UserCredentials> cacheCredentials = new AtomicReference<>();
|
||||
|
||||
@Override
|
||||
public Flux<GameEvent> connectToGame() {
|
||||
RSocketRequester requester = connect();
|
||||
return requester.route("game.join")
|
||||
.data(Mono.just(gameId), UUID.class)
|
||||
.retrieveFlux(GameEvent.class)
|
||||
.as(p -> disposeConnectionOnTerminate(requester, p));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GameJoinedEvent> waitForOpponentToJoin() {
|
||||
RSocketRequester requester = connect();
|
||||
return requester.route("game.waitForJoin")
|
||||
.data(Mono.just(gameId), UUID.class)
|
||||
.retrieveMono(GameJoinedEvent.class)
|
||||
.as(p -> disposeConnectionOnTerminate(requester, p));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMove(Move move) {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.put()
|
||||
.uri("/game/move/perform?gameId={gameId}&move={move}", gameId, move.toUCI())
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, sendMove(move)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> offerDraw() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.put()
|
||||
.uri("/game/draw/offer?gameId={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, offerDraw()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> acceptDraw() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.put()
|
||||
.uri("/game/draw/accept?gameId={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, acceptDraw()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> refuseDraw() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.put()
|
||||
.uri("/game/draw/refuse?gameId={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, refuseDraw()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> giveUp() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.put()
|
||||
.uri("/game/giveup?gameId={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, giveUp()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PieceColor> getMyColor() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.get()
|
||||
.uri("/game/getcolor?gameId={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(PieceColor.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, getMyColor()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> hasMyColor() {
|
||||
return Mono.just(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> cancelGame() {
|
||||
return authenticate()
|
||||
.flatMap(
|
||||
credentials -> http.delete()
|
||||
.uri("/game/cancel?id={gameId}", gameId)
|
||||
.header("Authorization", credentials.generateAuthorizationHeader())
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
)
|
||||
.as(p -> reAuthenticateOnAuthException(p, cancelGame()));
|
||||
}
|
||||
|
||||
private RSocketRequester connect() {
|
||||
return rsocket.websocket(URI.create(chessServerProps.getRSocketBasePath()));
|
||||
}
|
||||
|
||||
private static <T> Flux<T> disposeConnectionOnTerminate(RSocketRequester requester, Flux<T> source) {
|
||||
return source
|
||||
.doOnTerminate(requester::dispose)
|
||||
.doOnCancel(requester::dispose);
|
||||
}
|
||||
|
||||
private static <T> Mono<T> disposeConnectionOnTerminate(RSocketRequester requester, Mono<T> source) {
|
||||
return source
|
||||
.doOnTerminate(requester::dispose)
|
||||
.doOnCancel(requester::dispose);
|
||||
}
|
||||
|
||||
private Mono<UserCredentials> authenticate() {
|
||||
return Mono.justOrEmpty(cacheCredentials.get())
|
||||
.switchIfEmpty(Mono.fromSupplier(credentialsFactory).cast(UserCredentials.class))
|
||||
.doOnNext(cacheCredentials::set);
|
||||
}
|
||||
|
||||
private <T> Mono<T> reAuthenticateOnAuthException(Mono<T> source, Mono<T> next) {
|
||||
return source.onErrorResume(AuthenticationException.class, _ -> refreshCredentials().then(next));
|
||||
}
|
||||
|
||||
private Mono<Void> refreshCredentials() {
|
||||
return Mono.justOrEmpty(cacheCredentials.get())
|
||||
.flatMap(credentialsFactory.andThen(Mono::from))
|
||||
.cast(UserCredentials.class)
|
||||
.switchIfEmpty(Mono.fromSupplier(credentialsFactory).cast(UserCredentials.class))
|
||||
.doOnNext(cacheCredentials::lazySet)
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import reactor.core.publisher.Operators;
|
||||
import ru.fokinatorr.chess.impl.ChessUtil;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO;
|
||||
import ru.fokinatorr.chess.server.game.event.*;
|
||||
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.util.Locale;
|
||||
|
||||
/// Listens to game events and reflects them in a local chess game instance.
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class GameEventSubscriber implements Subscriber<GameEvent> {
|
||||
|
||||
boolean seenTerminalEvent;
|
||||
Subscription sub;
|
||||
final ObservableChessGame chessGame;
|
||||
@Setter
|
||||
@NonNull
|
||||
ChessGameController.GUIEventCallback eventCallback;
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Subscription s) {
|
||||
if (Operators.validate(sub, s)) {
|
||||
this.sub = s;
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(GameEvent gameEvent) {
|
||||
assert !seenTerminalEvent : "Expected onComplete() after terminal event";
|
||||
seenTerminalEvent = gameEvent.isTerminal();
|
||||
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGame.getChessBoardAndMoveStack();
|
||||
switch (gameEvent) {
|
||||
case MovePerformedEvent e -> {
|
||||
Move move = ChessUtil.parseUCIMove(e.move(), chessBoardAndMoveStack);
|
||||
try {
|
||||
chessGame.performMove(move);
|
||||
log.info(
|
||||
"Move {} done by {}. Current thread: {}",
|
||||
move, // the move
|
||||
chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving().name().toLowerCase(Locale.ENGLISH), // color
|
||||
Thread.currentThread().getName() // current thread name
|
||||
);
|
||||
} catch (IllegalStateException _) {
|
||||
// usually means the move couldn't be performed. set the board to fallback
|
||||
restoreFromFallback(e.chessBoardAndMoveStack());
|
||||
}
|
||||
Platform.runLater(() -> eventCallback.movePerformed(e));
|
||||
}
|
||||
case MoveUndoneEvent e -> {
|
||||
try {
|
||||
Move undone = chessGame.undoMove();
|
||||
log.info(
|
||||
"Move {} undone by {}. Current thread: {}",
|
||||
undone, // the move undone
|
||||
chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving().name().toLowerCase(Locale.ENGLISH), // color
|
||||
Thread.currentThread().getName() // the current thread name
|
||||
);
|
||||
} catch (IllegalStateException _) {
|
||||
// usually means the move couldn't be undone. set the board to fallback
|
||||
restoreFromFallback(e.chessBoardAndMoveStack());
|
||||
}
|
||||
Platform.runLater(() -> eventCallback.moveUndone(e));
|
||||
}
|
||||
case MoveRedoneEvent e -> {
|
||||
try {
|
||||
Move redone = chessGame.redoMove();
|
||||
log.info(
|
||||
"Move {} redone by {}. Current thread: {}",
|
||||
redone, // the move redone
|
||||
chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving().name().toLowerCase(Locale.ENGLISH), // color
|
||||
Thread.currentThread().getName() // the current thread name
|
||||
);
|
||||
} catch (IllegalStateException _) {
|
||||
// usually means the move couldn't be undone. set the board to fallback
|
||||
restoreFromFallback(e.chessBoardAndMoveStack());
|
||||
}
|
||||
Platform.runLater(() -> eventCallback.moveRedone(e));
|
||||
}
|
||||
case GameFinishedEvent e -> {
|
||||
log.info(
|
||||
"Game finished for reason '{}'. Winner: {}",
|
||||
e.cause().name().toLowerCase(Locale.ENGLISH).replace('_', ' '),
|
||||
e.winner() == null ? "none" : e.winner().name().toLowerCase(Locale.ENGLISH)
|
||||
);
|
||||
restoreFromFallback(e.chessBoardAndMoveStack());
|
||||
Platform.runLater(() -> eventCallback.gameFinished(e));
|
||||
}
|
||||
case DrawOfferedEvent e -> {
|
||||
log.info(
|
||||
"{} offered draw",
|
||||
e.byColor().name().charAt(0) + e.byColor().name().toLowerCase(Locale.ENGLISH).substring(1) // Capitalize the color
|
||||
);
|
||||
Platform.runLater(() -> eventCallback.drawOffered(e));
|
||||
}
|
||||
case DrawRefusedEvent e -> {
|
||||
log.info(
|
||||
"{} refused draw",
|
||||
e.byColor().name().charAt(0) + e.byColor().name().toLowerCase(Locale.ENGLISH).substring(1) // Capitalize the color
|
||||
);
|
||||
Platform.runLater(() -> eventCallback.drawRefused(e));
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unsupported event: " + gameEvent.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
sub = null;
|
||||
if (t instanceof ClosedChannelException) {
|
||||
log.error("Disconnected from channel", t);
|
||||
}
|
||||
log.error("Unexpected error", t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
assert seenTerminalEvent : "Unexpected onComplete(): terminal event not seen";
|
||||
sub = null;
|
||||
}
|
||||
|
||||
|
||||
void restoreFromFallback(ChessBoardAndMoveStackDTO fallback) {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGame.getChessBoardAndMoveStack();
|
||||
while (chessBoardAndMoveStack.peekDone() != null) {
|
||||
chessBoardAndMoveStack.undo();
|
||||
}
|
||||
chessBoardAndMoveStack.getChessBoard().setPieces(fallback.initialBoard().getPieces());
|
||||
for (String move : fallback.doneMoves()) {
|
||||
chessBoardAndMoveStack.perform(ChessUtil.parseUCIMove(move, chessBoardAndMoveStack));
|
||||
}
|
||||
for (int i = fallback.undoneMoves().length - 1; i >= 0; i--) {
|
||||
chessBoardAndMoveStack.perform(ChessUtil.parseUCIMove(fallback.undoneMoves()[i], chessBoardAndMoveStack));
|
||||
}
|
||||
for (int i = 0; i < fallback.undoneMoves().length; i++) {
|
||||
chessBoardAndMoveStack.undo();
|
||||
}
|
||||
chessBoardAndMoveStack.getChessBoard().setCurrentlyMoving(fallback.currentlyMoving());
|
||||
}
|
||||
|
||||
/// Cancels the subscription and clears the reference to it, effectively disconnecting.
|
||||
public void cancel() {
|
||||
if (sub != null) {
|
||||
sub.cancel();
|
||||
sub = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import ru.fokinatorr.chess.server.game.event.GameJoinedEvent;
|
||||
|
||||
/// Additional methods for a local (offline) chess game.
|
||||
public interface LocalChessGameService extends ChessGameService {
|
||||
|
||||
/// Undo a move. The resulting `Publisher` will signal when the event submission completes.
|
||||
Mono<Void> undoMove();
|
||||
|
||||
/// Redo a move. The resulting `Publisher` will signal when the event submission completes.
|
||||
Mono<Void> redoMove();
|
||||
|
||||
/// Returns a `Publisher` that completes without a value since local games can't have an opponent joining.
|
||||
/// @return an empty `Publisher`
|
||||
@Override
|
||||
default Mono<GameJoinedEvent> waitForOpponentToJoin() {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import ru.fokinatorr.chess.impl.ChessUtil;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BinaryOperator;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/// Manages a chess game's state, emitting events on performed moves.
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class ObservableChessGame {
|
||||
|
||||
/// The chess game
|
||||
@Getter
|
||||
final ChessBoardAndMoveStack chessBoardAndMoveStack;
|
||||
|
||||
ObservableList<ChessGameObserver> observers = FXCollections.observableArrayList();
|
||||
ObservableList<ChessGameObserver> observersUnmodifiable;
|
||||
|
||||
/// Performs a move on this chess board, if it was allowed by {@link #getBeforePerformMove() the hook} and the move is valid,
|
||||
/// and executes {@link #getAfterMovePerformed() the handler}.
|
||||
public void performMove(Move move) {
|
||||
if (getBeforePerformMove().getAsBoolean() && ChessUtil.isValidMove(chessBoardAndMoveStack, move)) {
|
||||
chessBoardAndMoveStack.perform(move);
|
||||
chessBoardAndMoveStack.reverseCurrentlyMoving();
|
||||
getAfterMovePerformed().run();
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
if (chessBoardAndMoveStack.isCheckmate(currentlyMoving)) {
|
||||
getOnCheckmate().accept(currentlyMoving.reverse()); // accept the winner
|
||||
} else if (chessBoardAndMoveStack.isCheck(currentlyMoving)) {
|
||||
getOnCheck().accept(currentlyMoving.reverse()); // accept the player who checked
|
||||
} else if (chessBoardAndMoveStack.isStalemate(currentlyMoving)) {
|
||||
getOnStalemate().run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes a move on this chess board, if it was allowed by {@link #getBeforeUndoMove() the hook},
|
||||
/// executes {@link #getAfterMoveUndone() the handler} and returns the undone move, if any.
|
||||
public Move undoMove() {
|
||||
if (getBeforeUndoMove().getAsBoolean()) {
|
||||
Move result = chessBoardAndMoveStack.undo();
|
||||
chessBoardAndMoveStack.reverseCurrentlyMoving();
|
||||
getAfterMoveUndone().run();
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Redoes a move on this chess board, if it was allowed by {@link #getBeforeRedoMove() the hook},
|
||||
/// executes {@link #getAfterMoveRedone() the handler} and returns the redone move, if any.
|
||||
public Move redoMove() {
|
||||
if (getBeforeRedoMove().getAsBoolean()) {
|
||||
Move result = chessBoardAndMoveStack.redo();
|
||||
chessBoardAndMoveStack.reverseCurrentlyMoving();
|
||||
getAfterMoveRedone().run();
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// Returns what to do before a move is performed and whether to allow performing a move.
|
||||
public BooleanSupplier getBeforePerformMove() {
|
||||
return applyToObservers(observer -> observer::beforePerformMove, COMPOSE_BOOLEAN_SUP, ALWAYS);
|
||||
}
|
||||
|
||||
/// Returns what to do after a move was performed.
|
||||
public Runnable getAfterMovePerformed() {
|
||||
return applyToObservers(observer -> observer::afterMovePerformed, COMPOSE_RUNNABLE, NOOP_RUN);
|
||||
}
|
||||
|
||||
/// Returns what to do before a move is undone and whether to allow undoing a move.
|
||||
public BooleanSupplier getBeforeUndoMove() {
|
||||
return applyToObservers(observer -> observer::beforeUndoMove, COMPOSE_BOOLEAN_SUP, ALWAYS);
|
||||
}
|
||||
|
||||
/// Returns what to do after a move was undone.
|
||||
public Runnable getAfterMoveUndone() {
|
||||
return applyToObservers(observer -> observer::afterMoveUndone, COMPOSE_RUNNABLE, NOOP_RUN);
|
||||
}
|
||||
|
||||
/// Returns what to do before a move is redone and whether to allow redoing a move.
|
||||
public BooleanSupplier getBeforeRedoMove() {
|
||||
return applyToObservers(observer -> observer::beforeRedoMove, COMPOSE_BOOLEAN_SUP, ALWAYS);
|
||||
}
|
||||
|
||||
/// Returns what to do after a move was redone.
|
||||
public Runnable getAfterMoveRedone() {
|
||||
return applyToObservers(observer -> observer::afterMoveRedone, COMPOSE_RUNNABLE, NOOP_RUN);
|
||||
}
|
||||
|
||||
/// Returns what to do after a check occurs.
|
||||
public Consumer<PieceColor> getOnCheck() {
|
||||
return applyToObservers(observer -> observer::onCheck, Consumer::andThen, noOpConsumer());
|
||||
}
|
||||
|
||||
/// Returns what to do after a checkmate occurs.
|
||||
public Consumer<PieceColor> getOnCheckmate() {
|
||||
return applyToObservers(observer -> observer::onCheckmate, Consumer::andThen, noOpConsumer());
|
||||
}
|
||||
|
||||
/// Returns what to do after a stalemate occurs.
|
||||
public Runnable getOnStalemate() {
|
||||
return applyToObservers(observer -> observer::onStalemate, COMPOSE_RUNNABLE, NOOP_RUN);
|
||||
}
|
||||
|
||||
/// Add a [ChessGameObserver] that will observe events on this game.
|
||||
public void addObserver(@NonNull ChessGameObserver observer) {
|
||||
if (!observers.contains(observer)) {
|
||||
observers.add(Objects.requireNonNull(observer, "Observer cannot be null."));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the given [ChessGameObserver] from the list of observers so that it no longer observers events on this game.
|
||||
/// Note that the observer to remove and the given observer should be the same.
|
||||
public void removeObserver(ChessGameObserver observer) {
|
||||
observers.remove(observer);
|
||||
}
|
||||
|
||||
/// Returns an [ObservableList] of the current observers. It is immutable; to add/remove observers use {@link #addObserver(ChessGameObserver)} and
|
||||
/// {@link #removeObserver(ChessGameObserver)} respectively.
|
||||
public ObservableList<ChessGameObserver> getObservers() {
|
||||
if (observers == null) {
|
||||
observers = FXCollections.observableList(new CopyOnWriteArrayList<>());
|
||||
}
|
||||
if (observersUnmodifiable == null) {
|
||||
observersUnmodifiable = FXCollections.unmodifiableObservableList(observers);
|
||||
}
|
||||
return observersUnmodifiable;
|
||||
}
|
||||
|
||||
private <T> T applyToObservers(Function<? super ChessGameObserver, T> transformer,
|
||||
BinaryOperator<T> reducer,
|
||||
T defaultValue) {
|
||||
// Disable formatter to improve readability of the stream pipeline below
|
||||
// @formatter:off
|
||||
return observers == null ? defaultValue : observers.stream()
|
||||
.map(transformer)
|
||||
.reduce(reducer)
|
||||
.orElse(defaultValue);
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// default handlers, cached for performance
|
||||
// @formatter:off
|
||||
private static final Runnable NOOP_RUN = () -> {};
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static final Consumer NOOP_CONSUMER = _ -> {};
|
||||
// @formatter:on
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> Consumer<T> noOpConsumer() {
|
||||
return (Consumer<T>) NOOP_CONSUMER;
|
||||
}
|
||||
|
||||
private static final BooleanSupplier ALWAYS = () -> true;
|
||||
|
||||
// composers
|
||||
private static final BinaryOperator<Runnable> COMPOSE_RUNNABLE = (r1, r2) -> () -> {
|
||||
r1.run();
|
||||
r2.run();
|
||||
};
|
||||
|
||||
private static final BinaryOperator<BooleanSupplier> COMPOSE_BOOLEAN_SUP = (s1, s2) -> () -> s1.getAsBoolean() && s2.getAsBoolean();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.fokinatorr.chess.game;
|
||||
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
|
||||
/// Class providing default implementations of [ObservableChessGame] events.
|
||||
public class SimpleChessGameObserver implements ChessGameObserver {
|
||||
@Override
|
||||
public boolean beforePerformMove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterMovePerformed() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeUndoMove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterMoveUndone() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeRedoMove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterMoveRedone() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCheck(PieceColor checker) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCheckmate(PieceColor winner) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStalemate() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.fokinatorr.chess.gui;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Slider;
|
||||
import ru.fokinatorr.chess.misc.Pair;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.rgielen.fxweaver.core.FxmlView;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import ru.fokinatorr.chess.misc.ChessEngineSettings;
|
||||
|
||||
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
|
||||
|
||||
@Controller
|
||||
@FxmlView("double_engine_settings.fxml")
|
||||
@Scope(SCOPE_PROTOTYPE)
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class DoubleEngineSettingsController {
|
||||
|
||||
@FXML
|
||||
Slider blackSkillLevelView;
|
||||
@FXML
|
||||
Slider whiteSkillLevelView;
|
||||
|
||||
/// Build the [ChessEngineSettings] pair by the current view properties.
|
||||
public Pair<Integer, Integer> getResult() {
|
||||
return new Pair<>((int) whiteSkillLevelView.getValue(), (int) blackSkillLevelView.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package ru.fokinatorr.chess.gui;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Stage;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.rgielen.fxweaver.core.FxmlView;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.game.ChessGameController;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.server.game.event.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
|
||||
|
||||
/// A chess game controller.
|
||||
///
|
||||
/// **NOTE**: This was renamed from `GameController` to `GameFXController` to avoid confusion with [ChessGameController].
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Scope(SCOPE_PROTOTYPE)
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
@FxmlView("game.fxml")
|
||||
public class GameFXController {
|
||||
|
||||
final ObjectProvider<ChessEngine> chessEngineProvider;
|
||||
final ExecutorService executorService;
|
||||
@FXML
|
||||
StackPane chessBoardViewContainer;
|
||||
@FXML
|
||||
Button undoButton;
|
||||
@FXML
|
||||
Button redoButton;
|
||||
@FXML
|
||||
Button giveUpButton;
|
||||
@FXML
|
||||
Button offerDrawButton;
|
||||
@FXML
|
||||
Button analyzePositionButton;
|
||||
@FXML
|
||||
Button settingsButton;
|
||||
|
||||
@Getter
|
||||
ChessGameController chessGameController;
|
||||
@Getter
|
||||
Stage parent;
|
||||
boolean endGame, wasEndGame;
|
||||
|
||||
/// Initialize this `GameFXController` with other not Spring beans data but necessary to run. ***MUST*** be called before
|
||||
/// any other action with this controller.
|
||||
public void init(ChessGameController chessGameController, Stage parent) {
|
||||
if (this.chessGameController != null) {
|
||||
throw new IllegalStateException("Already initialized with " + this.chessGameController + ", " + this.parent);
|
||||
}
|
||||
this.chessGameController = Objects.requireNonNull(chessGameController, "chess game controller");
|
||||
this.parent = Objects.requireNonNull(parent, "parent stage");
|
||||
postInitialize();
|
||||
}
|
||||
|
||||
private void postInitialize() {
|
||||
chessBoardViewContainer.getChildren().setAll(chessGameController.getChessBoardView().getView());
|
||||
chessGameController.setGuiEventCallback(new ChessGameController.SimpleGUIEventCallback() {
|
||||
private void afterMove() {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGameController.getChessGame().getChessBoardAndMoveStack();
|
||||
undoButton.setDisable(chessBoardAndMoveStack.peekDone() == null);
|
||||
redoButton.setDisable(chessBoardAndMoveStack.peekUndone() == null);
|
||||
giveUpButton.setDisable(false);
|
||||
offerDrawButton.setDisable(false);
|
||||
analyzePositionButton.setDisable(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveRedone(MoveRedoneEvent event) {
|
||||
afterMove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveUndone(MoveUndoneEvent event) {
|
||||
afterMove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePerformed(MovePerformedEvent event) {
|
||||
afterMove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gameFinished(GameFinishedEvent event) {
|
||||
handleEndGame(switch (event.cause()) {
|
||||
case CHECKMATE -> colorName(event.winner()) + " поставили мат! (их победа)";
|
||||
case STALEMATE -> "Ничья (пат)!";
|
||||
case DRAW_ACCEPTED -> "Ничья подтверждена!";
|
||||
case GIVE_UP -> colorName(event.winner().reverse()) + " сдались! (" + colorName(event.winner()) + " победили)";
|
||||
case TIME_OVERSTAYED -> colorName(event.winner().reverse()) + " просрочили время! (" + colorName(event.winner()) + " победили)";
|
||||
case CANCELLED -> "Игра отменена";
|
||||
case OTHER -> "Игра завершилась по неизвестной причине! 0_О";
|
||||
});
|
||||
chessGameController.getChessBoardView().unselectAllButtons();
|
||||
chessGameController.getChessBoardView().setDisableAllButtons(true);
|
||||
analyzePositionButton.setDisable(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawOffered(DrawOfferedEvent event) {
|
||||
if (confirmationAlert(colorName(event.byColor()) + " предлагают ничью.\nПринять?")) {
|
||||
chessGameController.acceptDraw();
|
||||
} else {
|
||||
chessGameController.refuseDraw();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawRefused(DrawRefusedEvent event) {
|
||||
Util.alert(colorName(event.byColor()) + " отказались от ничьи", Alert.AlertType.WARNING);
|
||||
}
|
||||
});
|
||||
parent.setOnCloseRequest(e -> {
|
||||
if (!endGame) {
|
||||
if (chessGameController.getChessGame().getChessBoardAndMoveStack().peekDone() == null) {
|
||||
if (confirmationAlert("Отменить игру?")) {
|
||||
chessGameController.cancelGame();
|
||||
}
|
||||
} else {
|
||||
giveUp();
|
||||
}
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
|
||||
ContextMenu contextMenu = new ContextMenu();
|
||||
contextMenu.setAutoHide(false);
|
||||
CheckMenuItem reverseBoard = new CheckMenuItem("Перевернуть доску");
|
||||
chessGameController.getChessBoardView().reverseBoardProperty().bind(reverseBoard.selectedProperty());
|
||||
MenuItem closeSettings = new MenuItem("Закрыть меню");
|
||||
closeSettings.setOnAction(_ -> contextMenu.hide());
|
||||
contextMenu.getItems().setAll(reverseBoard, closeSettings);
|
||||
settingsButton.setOnAction(_ -> contextMenu.show(settingsButton, Side.BOTTOM, 0, 0));
|
||||
}
|
||||
|
||||
/// Executed when {@link #undoButton} is clicked.
|
||||
@FXML
|
||||
private void undoMove() {
|
||||
if (chessGameController.undoMove()) {
|
||||
endGame = false;
|
||||
} else {
|
||||
Util.alert("Не удалось отменить ход .__.", Alert.AlertType.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed when {@link #redoButton} is clicked.
|
||||
@FXML
|
||||
private void redoMove() {
|
||||
if (chessGameController.redoMove()) {
|
||||
endGame = chessGameController.getChessGame().getChessBoardAndMoveStack().peekUndone() == null && wasEndGame;
|
||||
} else {
|
||||
Util.alert("Не удалось вернуть ход .__.", Alert.AlertType.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canPerformAction() {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = chessGameController.getChessGame().getChessBoardAndMoveStack();
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
|
||||
if (chessGameController.getMyColor() == null) {
|
||||
// For local games (with possibly engines), determine if the current player is human
|
||||
boolean isWhiteEngine = chessGameController.getChessEngineService().hasWhiteEngine();
|
||||
boolean isBlackEngine = chessGameController.getChessEngineService().hasBlackEngine();
|
||||
|
||||
// If both sides have engines, no human can make moves
|
||||
if (isWhiteEngine && isBlackEngine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If only one side has an engine, the other side (human) can move on their turn
|
||||
if (isBlackEngine) {
|
||||
return currentlyMoving == PieceColor.WHITE;
|
||||
}
|
||||
if (isWhiteEngine) {
|
||||
return currentlyMoving == PieceColor.BLACK;
|
||||
}
|
||||
// If no engines are present, it's a same PC two-player game
|
||||
return true;
|
||||
} else {
|
||||
// For online games, check if it's the player's turn
|
||||
return chessGameController.getMyColor() == currentlyMoving;
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed when {@link #offerDrawButton} is clicked.
|
||||
@FXML
|
||||
private void offerDraw() {
|
||||
if (canPerformAction() && confirmationAlert("Точно предложить ничью?")) {
|
||||
chessGameController.offerDraw();
|
||||
Util.alert("Предложение отправлено!", Alert.AlertType.INFORMATION);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed when {@link #giveUpButton} is clicked.
|
||||
@FXML
|
||||
private void giveUp() {
|
||||
if (canPerformAction() && confirmationAlert("Точно сдаться?")) {
|
||||
chessGameController.giveUp();
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed when {@link #analyzePositionButton} is clicked.
|
||||
@FXML
|
||||
private void analyzePosition() {
|
||||
executorService.submit(() -> {
|
||||
try (ChessEngine analyzer = chessEngineProvider.getObject(20)) {
|
||||
// TODO allow customization
|
||||
analyzer.setOption("Threads", 32)
|
||||
.thenCompose(_ -> analyzer.setOption("Hash", 4096))
|
||||
.thenCompose(_ -> {
|
||||
log.info("Performing analysis...");
|
||||
return analyzer.analyzePositionAsync(Duration.ofSeconds(15));
|
||||
})
|
||||
.thenAccept(analysis -> log.info("Analysis: {}", analysis))
|
||||
.exceptionally(ex -> {
|
||||
log.error("Could not analyze!", ex instanceof CompletionException ? ex.getCause() : ex);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean confirmationAlert(String text) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text, ButtonType.YES, ButtonType.NO);
|
||||
return alert.showAndWait().filter(value -> value == ButtonType.YES).isPresent();
|
||||
}
|
||||
|
||||
private static String colorName(PieceColor pieceColor) {
|
||||
return switch (pieceColor) {
|
||||
case WHITE -> "Белые";
|
||||
case BLACK -> "Чёрные";
|
||||
};
|
||||
}
|
||||
|
||||
private void handleEndGame(String text) {
|
||||
undoButton.setDisable(true);
|
||||
redoButton.setDisable(true);
|
||||
giveUpButton.setDisable(true);
|
||||
offerDrawButton.setDisable(true);
|
||||
endGame = wasEndGame = true;
|
||||
chessGameController.close();
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION, text, ButtonType.OK, ButtonType.CLOSE);
|
||||
if (alert.showAndWait().filter(value -> value == ButtonType.CLOSE).isPresent()) {
|
||||
parent.hide();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package ru.fokinatorr.chess.gui;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.stage.Stage;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.rgielen.fxweaver.core.FxControllerAndView;
|
||||
import net.rgielen.fxweaver.core.FxWeaver;
|
||||
import net.rgielen.fxweaver.core.FxmlView;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import ru.fokinatorr.chess.Main;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.engine.service.ChessEngineService;
|
||||
import ru.fokinatorr.chess.engine.service.ChessEngineServiceFactory;
|
||||
import ru.fokinatorr.chess.game.ChessGameController;
|
||||
import ru.fokinatorr.chess.game.ChessGameServiceFactory;
|
||||
import ru.fokinatorr.chess.gui.misc.CredentialsInputDialog;
|
||||
import ru.fokinatorr.chess.impl.ChessBoard;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.misc.ChessEngineSettings;
|
||||
import ru.fokinatorr.chess.misc.Pair;
|
||||
import ru.fokinatorr.chess.security.HttpBasicUserCredentialsFactory;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static javafx.scene.control.ButtonType.CANCEL;
|
||||
import static javafx.scene.control.ButtonType.OK;
|
||||
|
||||
/// The main controller of them all (the main stage controller).
|
||||
@Slf4j
|
||||
@Controller
|
||||
@FxmlView("main.fxml")
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class MainController {
|
||||
// TODO: add online game options
|
||||
|
||||
private static final ButtonType RETRY = new ButtonType("Повторить", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
|
||||
FxWeaver fxWeaver;
|
||||
// "Choose wisely who goes first" - the teacher said. The students replied:
|
||||
Random random; // Yeah, we're going to choose wisely!
|
||||
DTOService dtoService;
|
||||
ChessGameServiceFactory chessGameServiceFactory;
|
||||
ChessEngineServiceFactory chessEngineServiceFactory;
|
||||
ObjectProvider<ChessEngine> chessEngineProvider;
|
||||
ExecutorService executorService;
|
||||
WebClient http;
|
||||
HttpBasicUserCredentialsFactory userCredentialsFactory;
|
||||
|
||||
@FXML
|
||||
private void humanVSHuman() {
|
||||
ChessBoardAndMoveStack board = ChessBoardAndMoveStack.startPosition(); // share the same instance since it's mutable
|
||||
showGameStage(
|
||||
new ChessGameController(
|
||||
board,
|
||||
chessGameServiceFactory.offline(board, ChessBoard.startPosition()),
|
||||
chessEngineServiceFactory.none(),
|
||||
dtoService,
|
||||
executorService
|
||||
),
|
||||
"Игра с другом :)"
|
||||
);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void humanVSComputer() {
|
||||
Dialog<ChessEngineSettings> dialog = new Dialog<>();
|
||||
FxControllerAndView<SingleEngineSettingsController, ?> singleEngineSettings = fxWeaver.load(SingleEngineSettingsController.class);
|
||||
dialog.getDialogPane().getButtonTypes().setAll(ButtonType.OK, CANCEL);
|
||||
dialog.getDialogPane().setContent(singleEngineSettings.getView().orElseThrow(() -> new InternalError("Engine settings view not present")));
|
||||
dialog.setResultConverter(button -> {
|
||||
ButtonBar.ButtonData buttonData = button == null ? null : button.getButtonData();
|
||||
return buttonData == ButtonBar.ButtonData.OK_DONE ? singleEngineSettings.getController().getResult() : null;
|
||||
});
|
||||
dialog.setTitle("Настройки движка");
|
||||
dialog.showAndWait().ifPresent(chessEngineSettings -> {
|
||||
ChessBoardAndMoveStack board = ChessBoardAndMoveStack.startPosition(); // share the same instance since it's mutable
|
||||
showGameStage(
|
||||
new ChessGameController(
|
||||
board,
|
||||
chessGameServiceFactory.offline(board, ChessBoard.startPosition()),
|
||||
createEngineService(chessEngineSettings),
|
||||
dtoService,
|
||||
executorService),
|
||||
"Игра с компом"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void computerVSComputer() {
|
||||
Dialog<Pair<Integer, Integer>> dialog = new Dialog<>();
|
||||
FxControllerAndView<DoubleEngineSettingsController, ?> doubleEngineSettings = fxWeaver.load(DoubleEngineSettingsController.class);
|
||||
dialog.getDialogPane().getButtonTypes().setAll(ButtonType.OK, CANCEL);
|
||||
dialog.getDialogPane().setContent(doubleEngineSettings.getView().orElseThrow(() -> new InternalError("Engine settings view not present")));
|
||||
dialog.setResultConverter(button -> {
|
||||
ButtonBar.ButtonData buttonData = button == null ? null : button.getButtonData();
|
||||
return buttonData == ButtonBar.ButtonData.OK_DONE ? doubleEngineSettings.getController().getResult() : null;
|
||||
});
|
||||
dialog.setTitle("Настройки движков");
|
||||
dialog.showAndWait().ifPresent(chessEngineSettingsPair -> {
|
||||
ChessEngine whiteEngine = chessEngineProvider.getObject(chessEngineSettingsPair.first());
|
||||
ChessEngine blackEngine = chessEngineProvider.getObject(chessEngineSettingsPair.second());
|
||||
ChessBoardAndMoveStack board = ChessBoardAndMoveStack.startPosition(); // share the same instance since it's mutable
|
||||
showGameStage(
|
||||
new ChessGameController(
|
||||
board,
|
||||
chessGameServiceFactory.offline(board, ChessBoard.startPosition()),
|
||||
chessEngineServiceFactory.both(whiteEngine, blackEngine),
|
||||
dtoService,
|
||||
executorService
|
||||
),
|
||||
"Комп против компа"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private ChessEngineService createEngineService(ChessEngineSettings settings) {
|
||||
ChessEngine engine = chessEngineProvider.getObject(settings.skillLevel());
|
||||
return switch (settings.color().getActualPieceColor(random)) {
|
||||
case WHITE -> chessEngineServiceFactory.white(engine);
|
||||
case BLACK -> chessEngineServiceFactory.black(engine);
|
||||
};
|
||||
}
|
||||
|
||||
private void maybeInitCredentials() {
|
||||
if (!userCredentialsFactory.isInitialized()) {
|
||||
Scheduler scheduler = Schedulers.fromExecutorService(executorService);
|
||||
CredentialsInputDialog dialog = new CredentialsInputDialog();
|
||||
dialog.setHeaderText("Скажи пароль");
|
||||
dialog.setContentText("и имя пользователя :)");
|
||||
Pair<String, char[]> rawCredentials = dialog.showAndWait().orElse(null);
|
||||
if (rawCredentials == null) return;
|
||||
// check validity
|
||||
http.post()
|
||||
.uri("/login?username={username}&password={password}", rawCredentials.first(), new String(rawCredentials.second()))
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.retrieve()
|
||||
.bodyToMono(Long.class) // the user ID
|
||||
.subscribeOn(scheduler)
|
||||
.subscribe(
|
||||
_ -> {
|
||||
userCredentialsFactory.initialize(rawCredentials.first(), makePasswordAndClear(rawCredentials.second()));
|
||||
Platform.runLater(() -> new Alert(
|
||||
Alert.AlertType.INFORMATION,
|
||||
"Бобро... ой добро пожаловать, " + rawCredentials.first() + "!",
|
||||
OK
|
||||
).showAndWait());
|
||||
},
|
||||
e -> Platform.runLater(() -> {
|
||||
Alert alert;
|
||||
if (e instanceof UsernameNotFoundException) {
|
||||
alert = new Alert(
|
||||
Alert.AlertType.ERROR,
|
||||
"Это кто вообще не знаю чела с таким именем пользователя (:",
|
||||
RETRY, CANCEL
|
||||
);
|
||||
} else if (e instanceof BadCredentialsException) {
|
||||
alert = new Alert(
|
||||
Alert.AlertType.ERROR,
|
||||
"Чет пароль не подошел",
|
||||
RETRY, CANCEL
|
||||
);
|
||||
} else {
|
||||
log.info("Exception on login attempt", e);
|
||||
return;
|
||||
}
|
||||
if (alert.showAndWait()
|
||||
.filter(buttonType -> buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD)
|
||||
.isPresent()) {
|
||||
maybeInitCredentials();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static String makePasswordAndClear(char... passwordChars) {
|
||||
String result = new String(passwordChars);
|
||||
Arrays.fill(passwordChars, '●'); // bullet symbol lol ^u^
|
||||
return result;
|
||||
}
|
||||
|
||||
private void showGameStage(ChessGameController chessGameController, String title) {
|
||||
Stage stage = new Stage();
|
||||
FxControllerAndView<GameFXController, ? extends Parent> fxControllerAndView = fxWeaver.load(GameFXController.class);
|
||||
fxControllerAndView.getController().init(chessGameController, stage);
|
||||
stage.setTitle(title);
|
||||
stage.setScene(new Scene(fxControllerAndView.getView().orElseThrow(() -> new InternalError("Game view not present")), 800, 600));
|
||||
stage.initOwner(Main.getPrimaryStage());
|
||||
stage.sizeToScene();
|
||||
stage.setResizable(false);
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.fokinatorr.chess.gui;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.RadioButton;
|
||||
import javafx.scene.control.Slider;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.rgielen.fxweaver.core.FxmlView;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.PieceColorOrRandom;
|
||||
import ru.fokinatorr.chess.misc.ChessEngineSettings;
|
||||
import ru.fokinatorr.chess.misc.ChessEngineSettings.ChessEngineSettingsBuilder;
|
||||
|
||||
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
|
||||
|
||||
@Controller
|
||||
@FxmlView("single_engine_settings.fxml")
|
||||
@Scope(SCOPE_PROTOTYPE)
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class SingleEngineSettingsController {
|
||||
|
||||
@FXML
|
||||
Slider skillLevelView;
|
||||
@FXML
|
||||
RadioButton asBlackView;
|
||||
@FXML
|
||||
RadioButton asWhiteView;
|
||||
@FXML
|
||||
RadioButton asRandomView;
|
||||
|
||||
/// Build the [ChessEngineSettings] by the current view properties.
|
||||
public ChessEngineSettings getResult() {
|
||||
ChessEngineSettingsBuilder chessEngineSettingsBuilder = ChessEngineSettings.builder().skillLevel((int) skillLevelView.getValue());
|
||||
if (asBlackView.isSelected()) {
|
||||
chessEngineSettingsBuilder.color(PieceColorOrRandom.BLACK);
|
||||
} else if (asWhiteView.isSelected()) {
|
||||
chessEngineSettingsBuilder.color(PieceColorOrRandom.WHITE);
|
||||
} else if (asRandomView.isSelected()) {
|
||||
chessEngineSettingsBuilder.color(PieceColorOrRandom.RANDOM);
|
||||
}
|
||||
return chessEngineSettingsBuilder.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.fokinatorr.chess.gui;
|
||||
|
||||
import javafx.scene.control.Alert;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
|
||||
/// Commonly used GUI utilities.
|
||||
@UtilityClass
|
||||
public class Util {
|
||||
|
||||
/// Shows an alert of the given type with the given text and wait for user to close it.
|
||||
///
|
||||
/// **NOTE**: This method must be called *only* from `JavaFX Application Thread`.
|
||||
/// @param text the alert's message
|
||||
/// @param type the alert's type (determines its icon and buttons)
|
||||
public void alert(String text, Alert.AlertType type) {
|
||||
new Alert(type, text).showAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.fokinatorr.chess.gui.engine;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.stage.FileChooser;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.fokinatorr.chess.engine.impl.stockfish.LocalStockfishManager;
|
||||
import ru.fokinatorr.chess.gui.misc.FileInputDialog;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
|
||||
|
||||
/// Displays the error messages to the user with standard [Dialog]s. Never throws anything.
|
||||
@Component
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Getter
|
||||
@Scope(SCOPE_PROTOTYPE)
|
||||
public class JavaFXLocalStockfishManager extends LocalStockfishManager {
|
||||
|
||||
final boolean usable;
|
||||
|
||||
@Autowired
|
||||
public JavaFXLocalStockfishManager(ExecutorService executorService) {
|
||||
super(doFindStockfishLocation(), executorService);
|
||||
usable = pathToExecutable != null;
|
||||
}
|
||||
|
||||
private static String doFindStockfishLocation() {
|
||||
String location = findStockfishLocation();
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
File file = null;
|
||||
FileInputDialog fileInputDialog = new FileInputDialog(System.getProperty("os.name").startsWith("Windows")
|
||||
? List.of(new FileChooser.ExtensionFilter("Executable file", "*.exe", "*.com"))
|
||||
: List.of(new FileChooser.ExtensionFilter("Executable file", "*")));
|
||||
fileInputDialog.setContentText("Stockfish не был обнаружен на вашем ПК. Пожалуйста, установите Stockfish или введите в поле ниже путь " +
|
||||
"к исполняемому файлу Stockfish. \nПри нажатии кнопки Отмена игра с компьютером будет недоступна.");
|
||||
do {
|
||||
Optional<File> optionalFile = fileInputDialog.showAndWait();
|
||||
if (optionalFile.isPresent()) {
|
||||
file = optionalFile.get();
|
||||
}
|
||||
fileInputDialog.setContentText("Извините, некорректный файл. Попробуйте ещё раз.");
|
||||
} while (file != null && !(file.exists() && file.canExecute() && !file.isDirectory()));
|
||||
return file == null ? null : file.getAbsolutePath();
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
protected void disconnectBehaviour() {
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Извините, процесс Stockfish неожиданно завершился. Придётся перезапустить игру.");
|
||||
alert.showAndWait();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.fokinatorr.chess.gui.game;
|
||||
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import ru.fokinatorr.chess.impl.Position;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// A button that represents a square on a chess board.
|
||||
@Getter
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class ChessBoardButton extends Button {
|
||||
|
||||
final Position position;
|
||||
State state;
|
||||
Move move;
|
||||
|
||||
public ChessBoardButton(Position position, ChessBoardView parent) {
|
||||
this.position = position;
|
||||
setState(State.NORMAL);
|
||||
setPrefHeight(40);
|
||||
// force square
|
||||
prefWidthProperty().bind(heightProperty());
|
||||
minWidthProperty().bind(heightProperty());
|
||||
maxWidthProperty().bind(heightProperty());
|
||||
setOnAction(e -> parent.onAction(position, e));
|
||||
}
|
||||
|
||||
/// Updates the state of this button to the given one and syncs the background.
|
||||
public void setState(State state) {
|
||||
this.state = Objects.requireNonNull(state, "state");
|
||||
setBackground((position.getColumn().getIndex() + position.getRow().getIndex()) % 2 == 0 ? state.getBlackAsBackground() : state.getWhiteAsBackground());
|
||||
if (state != State.SELECTED_POSSIBLE_MOVE) {
|
||||
move = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the move that will be performed when this button is clicked.
|
||||
/// A button can only have move if its state is {@link State#SELECTED_POSSIBLE_MOVE SELECTED_POSSIBLE_MOVE}.
|
||||
public void setMove(Move move) {
|
||||
if (state != State.SELECTED_POSSIBLE_MOVE && move != null) {
|
||||
throw new IllegalStateException("Illegal state: " + state);
|
||||
}
|
||||
this.move = move;
|
||||
}
|
||||
|
||||
/// The state of this button.
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public enum State {
|
||||
/// Not selected.
|
||||
NORMAL(Color.SADDLEBROWN.darker(), Color.LIGHTYELLOW.darker()),
|
||||
/// Selected; the piece on the button selected like this is to be moved.
|
||||
SELECTED_PIECE_TO_MOVE(Color.DARKGREEN, Color.LIGHTGREEN.deriveColor(0, 1, 0.9, 1)),
|
||||
/// Selected; if clicked, the piece selected like above is moved to position selected like this.
|
||||
SELECTED_POSSIBLE_MOVE(Color.GREEN.brighter(), Color.LIGHTGREEN.brighter());
|
||||
|
||||
Paint black, white;
|
||||
|
||||
/// Returns the black [Paint] as a [Background].
|
||||
public Background getBlackAsBackground() {
|
||||
return new Background(new BackgroundFill(black, null, null));
|
||||
}
|
||||
|
||||
/// Returns the white [Paint] as a [Background].
|
||||
public Background getWhiteAsBackground() {
|
||||
return new Background(new BackgroundFill(white, null, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package ru.fokinatorr.chess.gui.game;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ChoiceDialog;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.StringConverter;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Lombok;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import ru.fokinatorr.chess.Constants;
|
||||
import ru.fokinatorr.chess.gui.game.ChessBoardButton.State;
|
||||
import ru.fokinatorr.chess.impl.Position;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.Move;
|
||||
import ru.fokinatorr.chess.impl.piece.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/// Renders view of a chess board and move stack.
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class ChessBoardView {
|
||||
|
||||
/// The generated chess board view.
|
||||
GridPane chessBoardView;
|
||||
GridPane movesView;
|
||||
@Getter
|
||||
HBox view;
|
||||
/// The buttons to render
|
||||
final ChessBoardButton[][] buttons = new ChessBoardButton[8][8];
|
||||
/// The data
|
||||
@Getter
|
||||
final ChessBoardAndMoveStack chessBoardAndMoveStack;
|
||||
/// Whether to reverse the board (if `true` the 1st row is at the top)
|
||||
BooleanProperty reverseBoard;
|
||||
/// What to do when a button was clicked.
|
||||
ObjectProperty<BiConsumer<? super Position, ? super ActionEvent>> onAction;
|
||||
/// Whether a piece was selected previously
|
||||
@Getter
|
||||
@Setter
|
||||
boolean pieceSelected;
|
||||
|
||||
public BooleanProperty reverseBoardProperty() {
|
||||
if (reverseBoard == null) {
|
||||
reverseBoard = new SimpleBooleanProperty(this, "reverseBoard", false);
|
||||
}
|
||||
return reverseBoard;
|
||||
}
|
||||
|
||||
public boolean isReverseBoard() {
|
||||
return reverseBoard != null && reverseBoard.get();
|
||||
}
|
||||
|
||||
public void setReverseBoard(boolean reverseBoard) {
|
||||
reverseBoardProperty().set(reverseBoard);
|
||||
}
|
||||
|
||||
public ObjectProperty<BiConsumer<? super Position, ? super ActionEvent>> onActionProperty() {
|
||||
if (onAction == null) {
|
||||
onAction = new SimpleObjectProperty<>(this, "onAction");
|
||||
}
|
||||
return onAction;
|
||||
}
|
||||
|
||||
public BiConsumer<? super Position, ? super ActionEvent> getOnAction() {
|
||||
return onAction == null ? null : onAction.get();
|
||||
}
|
||||
|
||||
public void setOnAction(BiConsumer<? super Position, ? super ActionEvent> onAction) {
|
||||
onActionProperty().set(onAction);
|
||||
}
|
||||
|
||||
/// Constructs a `ChessBoardView`.
|
||||
public ChessBoardView(ChessBoardAndMoveStack chessBoardAndMoveStack) {
|
||||
this.chessBoardAndMoveStack = chessBoardAndMoveStack;
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j] = new ChessBoardButton(Position.valueOf(i, j), this);
|
||||
}
|
||||
}
|
||||
chessBoardAndMoveStack.setPawnPromoter(color -> {
|
||||
ChoiceDialog<Piece> choiceDialog = new ChoiceDialog<>(null,
|
||||
Set.of(new Queen(color), new Rook(color), new Bishop(color), new Knight(color)));
|
||||
choiceDialog.setContentText("Select a piece to promote to.");
|
||||
//noinspection unchecked
|
||||
((ComboBox<Piece>) choiceDialog.getDialogPane().getContent().lookup(".combo-box")).setConverter(new StringConverter<>() {
|
||||
@Override
|
||||
public String toString(Piece object) {
|
||||
return object.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Piece fromString(String string) {
|
||||
return switch (string) {
|
||||
case "Queen" -> new Queen(color);
|
||||
case "Rook" -> new Rook(color);
|
||||
case "Bishop" -> new Bishop(color);
|
||||
case "Knight" -> new Knight(color);
|
||||
default -> throw new AssertionError("Unexpected value: " + string);
|
||||
};
|
||||
}
|
||||
});
|
||||
return choiceDialog.showAndWait().orElse(new Queen(color));
|
||||
});
|
||||
reverseBoardProperty().addListener(_ -> updateView());
|
||||
updateView();
|
||||
}
|
||||
|
||||
/// What to do when a button was clicked.
|
||||
public void onAction(Position position, ActionEvent e) {
|
||||
BiConsumer<? super Position, ? super ActionEvent> onAction = getOnAction();
|
||||
if (onAction != null) {
|
||||
onAction.accept(position, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a button at the given position.
|
||||
public ChessBoardButton getButton(Position pos) {
|
||||
return buttons[pos.getColumn().getIndex()][pos.getRow().getIndex()];
|
||||
}
|
||||
|
||||
/// Set all buttons' state to {@link State#NORMAL}.
|
||||
public void unselectAllButtons() {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j].setState(ChessBoardButton.State.NORMAL);
|
||||
}
|
||||
}
|
||||
pieceSelected = false;
|
||||
}
|
||||
|
||||
/// Enable/disable clicking on chess board buttons.
|
||||
public void setDisableAllButtons(boolean disable) {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j].setDisable(disable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the view update so that it would reflect the chess board.
|
||||
public synchronized void updateView() {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Runnable r = () -> {
|
||||
if (chessBoardView == null) {
|
||||
chessBoardView = new GridPane();
|
||||
}
|
||||
for (Node child : chessBoardView.getChildren()) {
|
||||
GridPane.clearConstraints(child);
|
||||
}
|
||||
chessBoardView.getChildren().clear();
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
Label column = new Label(String.valueOf(Position.Column.valueOf(i - 1).getCharacter())),
|
||||
row = new Label(String.valueOf(isReverseBoard() ? i : 9 - i));
|
||||
GridPane.setHalignment(column, HPos.CENTER);
|
||||
GridPane.setValignment(row, VPos.CENTER);
|
||||
chessBoardView.add(column, i, 0);
|
||||
chessBoardView.add(row, 0, i);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
ChessBoardButton chessBoardButton = buttons[i - 1][j];
|
||||
chessBoardButton.setGraphic(graphicFor(chessBoardAndMoveStack.getPiece(chessBoardButton.getPosition())));
|
||||
chessBoardView.add(chessBoardButton, i, isReverseBoard() ? j + 1 : 8 - j);
|
||||
}
|
||||
}
|
||||
if (movesView == null) {
|
||||
movesView = new GridPane(10, 0);
|
||||
}
|
||||
for (Node child : movesView.getChildren()) {
|
||||
GridPane.clearConstraints(child);
|
||||
}
|
||||
movesView.getChildren().clear();
|
||||
List<Move> doneMoves = new ArrayList<>(chessBoardAndMoveStack.getMoveStack().getDoneMoves());
|
||||
for (int i = 0; i < doneMoves.size(); i++) {
|
||||
if (i % 2 == 0) {
|
||||
movesView.add(graphicForMoveString((i / 2 + 1) + "."), 0, i / 2);
|
||||
}
|
||||
movesView.add(graphicFor(doneMoves.get(i)), i % 2 + 1, i / 2);
|
||||
}
|
||||
if (view == null) {
|
||||
view = new HBox(10);
|
||||
}
|
||||
ScrollPane movesScroll = new ScrollPane(movesView);
|
||||
movesScroll.setMinWidth(200);
|
||||
view.getChildren().setAll(chessBoardView, movesScroll);
|
||||
latch.countDown();
|
||||
};
|
||||
if (Thread.currentThread().getName().equals("JavaFX Application Thread")) {
|
||||
r.run();
|
||||
} else {
|
||||
Platform.runLater(r);
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw Lombok.sneakyThrow(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Node graphicFor(Piece piece) {
|
||||
if (piece == null) return null;
|
||||
return new ImageView(Constants.getPieceImage(piece.getColor(), piece.getClass()));
|
||||
}
|
||||
|
||||
private final Map<Move, ChessBoardAndMoveStack> cachedPositions = new HashMap<>();
|
||||
|
||||
private Node graphicFor(Move move) {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = cachedPositions.computeIfAbsent(move, _ -> this.chessBoardAndMoveStack.clone());
|
||||
return graphicForMoveString(move.getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack()));
|
||||
}
|
||||
|
||||
private static Node graphicForMoveString(String moveNotation) {
|
||||
Text text = new Text(moveNotation);
|
||||
text.setFill(Color.BLACK);
|
||||
text.setFont(Font.font("Times New Roman Italic", 15));
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package ru.fokinatorr.chess.gui.game;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.StringConverter;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Lombok;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import ru.fokinatorr.chess.Constants;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.game.GameEventSubscriber;
|
||||
import ru.fokinatorr.chess.impl.*;
|
||||
import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack;
|
||||
import ru.fokinatorr.chess.impl.movestack.*;
|
||||
import ru.fokinatorr.chess.impl.piece.*;
|
||||
import ru.fokinatorr.chess.misc.ChessServerProps;
|
||||
import ru.fokinatorr.chess.server.game.event.GameEvent;
|
||||
import ru.fokinatorr.chess.server.game.event.GameFinishedEvent;
|
||||
import ru.fokinatorr.chess.server.game.event.MovePerformedEvent;
|
||||
import ru.fokinatorr.chess.server.service.DTOService;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/// This class used to be the controller of a view of a chess board. As you can see,
|
||||
/// it looks like a "god" class with a lot of responsibilities. Its functionality
|
||||
/// was split into different components
|
||||
/// (see packages
|
||||
/// [ru.fokinatorr.chess.game]
|
||||
/// [ru.fokinatorr.chess.security]
|
||||
/// and the class [ru.fokinatorr.chess.gui.game.ChessBoardView]),
|
||||
/// making this class redundant. I'm keeping it as an example violation of SRP.
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Slf4j
|
||||
public class ChessBoardViewController {
|
||||
|
||||
private static final Duration DEFAULT_ENGINE_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
/// The buttons to render
|
||||
final ChessBoardButton[][] buttons = new ChessBoardButton[8][8];
|
||||
/// The data
|
||||
@Getter
|
||||
final ChessBoardAndMoveStack chessBoardAndMoveStack;
|
||||
final ChessBoard initialBoard;
|
||||
final DTOService dtoService;
|
||||
final OnlineGameObjects onlineGameObjects;
|
||||
|
||||
final Supplier<? extends Publisher<? extends GameEvent>> eventBroadcasterFactory;
|
||||
final Sinks.Many<GameEvent> selfSink;
|
||||
GameEventSubscriber gameEventSubscriber;
|
||||
/// Whether a piece was selected previously
|
||||
boolean pieceSelected;
|
||||
/// Whether to reverse the board (if `true` the 1st row is at the top)
|
||||
BooleanProperty reverseBoard;
|
||||
/// What to execute after a move was performed/undone/redone.
|
||||
ObjectProperty<Runnable> onAfterMove;
|
||||
/// What to execute when the game has finished.
|
||||
ObjectProperty<Consumer<? super GameFinishedEvent>> onGameFinish;
|
||||
@Getter
|
||||
final ChessEngine whiteEngine;
|
||||
@Getter
|
||||
final ChessEngine blackEngine;
|
||||
/// The generated chess board view.
|
||||
GridPane chessBoardView;
|
||||
GridPane movesView;
|
||||
@Getter
|
||||
HBox view;
|
||||
boolean isGame = true;
|
||||
|
||||
/// Constructor for local (offline) chess game.
|
||||
public ChessBoardViewController(ChessBoardAndMoveStack chessBoardAndMoveStack,
|
||||
DTOService dtoService,
|
||||
@Nullable ChessEngine whiteEngine, @Nullable ChessEngine blackEngine) {
|
||||
this.chessBoardAndMoveStack = chessBoardAndMoveStack;
|
||||
this.initialBoard = chessBoardAndMoveStack.getChessBoard().clone();
|
||||
this.dtoService = dtoService;
|
||||
this.onlineGameObjects = null;
|
||||
this.whiteEngine = whiteEngine;
|
||||
this.blackEngine = blackEngine;
|
||||
this.selfSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
this.eventBroadcasterFactory = selfSink::asFlux;
|
||||
init();
|
||||
}
|
||||
|
||||
/// Constructor for online chess game.
|
||||
public ChessBoardViewController(UUID gameId,
|
||||
RSocketRequester.Builder builder, ChessServerProps chessServerProps,
|
||||
WebClient webClient,
|
||||
ExecutorService executorService,
|
||||
DTOService dtoService) {
|
||||
this.chessBoardAndMoveStack = new ChessBoardAndMoveStack();
|
||||
this.initialBoard = new ChessBoard();
|
||||
this.dtoService = dtoService;
|
||||
this.onlineGameObjects = new OnlineGameObjects(gameId, builder, chessServerProps, webClient, executorService);
|
||||
this.whiteEngine = null;
|
||||
this.blackEngine = null;
|
||||
this.selfSink = null;
|
||||
this.eventBroadcasterFactory = () -> {
|
||||
RSocketRequester requester = onlineGameObjects.createRSocket();
|
||||
return requester.route("game.join")
|
||||
.data(onlineGameObjects.gameId)
|
||||
.retrieveFlux(GameEvent.class)
|
||||
.doOnTerminate(requester::dispose)
|
||||
.doOnCancel(requester::dispose);
|
||||
};
|
||||
init();
|
||||
}
|
||||
|
||||
private record OnlineGameObjects(UUID gameId, RSocketRequester.Builder rsocket, ChessServerProps chessServerProps, WebClient http, ExecutorService executorService) {
|
||||
|
||||
RSocketRequester createRSocket() {
|
||||
return rsocket.websocket(URI.create(chessServerProps.getRSocketBasePath()));
|
||||
}
|
||||
}
|
||||
|
||||
private void init() {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j] = new ChessBoardButton(Position.valueOf(i, j), this);
|
||||
}
|
||||
}
|
||||
chessBoardAndMoveStack.setPawnPromoter(color -> {
|
||||
ChoiceDialog<Piece> choiceDialog = new ChoiceDialog<>(null,
|
||||
Set.of(new Queen(color), new Rook(color), new Bishop(color), new Knight(color)));
|
||||
choiceDialog.setContentText("Select a piece to promote to.");
|
||||
//noinspection unchecked
|
||||
((ComboBox<Piece>) choiceDialog.getDialogPane().getContent().lookup(".combo-box")).setConverter(new StringConverter<>() {
|
||||
@Override
|
||||
public String toString(Piece object) {
|
||||
return object.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Piece fromString(String string) {
|
||||
return switch (string) {
|
||||
case "Queen" -> new Queen(color);
|
||||
case "Rook" -> new Rook(color);
|
||||
case "Bishop" -> new Bishop(color);
|
||||
case "Knight" -> new Knight(color);
|
||||
default -> throw new AssertionError("Unexpected value: " + string);
|
||||
};
|
||||
}
|
||||
});
|
||||
return choiceDialog.showAndWait().orElse(new Queen(color));
|
||||
});
|
||||
reverseBoardProperty().addListener(_ -> updateView());
|
||||
updateView();
|
||||
}
|
||||
|
||||
|
||||
public BooleanProperty reverseBoardProperty() {
|
||||
if (reverseBoard == null) {
|
||||
reverseBoard = new SimpleBooleanProperty(this, "reverseBoard", false);
|
||||
}
|
||||
return reverseBoard;
|
||||
}
|
||||
|
||||
public boolean isReverseBoard() {
|
||||
return reverseBoard != null && reverseBoard.get();
|
||||
}
|
||||
|
||||
public void setReverseBoard(boolean reverseBoard) {
|
||||
reverseBoardProperty().set(reverseBoard);
|
||||
}
|
||||
|
||||
public ObjectProperty<Runnable> onAfterMoveProperty() {
|
||||
if (onAfterMove == null) {
|
||||
onAfterMove = new SimpleObjectProperty<>(this, "onAfterMove");
|
||||
}
|
||||
return onAfterMove;
|
||||
}
|
||||
|
||||
public Runnable getOnAfterMove() {
|
||||
return onAfterMove == null ? null : onAfterMove.get();
|
||||
}
|
||||
|
||||
public void setOnAfterMove(Runnable onAfterMove) {
|
||||
onAfterMoveProperty().set(onAfterMove);
|
||||
}
|
||||
|
||||
public ObjectProperty<Consumer<? super GameFinishedEvent>> onGameFinishProperty() {
|
||||
if (onGameFinish == null) {
|
||||
onGameFinish = new SimpleObjectProperty<>(this, "onGameFinish");
|
||||
}
|
||||
return onGameFinish;
|
||||
}
|
||||
|
||||
public Consumer<? super GameFinishedEvent> getOnGameFinish() {
|
||||
return onGameFinish == null ? null : onGameFinish.get();
|
||||
}
|
||||
|
||||
public void setOnGameFinish(Consumer<? super GameFinishedEvent> onGameFinish) {
|
||||
onGameFinishProperty().set(onGameFinish);
|
||||
}
|
||||
|
||||
/// Called when the given position on this board is clicked.
|
||||
/// @param position the position
|
||||
/// @param e the JavaFX {@link ActionEvent} representing the action, i.e. click on the button
|
||||
public void onAction(Position position, ActionEvent e) {
|
||||
if (chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() == PieceColor.WHITE && whiteEngine != null
|
||||
|| chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() == PieceColor.BLACK && blackEngine != null) {
|
||||
return;
|
||||
}
|
||||
switch (button(position).getState()) {
|
||||
case NORMAL -> {
|
||||
if (pieceSelected) {
|
||||
unselectAllButtons();
|
||||
} else {
|
||||
Piece piece = chessBoardAndMoveStack.getPiece(position);
|
||||
if (piece != null && piece.getColor() == chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving()) {
|
||||
button(position).setState(ChessBoardButton.State.SELECTED_PIECE_TO_MOVE);
|
||||
for (MoveEntry moveEntry : chessBoardAndMoveStack.getMoves(position)) {
|
||||
button(moveEntry.destPos()).setState(ChessBoardButton.State.SELECTED_POSSIBLE_MOVE);
|
||||
button(moveEntry.destPos()).setMove(moveEntry.move());
|
||||
}
|
||||
pieceSelected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
case SELECTED_PIECE_TO_MOVE -> unselectAllButtons();
|
||||
case SELECTED_POSSIBLE_MOVE -> sendMove(button(position).getMove());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private synchronized void sendMove(Move move) {
|
||||
if (onlineGameObjects != null) {
|
||||
onlineGameObjects.http.put()
|
||||
.uri("/game/move/perform?gameId={gameId}&move={move}", onlineGameObjects.gameId, move.toUCI())
|
||||
.header("Authorization", authorize("", "")) // TODO_
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
.subscribeOn(Schedulers.fromExecutorService(onlineGameObjects.executorService))
|
||||
.doOnError(t -> Platform.runLater(new Alert(Alert.AlertType.ERROR, "Could not perform move:\n" + t)::showAndWait))
|
||||
.subscribe();
|
||||
} else {
|
||||
ChessBoardAndMoveStack fallback = chessBoardAndMoveStack.clone();
|
||||
fallback.perform(move);
|
||||
selfSink.emitNext(new MovePerformedEvent(move.toUCI(), dtoService.toDTO(fallback, initialBoard)), Sinks.EmitFailureHandler.FAIL_FAST);
|
||||
}
|
||||
}
|
||||
|
||||
private String authorize(String username, String password) {
|
||||
String authorization = username + ":" + password;
|
||||
return "basic " + Base64.getEncoder().encodeToString(authorization.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/// Undo a move on the board.
|
||||
public synchronized void undoMove() {
|
||||
if (whiteEngine == null && blackEngine == null) {
|
||||
if (chessBoardAndMoveStack.undo() != null) {
|
||||
afterMove(false, false);
|
||||
setDisableAllButtons(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
PieceColor pieceColor;
|
||||
if (whiteEngine == null) {
|
||||
pieceColor = PieceColor.WHITE;
|
||||
} else if (blackEngine == null) {
|
||||
pieceColor = PieceColor.BLACK;
|
||||
} else {
|
||||
return; // No undoing in engine vs engine
|
||||
}
|
||||
do {
|
||||
if (chessBoardAndMoveStack.undo() != null) {
|
||||
afterMove(false, false);
|
||||
setDisableAllButtons(false);
|
||||
}
|
||||
} while (chessBoardAndMoveStack.peekDone() != null && chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() != pieceColor);
|
||||
}
|
||||
|
||||
/// Redo a move on the board.
|
||||
public synchronized void redoMove() {
|
||||
if (whiteEngine == null && blackEngine == null) {
|
||||
if (chessBoardAndMoveStack.redo() != null) {
|
||||
afterMove(true, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
PieceColor pieceColor;
|
||||
if (whiteEngine == null) {
|
||||
pieceColor = PieceColor.WHITE;
|
||||
} else if (blackEngine == null) {
|
||||
pieceColor = PieceColor.BLACK;
|
||||
} else {
|
||||
return; // No redoing in engine vs engine
|
||||
}
|
||||
do {
|
||||
if (chessBoardAndMoveStack.redo() != null) {
|
||||
afterMove(true, false);
|
||||
}
|
||||
} while (chessBoardAndMoveStack.peekUndone() != null && chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() != pieceColor);
|
||||
}
|
||||
|
||||
private ChessBoardButton button(Position pos) {
|
||||
return buttons[pos.getColumn().getIndex()][pos.getRow().getIndex()];
|
||||
}
|
||||
|
||||
private void unselectAllButtons() {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j].setState(ChessBoardButton.State.NORMAL);
|
||||
}
|
||||
}
|
||||
pieceSelected = false;
|
||||
}
|
||||
|
||||
private void setDisableAllButtons(boolean disable) {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int j = 0; j < 8; j++) {
|
||||
buttons[i][j].setDisable(disable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed after a move was done or undone.
|
||||
private synchronized void afterMove(boolean playSound, boolean doEngineMove) {
|
||||
unselectAllButtons();
|
||||
updateView();
|
||||
chessBoardAndMoveStack.reverseCurrentlyMoving();
|
||||
if (playSound) {
|
||||
PieceColor currentlyMoving = chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving();
|
||||
Move lastMove = chessBoardAndMoveStack.peekDone();
|
||||
|
||||
if (chessBoardAndMoveStack.isCheckmate(currentlyMoving)) {
|
||||
Constants.CHECKMATE_SOUND.play();
|
||||
} else if (chessBoardAndMoveStack.isCheck(currentlyMoving)) {
|
||||
Constants.CHECK_SOUND.play();
|
||||
} else if (lastMove instanceof PawnPromotionMove) {
|
||||
Constants.PROMOTION_SOUND.play();
|
||||
} else if (lastMove instanceof EnPassantMove
|
||||
|| (lastMove instanceof SimpleMove simpleMove && simpleMove.getCapturedPiece() != null)) {
|
||||
Constants.CAPTURE_SOUND.play();
|
||||
} else if (lastMove instanceof CastlingMove) {
|
||||
Constants.CASTLING_SOUND.play();
|
||||
} else if (!(lastMove instanceof NoOpMove)) {
|
||||
Constants.MOVE_SOUND.play();
|
||||
}
|
||||
}
|
||||
if (isGame && doEngineMove) {
|
||||
if (chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() == PieceColor.WHITE && whiteEngine != null) {
|
||||
whiteEngine.positionUpdated()
|
||||
.thenCompose(_ -> whiteEngine.findBestMoveAsync(DEFAULT_ENGINE_TIMEOUT))
|
||||
.thenAccept(this::sendMove)
|
||||
.exceptionally(t -> {
|
||||
log.error("Unexpected exception", t);
|
||||
return null;
|
||||
});
|
||||
} else if (chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() == PieceColor.BLACK && blackEngine != null) {
|
||||
blackEngine.positionUpdated()
|
||||
.thenCompose(_ -> blackEngine.findBestMoveAsync(DEFAULT_ENGINE_TIMEOUT))
|
||||
.thenAccept(this::sendMove)
|
||||
.exceptionally(t -> {
|
||||
log.error("Unexpected exception", t);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
Runnable handler = getOnAfterMove();
|
||||
if (handler != null) {
|
||||
Platform.runLater(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the view update so that it would reflect the chess board.
|
||||
private synchronized void updateView() {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Runnable r = () -> {
|
||||
if (chessBoardView == null) {
|
||||
chessBoardView = new GridPane();
|
||||
}
|
||||
for (Node child : chessBoardView.getChildren()) {
|
||||
GridPane.clearConstraints(child);
|
||||
}
|
||||
chessBoardView.getChildren().clear();
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
Label column = new Label(String.valueOf(Position.Column.valueOf(i - 1).getCharacter())),
|
||||
row = new Label(String.valueOf(isReverseBoard() ? i : 9 - i));
|
||||
GridPane.setHalignment(column, HPos.CENTER);
|
||||
GridPane.setValignment(row, VPos.CENTER);
|
||||
chessBoardView.add(column, i, 0);
|
||||
chessBoardView.add(row, 0, i);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
ChessBoardButton chessBoardButton = buttons[i - 1][j];
|
||||
chessBoardButton.setGraphic(graphicFor(chessBoardAndMoveStack.getPiece(chessBoardButton.getPosition())));
|
||||
chessBoardView.add(chessBoardButton, i, isReverseBoard() ? j + 1 : 8 - j);
|
||||
}
|
||||
}
|
||||
if (movesView == null) {
|
||||
movesView = new GridPane(10, 0);
|
||||
}
|
||||
for (Node child : movesView.getChildren()) {
|
||||
GridPane.clearConstraints(child);
|
||||
}
|
||||
movesView.getChildren().clear();
|
||||
List<Move> doneMoves = new ArrayList<>(chessBoardAndMoveStack.getMoveStack().getDoneMoves());
|
||||
for (int i = 0; i < doneMoves.size(); i++) {
|
||||
if (i % 2 == 0) {
|
||||
movesView.add(graphicForMoveString((i / 2 + 1) + "."), 0, i / 2);
|
||||
}
|
||||
movesView.add(graphicFor(doneMoves.get(i)), i % 2 + 1, i / 2);
|
||||
}
|
||||
if (view == null) {
|
||||
view = new HBox(10);
|
||||
}
|
||||
ScrollPane movesScroll = new ScrollPane(movesView);
|
||||
movesScroll.setMinWidth(200);
|
||||
view.getChildren().setAll(chessBoardView, movesScroll);
|
||||
latch.countDown();
|
||||
};
|
||||
if (Thread.currentThread().getName().equals("JavaFX Application Thread")) {
|
||||
r.run();
|
||||
} else {
|
||||
Platform.runLater(r);
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw Lombok.sneakyThrow(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Node graphicFor(Piece piece) {
|
||||
if (piece == null) return null;
|
||||
return new ImageView(Constants.getPieceImage(piece.getColor(), piece.getClass()));
|
||||
}
|
||||
|
||||
private final Map<Move, ChessBoardAndMoveStack> cachedPositions = new HashMap<>();
|
||||
|
||||
private Node graphicFor(Move move) {
|
||||
ChessBoardAndMoveStack chessBoardAndMoveStack = cachedPositions.computeIfAbsent(move, _ -> this.chessBoardAndMoveStack.clone());
|
||||
return graphicForMoveString(move.getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack()));
|
||||
}
|
||||
|
||||
private static Node graphicForMoveString(String moveNotation) {
|
||||
Text text = new Text(moveNotation);
|
||||
text.setFill(Color.BLACK);
|
||||
text.setFont(Font.font("Times New Roman Italic", 15));
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Run this when the game has started.
|
||||
public void gameStart() {
|
||||
this.gameEventSubscriber = GameEventSubscriber.subscribe(this);
|
||||
if (whiteEngine != null) {
|
||||
whiteEngine.setChessBoardAndMoveStack(chessBoardAndMoveStack);
|
||||
whiteEngine.setMyColor(PieceColor.WHITE);
|
||||
whiteEngine.findBestMoveAsync(DEFAULT_ENGINE_TIMEOUT).thenAccept(this::sendMove);
|
||||
}
|
||||
if (blackEngine != null) {
|
||||
blackEngine.setChessBoardAndMoveStack(chessBoardAndMoveStack);
|
||||
blackEngine.setMyColor(PieceColor.BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
public void setEndGame() {
|
||||
isGame = false;
|
||||
gameEventSubscriber.cancel();
|
||||
gameEventSubscriber = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package ru.fokinatorr.chess.gui.misc;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import ru.fokinatorr.chess.misc.Pair;
|
||||
|
||||
/// Allows entering of credentials from GUI.
|
||||
///
|
||||
/// **Note: `char[]` for password is used to allow it being cleared manually.**
|
||||
public class CredentialsInputDialog extends Dialog<Pair<String, char[]>> {
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Fields
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
GridPane grid;
|
||||
Label label;
|
||||
TextField usernameField;
|
||||
PasswordField passwordField;
|
||||
String defaultUsername;
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Constructors
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
/// Creates a new CredentialsInputDialog without a default username entered into the
|
||||
/// dialog's username [TextField].
|
||||
public CredentialsInputDialog() {
|
||||
this("");
|
||||
}
|
||||
|
||||
/// Creates a new CredentialsInputDialog with the default username entered into the
|
||||
/// dialog's username [TextField].
|
||||
///
|
||||
/// @param defaultUsername the default username entered into the dialog
|
||||
public CredentialsInputDialog(@NamedArg("defaultValue") String defaultUsername) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
|
||||
|
||||
// -- username text field
|
||||
usernameField = new TextField(defaultUsername == null ? "" : defaultUsername);
|
||||
usernameField.setOnKeyPressed(event -> {
|
||||
if (event.getCode() == KeyCode.ENTER && usernameField.isFocused()) {
|
||||
passwordField.requestFocus();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
usernameField.setMaxWidth(Double.MAX_VALUE);
|
||||
GridPane.setHgrow(usernameField, Priority.ALWAYS);
|
||||
GridPane.setFillWidth(usernameField, true);
|
||||
|
||||
// -- password field
|
||||
passwordField = new PasswordField();
|
||||
passwordField.setMaxWidth(Double.MAX_VALUE);
|
||||
GridPane.setHgrow(passwordField, Priority.ALWAYS);
|
||||
GridPane.setFillWidth(passwordField, true);
|
||||
|
||||
// -- label
|
||||
label = new Label(dialogPane.getContentText());
|
||||
label.setMaxWidth(Double.MAX_VALUE);
|
||||
label.setMaxHeight(Double.MAX_VALUE);
|
||||
label.getStyleClass().add("content");
|
||||
label.setWrapText(true);
|
||||
label.setPrefWidth(Region.USE_COMPUTED_SIZE);
|
||||
label.textProperty().bind(dialogPane.contentTextProperty());
|
||||
|
||||
this.defaultUsername = defaultUsername;
|
||||
|
||||
grid = new GridPane();
|
||||
grid.setHgap(10);
|
||||
grid.setMaxWidth(Double.MAX_VALUE);
|
||||
grid.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
dialogPane.contentTextProperty().addListener(_ -> updateGrid());
|
||||
|
||||
setTitle("Confirmation");
|
||||
dialogPane.setHeaderText("Enter your credentials");
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
updateGrid();
|
||||
|
||||
setResultConverter(dialogButton -> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
// .toCharArray() used to allow manual password clearing
|
||||
return data == ButtonBar.ButtonData.OK_DONE && !usernameField.getText().isBlank()
|
||||
? new Pair<>(usernameField.getText(), passwordField.getText().toCharArray())
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Public API
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
/// Returns the username [TextField] used within this dialog.
|
||||
///
|
||||
/// @return the username [TextField] used within this dialog
|
||||
public final TextField getUsernameField() {
|
||||
return usernameField;
|
||||
}
|
||||
|
||||
// no password getter for security reasons ^u^
|
||||
|
||||
/// Returns the default username that was specified in the constructor.
|
||||
///
|
||||
/// @return the default username that was specified in the constructor
|
||||
public final String getDefaultUsername() {
|
||||
return defaultUsername;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Private Implementation
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
private void updateGrid() {
|
||||
grid.getChildren().clear();
|
||||
|
||||
grid.add(label, 0, 0);
|
||||
grid.add(usernameField, 1, 0);
|
||||
grid.add(passwordField, 2, 0);
|
||||
getDialogPane().setContent(grid);
|
||||
|
||||
Platform.runLater(usernameField::requestFocus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package ru.fokinatorr.chess.gui.misc;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.FileChooser;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collection;
|
||||
|
||||
/// A [Dialog] that has a [File] as a result.
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class FileInputDialog extends Dialog<File> {
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Fields
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
GridPane grid;
|
||||
Label label;
|
||||
TextField textField;
|
||||
Button browseButton;
|
||||
File defaultValue;
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Constructors
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
/// Creates a new FileInputDialog without a default value entered into the
|
||||
/// dialog [TextField].
|
||||
public FileInputDialog(Collection<FileChooser.ExtensionFilter> extensionFilters) {
|
||||
this(null, extensionFilters);
|
||||
}
|
||||
|
||||
/// Creates a new FileInputDialog with the default value entered into the
|
||||
/// dialog [TextField].
|
||||
/// @param defaultValue the default value entered into the dialog
|
||||
public FileInputDialog(@NamedArg("defaultValue") File defaultValue, Collection<FileChooser.ExtensionFilter> extensionFilters) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
|
||||
|
||||
// -- textfield
|
||||
this.textField = new TextField(defaultValue == null ? "" : defaultValue.getAbsolutePath());
|
||||
this.textField.setMaxWidth(Double.MAX_VALUE);
|
||||
GridPane.setHgrow(textField, Priority.ALWAYS);
|
||||
GridPane.setFillWidth(textField, true);
|
||||
|
||||
// -- label
|
||||
this.label = new Label(dialogPane.getContentText());
|
||||
this.label.setMaxWidth(Double.MAX_VALUE);
|
||||
this.label.setMaxHeight(Double.MAX_VALUE);
|
||||
this.label.getStyleClass().add("content");
|
||||
this.label.setWrapText(true);
|
||||
this.label.setPrefWidth(Region.USE_COMPUTED_SIZE);
|
||||
this.label.textProperty().bind(dialogPane.contentTextProperty());
|
||||
|
||||
this.defaultValue = defaultValue;
|
||||
|
||||
this.grid = new GridPane();
|
||||
this.grid.setHgap(10);
|
||||
this.grid.setMaxWidth(Double.MAX_VALUE);
|
||||
this.grid.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
this.browseButton = new Button("Browse");
|
||||
this.browseButton.setOnAction(_ -> {
|
||||
FileChooser fc = new FileChooser();
|
||||
fc.getExtensionFilters().addAll(extensionFilters);
|
||||
textField.setText(fc.showOpenDialog(getDialogPane().getScene().getWindow()).getAbsolutePath());
|
||||
});
|
||||
|
||||
dialogPane.contentTextProperty().addListener(_ -> updateGrid());
|
||||
|
||||
setTitle("Confirmation");
|
||||
dialogPane.setHeaderText("Confirmation");
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
updateGrid();
|
||||
|
||||
setResultConverter(dialogButton -> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE && !textField.getText().isBlank() ? new File(textField.getText()) : null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Public API
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
/// Returns the [TextField] used within this dialog.
|
||||
/// @return the [TextField] used within this dialog
|
||||
public final TextField getEditor() {
|
||||
return textField;
|
||||
}
|
||||
|
||||
/// Returns the default value that was specified in the constructor.
|
||||
/// @return the default value that was specified in the constructor
|
||||
public final File getDefaultValue() {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************************************
|
||||
*
|
||||
* Private Implementation
|
||||
*
|
||||
**************************************************************************/
|
||||
|
||||
private void updateGrid() {
|
||||
grid.getChildren().clear();
|
||||
|
||||
grid.add(label, 0, 0);
|
||||
grid.add(textField, 1, 0);
|
||||
grid.add(browseButton, 1, 1);
|
||||
getDialogPane().setContent(grid);
|
||||
|
||||
Platform.runLater(textField::requestFocus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.fokinatorr.chess.misc;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NonNull;
|
||||
import ru.fokinatorr.chess.engine.ChessEngine;
|
||||
import ru.fokinatorr.chess.impl.PieceColor;
|
||||
import ru.fokinatorr.chess.impl.PieceColorOrRandom;
|
||||
|
||||
/// Settings for the [ChessEngine].
|
||||
/// @param skillLevel the skill level, should not be negative
|
||||
/// @param color the color that the engine plays as
|
||||
@Builder
|
||||
public record ChessEngineSettings(int skillLevel, @NonNull PieceColorOrRandom color) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.fokinatorr.chess.misc;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/// Read properties from `application.yml` starting with `app.server.`.
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.server")
|
||||
@Data
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class ChessServerProps {
|
||||
|
||||
String host;
|
||||
String rsocketPath;
|
||||
|
||||
public String getBasePath(String protocol) {
|
||||
return protocol + "://" + host;
|
||||
}
|
||||
|
||||
public String getRSocketBasePath() {
|
||||
return getBasePath("ws") + rsocketPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.fokinatorr.chess.security;
|
||||
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/// Bearer token user credentials.
|
||||
public record AccessRefreshTokenCredentials(@NonNull String accessToken, @NonNull String refreshToken) implements UserCredentials {
|
||||
@Override
|
||||
public AuthenticationType getAuthenticationType() {
|
||||
return BearerTokenAuthenticationType.INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCredentials() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private enum BearerTokenAuthenticationType implements AuthenticationType {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public String generateAuthorizationHeader(Object credentials) {
|
||||
if (credentials instanceof String) {
|
||||
return "Bearer " + credentials;
|
||||
}
|
||||
throw new IllegalArgumentException("Unexpected credentials' type: " + credentials.getClass().getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Class<?>> getAcceptedTypes() {
|
||||
return Set.of(String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPrefix() {
|
||||
return "Bearer ";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.fokinatorr.chess.security;
|
||||
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
|
||||
/// HTTP Basic user credentials.
|
||||
public record HttpBasicCredentials(@NonNull String username, @NonNull String password) implements UserCredentials {
|
||||
@Override
|
||||
public AuthenticationType getAuthenticationType() {
|
||||
return HttpBasicAuthenticationType.INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpBasicCredentials getCredentials() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private enum HttpBasicAuthenticationType implements AuthenticationType {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public String generateAuthorizationHeader(Object credentials) {
|
||||
if (credentials instanceof HttpBasicCredentials(String username, String password)) {
|
||||
|
||||
String combined = username + ":" + password;
|
||||
byte[] encoded = Base64.getEncoder().encode(combined.getBytes(StandardCharsets.UTF_8));
|
||||
return "basic " + new String(encoded, StandardCharsets.UTF_8);
|
||||
}
|
||||
throw new IllegalArgumentException("Unexpected credentials' type: " + credentials.getClass().getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Class<?>> getAcceptedTypes() {
|
||||
return Set.of(HttpBasicCredentials.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPrefix() {
|
||||
return "basic";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.fokinatorr.chess.security;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.NonFinal;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static lombok.AccessLevel.PRIVATE;
|
||||
|
||||
/// The default credentials factory (HTTP Basic).
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = PRIVATE)
|
||||
public class HttpBasicUserCredentialsFactory implements UserCredentialsFactory {
|
||||
|
||||
@NonFinal String username;
|
||||
@NonFinal String password;
|
||||
|
||||
/// Initialize this factory with the given username and password.
|
||||
/// @param username the username
|
||||
/// @param password the password
|
||||
public void initialize(String username, String password) {
|
||||
if (this.username != null) {
|
||||
throw new IllegalStateException("Already initialized");
|
||||
}
|
||||
this.username = Objects.requireNonNull(username, "username");
|
||||
this.password = Objects.requireNonNull(password, "password");
|
||||
}
|
||||
|
||||
/// Returns whether this instance has already been initialized by a call to {@link #initialize}.
|
||||
public boolean isInitialized() {
|
||||
return username != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<UserCredentials> get() {
|
||||
return Mono.<UserCredentials>just(new HttpBasicCredentials(username, password))
|
||||
.onErrorMap(
|
||||
e -> e instanceof NullPointerException
|
||||
? new IllegalStateException("The UserCredentialsFactory hasn't been initialized!")
|
||||
: e
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<UserCredentials> apply(@NonNull UserCredentials expired) {
|
||||
return Mono.just(expired); // HTTP Basic doesn't expire
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.fokinatorr.chess.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/// A user credentials container.
|
||||
public interface UserCredentials {
|
||||
|
||||
/// Returns the authentication type.
|
||||
AuthenticationType getAuthenticationType();
|
||||
|
||||
/// Generates an `Authorization` HTTP header.
|
||||
default String generateAuthorizationHeader() {
|
||||
return getAuthenticationType().generateAuthorizationHeader(getCredentials());
|
||||
}
|
||||
|
||||
/// Returns the backing credentials.
|
||||
Object getCredentials();
|
||||
|
||||
|
||||
interface AuthenticationType {
|
||||
/// Generates an `Authorization` HTTP header by the given credentials.
|
||||
/// @throws IllegalArgumentException if credentials are of wrong type
|
||||
String generateAuthorizationHeader(Object credentials);
|
||||
|
||||
/// Returns the accepted credentials' types.
|
||||
Set<Class<?>> getAcceptedTypes();
|
||||
|
||||
/// Returns the `Authorization` header prefix.
|
||||
String getPrefix();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.fokinatorr.chess.security;
|
||||
|
||||
import lombok.NonNull;
|
||||
import org.reactivestreams.Publisher;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/// Creates a [UserCredentials] instance from scratch, or refreshes the expired ones.
|
||||
public interface UserCredentialsFactory extends Supplier<Publisher<? extends UserCredentials>>, Function<UserCredentials, Publisher<? extends UserCredentials>> {
|
||||
|
||||
/// Returns a [Publisher] emitting a newly created [UserCredentials]. In most cases, subscription will be cancelled after the first signal.
|
||||
@Override
|
||||
@NonNull
|
||||
Publisher<? extends UserCredentials> get();
|
||||
|
||||
/// Returns a [Publisher] emitting a refreshed [UserCredentials] from the expired ones. In most cases, subscription will be cancelled after the first signal.
|
||||
@Override
|
||||
@NonNull
|
||||
Publisher<? extends UserCredentials> apply(@NonNull UserCredentials expired);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: Ch4ss
|
||||
main:
|
||||
web-application-type: none
|
||||
debug: true
|
||||
app:
|
||||
server:
|
||||
host: localhost:8080
|
||||
rsocket-path: /ws
|
||||
@@ -0,0 +1 @@
|
||||
Typeface © The Monotype Corporation plc. Data © The Monotype Corporation plc/Type Solutions Inc. 1990-1992. All Rights Reserved
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.*?>
|
||||
<GridPane prefHeight="277.0" prefWidth="555.0"
|
||||
xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/23.0.1"
|
||||
fx:controller="ru.fokinatorr.chess.gui.DoubleEngineSettingsController">
|
||||
<fx:define>
|
||||
<ToggleGroup fx:id="group"/>
|
||||
</fx:define>
|
||||
<padding>
|
||||
<Insets topRightBottomLeft="10.0"/>
|
||||
</padding>
|
||||
<Text strokeType="OUTSIDE" strokeWidth="0.0" text="Настройки движков">
|
||||
<font>
|
||||
<Font name="Times New Roman Bold" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<Text layoutX="20.0" layoutY="91.0" strokeType="OUTSIDE" strokeWidth="0.0" text="Уровень белых:" GridPane.rowIndex="1">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<Slider blockIncrement="1.0" majorTickUnit="5.0" max="20.0" minorTickCount="4" showTickLabels="true"
|
||||
showTickMarks="true" snapToTicks="true" GridPane.columnIndex="1" GridPane.rowIndex="1" fx:id="whiteSkillLevelView"/>
|
||||
<Text layoutX="20.0" layoutY="91.0" strokeType="OUTSIDE" strokeWidth="0.0" text="Уровень чёрных:" GridPane.rowIndex="2">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<Slider blockIncrement="1.0" majorTickUnit="5.0" max="20.0" minorTickCount="4" showTickLabels="true"
|
||||
showTickMarks="true" snapToTicks="true" GridPane.columnIndex="1" GridPane.rowIndex="2" fx:id="blackSkillLevelView"/>
|
||||
</GridPane>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<?import ru.fokinatorr.chess.Constants?>
|
||||
<VBox prefHeight="400.0" prefWidth="600.0" spacing="15.0" xmlns="http://javafx.com/javafx/23.0.1"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="ru.fokinatorr.chess.gui.GameFXController">
|
||||
<StackPane fx:id="chessBoardViewContainer" />
|
||||
<HBox prefWidth="200.0" spacing="10.0">
|
||||
<padding>
|
||||
<Insets left="20.0" />
|
||||
</padding>
|
||||
<Button fx:id="undoButton" disable="true" onAction="#undoMove" text="Отменить">
|
||||
<graphic>
|
||||
<ImageView>
|
||||
<Constants fx:constant="UNDO" />
|
||||
</ImageView>
|
||||
</graphic>
|
||||
</Button>
|
||||
<Button fx:id="redoButton" disable="true" onAction="#redoMove" text="Вернуть">
|
||||
<graphic>
|
||||
<ImageView>
|
||||
<Constants fx:constant="REDO" />
|
||||
</ImageView>
|
||||
</graphic>
|
||||
</Button>
|
||||
</HBox>
|
||||
<HBox layoutX="10.0" layoutY="175.0" prefWidth="200.0" spacing="10.0">
|
||||
<padding>
|
||||
<Insets left="20.0" />
|
||||
</padding>
|
||||
<Button fx:id="giveUpButton" disable="true" onAction="#giveUp" text="Сдаться" />
|
||||
<Button fx:id="offerDrawButton" disable="true" onAction="#offerDraw" text="Предложить ничью" />
|
||||
</HBox>
|
||||
<Button fx:id="settingsButton" text="Настройки">
|
||||
<VBox.margin>
|
||||
<Insets left="20.0" />
|
||||
</VBox.margin>
|
||||
</Button>
|
||||
<Button fx:id="analyzePositionButton" text="Анализ позиции" onAction="#analyzePosition">
|
||||
<VBox.margin>
|
||||
<Insets left="20.0" />
|
||||
</VBox.margin>
|
||||
</Button>
|
||||
</VBox>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import javafx.scene.text.Font?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<VBox xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="ru.fokinatorr.chess.gui.MainController"
|
||||
prefHeight="400.0" prefWidth="600.0" alignment="CENTER">
|
||||
|
||||
<Text text="Матшахы">
|
||||
<font>
|
||||
<Font name="Times New Roman Bold" size="35" />
|
||||
</font>
|
||||
</Text>
|
||||
<Region prefHeight="50" />
|
||||
<Button text="Играть с другом" onAction="#humanVSHuman">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="15" />
|
||||
</font>
|
||||
</Button>
|
||||
<Region prefHeight="25" />
|
||||
<Button text="Играть с компьютером" onAction="#humanVSComputer">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="15" />
|
||||
</font>
|
||||
</Button>
|
||||
<Region prefHeight="25" />
|
||||
<Button text="Компьютер против компьютера потому что почему бы и нет" onAction="#computerVSComputer">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="15" />
|
||||
</font>
|
||||
</Button>
|
||||
|
||||
</VBox>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.*?>
|
||||
<GridPane prefHeight="277.0" prefWidth="555.0"
|
||||
xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/23.0.1"
|
||||
fx:controller="ru.fokinatorr.chess.gui.SingleEngineSettingsController">
|
||||
<fx:define>
|
||||
<ToggleGroup fx:id="group"/>
|
||||
</fx:define>
|
||||
<padding>
|
||||
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0"/>
|
||||
</padding>
|
||||
<Text strokeType="OUTSIDE" strokeWidth="0.0" text="Настройки движка">
|
||||
<font>
|
||||
<Font name="Times New Roman Bold" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<Text layoutX="20.0" layoutY="91.0" strokeType="OUTSIDE" strokeWidth="0.0" text="Уровень:" GridPane.rowIndex="1">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<Slider blockIncrement="1.0" majorTickUnit="5.0" max="20.0" minorTickCount="4" showTickLabels="true"
|
||||
showTickMarks="true" snapToTicks="true" GridPane.columnIndex="1" GridPane.rowIndex="1" fx:id="skillLevelView"/>
|
||||
<Text layoutX="20.0" layoutY="218.0" strokeType="OUTSIDE" strokeWidth="0.0" text="Играет за:" GridPane.rowIndex="2">
|
||||
<font>
|
||||
<Font name="Times New Roman" size="21.0"/>
|
||||
</font>
|
||||
</Text>
|
||||
<HBox alignment="CENTER_LEFT" spacing="10.0" GridPane.columnIndex="1" GridPane.rowIndex="2">
|
||||
<RadioButton selected="true" text="Чёрных" toggleGroup="$group" fx:id="asBlackView"/>
|
||||
<RadioButton text="Белых" toggleGroup="$group" fx:id="asWhiteView"/>
|
||||
<RadioButton text="Случайно" toggleGroup="$group" fx:id="asRandomView"/>
|
||||
</HBox>
|
||||
</GridPane>
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.fokinatorr.chess;
|
||||
|
||||
import net.rgielen.fxweaver.spring.boot.autoconfigure.FxWeaverAutoConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest(classes = {ru.fokinatorr.chess.Main.class, FxWeaverAutoConfiguration.class})
|
||||
class Ch4ssApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||