Initial commit

This commit is contained in:
Fokinator
2026-03-09 20:16:03 +03:00
commit da9d3baa77
81 changed files with 4852 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf
+34
View File
@@ -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/
+19
View File
@@ -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
+127
View File
@@ -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);
}
}
+114
View File
@@ -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);
}
+10
View File
@@ -0,0 +1,10 @@
spring:
application:
name: Ch4ss
main:
web-application-type: none
debug: true
app:
server:
host: localhost:8080
rsocket-path: /ws
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Typeface © The Monotype Corporation plc. Data © The Monotype Corporation plc/Type Solutions Inc. 1990-1992. All Rights Reserved
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

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() {
}
}