diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15d4ca2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/out/ +/lib/ +/.idea/ diff --git a/DomineeringAI.iml b/DomineeringAI.iml new file mode 100644 index 0000000..c198dd9 --- /dev/null +++ b/DomineeringAI.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..bddc46f --- /dev/null +++ b/Readme.md @@ -0,0 +1,21 @@ +# Domineering AI + +The game [domineering](https://en.wikipedia.org/wiki/Domineering) has very few rules and is therefore easy to learn. +However, the strategies which evolve can be more complex than one might think at first glance. + +Domineering can be played on a chess board in any rectangular shape, increasing the complexity with the overall size of +the board. Two players, here called "V" for vertical and "H" for horizontal, each place a domino pieces onto the board. +The pieces may not overlap each other and can only be placed fully on the board. + +The first player who is unable to legally place another piece has lost the game. + +To compute the winning strategy for the game, one has to compute a score for the possible board configurations which +result in the currently possible moves, the player could make. Because the number of possible moves and board +configurations eventually rises exponentially with bigger boards, we need a good strategy to sort out bad moves quickly. + +For this AI, I chose to use a combination of a min-max-algorithm with alpha-beta-pruning. For move ordering, I based my +board scoring and move evaluation algorithm on methods for move counting and scanning from Nathan Bullocks master thesis. + +Depending on how long the AI is allowed to "think" about the next move, the "depthForBoardState" function should be +altered. It is responsible for evaluating the search depth, aka. the number of next moves, the ai should take into +consideration. \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bac4996 --- /dev/null +++ b/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + groupId + DomineeringAI + 1.0-SNAPSHOT + + + 15 + 15 + + + \ No newline at end of file diff --git a/src/main/java/ai/AI.java b/src/main/java/ai/AI.java new file mode 100644 index 0000000..01b6c78 --- /dev/null +++ b/src/main/java/ai/AI.java @@ -0,0 +1,5 @@ +package ai; + +public abstract class AI { + public abstract Coordinate playMove(char[][] board, Player player); +} diff --git a/src/main/java/ai/Area.java b/src/main/java/ai/Area.java new file mode 100644 index 0000000..51d8b85 --- /dev/null +++ b/src/main/java/ai/Area.java @@ -0,0 +1,24 @@ +package ai; + +public abstract class Area { + protected final Coordinate cornerUL; // "upper-left"-corner + protected final Coordinate cornerLR; // "lower-right"-corner + + public Area(int startX, int startY, int endX, int endY) { + cornerUL = new Coordinate(startX, startY); + cornerLR = new Coordinate(endX, endY); + } + + public Coordinate getCornerUL() { + return cornerUL; + } + + public Coordinate getCornerLR() { + return cornerLR; + } + + // return true if the given point is not contained in the area + public boolean contains(int x, int y) { + return x >= cornerLR.getX() && x <= cornerLR.getX() && y >= cornerUL.getY() && y <= cornerLR.getY(); + } +} diff --git a/src/main/java/ai/BoardAnalyser.java b/src/main/java/ai/BoardAnalyser.java new file mode 100644 index 0000000..8147e03 --- /dev/null +++ b/src/main/java/ai/BoardAnalyser.java @@ -0,0 +1,358 @@ +package ai; + +import java.util.Arrays; +import java.util.Random; + +public final class BoardAnalyser { + private final Random generator; + + public final BoardLayout vertical; + public final BoardLayout horizontal; + + private final char[][] board; + + public BoardAnalyser(char[][] board, boolean noBounds) { + this.generator = new Random(); + this.board = board; + + this.vertical = new BoardLayout(Player.V); + this.horizontal = new BoardLayout(Player.H); + + // initialize the internal variables + analyseBoard(); + if (!noBounds) { + calcUnplayable(); + calcLowerBounds(); + calcUpperBounds(); + } + } + + /* + We try to play one of four openings for domineering: + ------------------ + | # | + |## # | + | | + | | + | | + | # ##| + | # | + ------------------ + */ + // to be able to play an opening without the performance loss of analyzing the entire board, this is a static method + public static Coordinate trySimpleOpening(char[][] board, Player player) { + // Horizontal + if (player == Player.H) { + if (board[board.length - 1][board[0].length - 2] == 'E' + && board[board.length - 2][board[0].length - 2] == 'E') { + return new Coordinate(board.length - 2, board[0].length - 2); + } + if (board[0][1] == 'E' && board[1][1] == 'E') { + return new Coordinate(0, 1); + } + } else { + // Vertical + if (board[board.length - 2][0] == 'E' && board[board.length - 2][1] == 'E') { + return new Coordinate(board.length - 2, 0); + } + if (board[1][board[0].length - 2] == 'E' && board[1][board[0].length - 1] == 'E') { + return new Coordinate(1, board[0].length - 2); + } + } + // no simple opening possible + return null; + } + + // scan board for special "areas" which contain all playable moves + public void analyseBoard() { + // copy board + char[][] boardCloneVertical = new char[board.length][]; + char[][] boardCloneHorizontal = new char[board.length][]; + + for (int i = 0; i < board.length; i++) { + boardCloneVertical[i] = Arrays.copyOf(board[i], board[i].length); + boardCloneHorizontal[i] = Arrays.copyOf(board[i], board[i].length); + } + + /* + Go through board and scan for "safe areas" and "protective areas": + + Safe areas are spots where the current player could place his tile, but the other player can never obstruct. + We should keep these areas as they can't be blocked by the other player. + + Protective areas are 2x2 areas which can be converted to "safe areas" by placing a tile on the internally saved + "protectSpot". This move converts a protective area to a safe area. No two protective areas are allowed to be + adjacent to each other. Otherwise the other player could destroy two protective areas by placing one tile. + */ + for (int x = 0; x < board.length; x++) { + for (int y = 0; y < board[0].length; y++) { + if (y < board[0].length - 1 && SafeArea.isSafeArea(boardCloneVertical, Player.V, x, y)) { + vertical.safeAreas.add(new SafeArea(x, y, x, y + 1)); + + // mark the "used spaces" in the board clone so they aren't reused for other areas + boardCloneVertical[x][y] = 'S'; + boardCloneVertical[x][y + 1] = 'S'; + } + if (x < board.length - 1 && SafeArea.isSafeArea(boardCloneHorizontal, Player.H, x, y)) { + horizontal.safeAreas.add(new SafeArea(x, y, x + 1, y)); + + // mark the "used spaces" in the board clone so they aren't reused for other areas + boardCloneHorizontal[x][y] = 'S'; + boardCloneHorizontal[x + 1][y] = 'S'; + } + + if (y < board[0].length - 1 + && ProtectiveArea + .isProtectiveArea(boardCloneVertical, Player.V, x, y, vertical.protectiveAreas)) { + vertical.protectiveAreas.add(new ProtectiveArea(x, y, x + 1, y + 1, board, Player.V)); + + // mark the 2x2 protective area on the board copy + boardCloneVertical[x][y] = 'P'; + boardCloneVertical[x + 1][y] = 'P'; + boardCloneVertical[x][y + 1] = 'P'; + boardCloneVertical[x + 1][y + 1] = 'P'; + } + + if (x < board.length - 1 + && ProtectiveArea + .isProtectiveArea(boardCloneHorizontal, Player.H, x, y, horizontal.protectiveAreas)) { + horizontal.protectiveAreas.add(new ProtectiveArea(x, y, x + 1, y + 1, board, Player.H)); + + // mark the 2x2 protective area on the board copy + boardCloneHorizontal[x][y] = 'P'; + boardCloneHorizontal[x + 1][y] = 'P'; + boardCloneHorizontal[x][y + 1] = 'P'; + boardCloneHorizontal[x + 1][y + 1] = 'P'; + } + } + } + + // scan all safe areas for the so called "option areas". This means player could place the piece into one + // square contained in the safe area and one square which is outside. This mustn't reduce the number of safe + // moves or destroy another protective area. It is really useful to reduce the opponent's possible moves. + for (SafeArea safeArea : vertical.safeAreas) { + int x = safeArea.getCornerUL().getX(); + int y = safeArea.getCornerUL().getY(); + + if (y + 2 < board[0].length) { + OptionArea oaLower = OptionArea + .getOptionArea(boardCloneVertical, Player.V, x, y + 2); + if (oaLower != null) { + vertical.optionAreas.add(oaLower); + safeArea.addOptionAreaLower(oaLower); + } + } + if (y - 1 > 0) { + OptionArea oaHigher = OptionArea + .getOptionArea(boardCloneVertical, Player.V, x, y - 1); + if (oaHigher != null) { + vertical.optionAreas.add(oaHigher); + safeArea.addOptionAreaHigher(oaHigher); + } + } + } + + for (SafeArea safeArea : horizontal.safeAreas) { + int x = safeArea.getCornerUL().getX(); + int y = safeArea.getCornerUL().getY(); + + if (x + 2 < board.length) { + OptionArea oaLower = OptionArea + .getOptionArea(boardCloneHorizontal, Player.H, x + 2, y); + if (oaLower != null) { + horizontal.optionAreas.add(oaLower); + safeArea.addOptionAreaLower(oaLower); + } + } + if (x - 1 > 0) { + OptionArea oaHigher = OptionArea + .getOptionArea(boardCloneHorizontal, Player.H, x - 1, y); + if (oaHigher != null) { + horizontal.optionAreas.add(oaHigher); + safeArea.addOptionAreaHigher(oaHigher); + } + } + } + + // used for temporary storage for the vulnerable area type + int type; + + // all places which are left and aren't single 1x1 fields are vulnerable areas + for (int x = 0; x < board.length; x++) { + for (int y = 0; y < board[0].length; y++) { + if (y < board[0].length - 1 && (type = VulnArea.isVulnArea(boardCloneVertical, x, y, Player.V)) != 0) { + if (type == 1) { + vertical.vulnAreasOne.add(VulnArea.getVulnArea(x, y, Player.V)); + boardCloneVertical[x][y] = 'D'; + boardCloneVertical[x][y + 1] = 'D'; + } else if (type == 2) { + vertical.vulnAreasTwo.add(VulnArea.getVulnArea(x, y, Player.V)); + boardCloneVertical[x][y] = 'D'; + boardCloneVertical[x][y + 1] = 'D'; + } else if (type == 3) { + // the vulnerable areas which contain a protected square are counted twice + vertical.vulnAreasOne.add(VulnArea.getVulnArea(x, y, Player.V)); + vertical.vulnAreasProtectedOne.add(VulnArea.getVulnArea(x, y, Player.V)); + boardCloneVertical[x][y] = 'D'; + boardCloneVertical[x][y + 1] = 'D'; + } else if (type == 4) { + vertical.vulnAreasTwo.add(VulnArea.getVulnArea(x, y, Player.V)); + vertical.vulnAreasProtectedTwo.add(VulnArea.getVulnArea(x, y, Player.V)); + boardCloneVertical[x][y] = 'D'; + boardCloneVertical[x][y + 1] = 'D'; + } + } + + if (x < board.length - 1 + && (type = VulnArea.isVulnArea(boardCloneHorizontal, x, y, Player.H)) != 0) { + if (type == 1) { + horizontal.vulnAreasOne.add(VulnArea.getVulnArea(x, y, Player.H)); + boardCloneHorizontal[x][y] = 'D'; + boardCloneHorizontal[x + 1][y] = 'D'; + } else if (type == 2) { + horizontal.vulnAreasTwo.add(VulnArea.getVulnArea(x, y, Player.H)); + boardCloneHorizontal[x][y] = 'D'; + boardCloneHorizontal[x + 1][y] = 'D'; + } else if (type == 3) { + // the vulnerable areas which contain a protected square are counted twice + horizontal.vulnAreasOne.add(VulnArea.getVulnArea(x, y, Player.H)); + horizontal.vulnAreasProtectedOne.add(VulnArea.getVulnArea(x, y, Player.H)); + boardCloneHorizontal[x][y] = 'D'; + boardCloneHorizontal[x + 1][y] = 'D'; + } else if (type == 4) { + horizontal.vulnAreasTwo.add(VulnArea.getVulnArea(x, y, Player.H)); + horizontal.vulnAreasProtectedTwo.add(VulnArea.getVulnArea(x, y, Player.H)); + boardCloneHorizontal[x][y] = 'D'; + boardCloneHorizontal[x + 1][y] = 'D'; + } + } + } + } + + // the last thing to do is to sum up all squares which were available from the start and also which could be + // played by either player. + for (int x = 0; x < board.length; x++) { + for (int y = 0; y < board[0].length; y++) { + if (board[x][y] == 'E') { + horizontal.startAvailableSquares++; + vertical.startAvailableSquares++; + } + + if ((boardCloneVertical[x][y] == 'E') + && ((x <= 0 || board[x - 1][y] != 'E') && (x + 1 >= board.length || board[x + 1][y] != 'E'))) { + horizontal.unavailableSquares++; + } + + if ((boardCloneHorizontal[x][y] == 'E') + && ((y <= 0 || board[x][y - 1] != 'E') && (y + 1 >= board[0].length + || board[x][y + 1] != 'E'))) { + vertical.unavailableSquares++; + } + } + } + } + + // this lower bound denotes the minimum number of moves the current player is able to play + // the main strategy and calculation is based on Nathan Bullock's master thesis (theorem 3.5.1) + private void calcLowerBounds() { + if (vertical.numProtectiveAreas() % 2 != 0) { + ProtectiveArea convertibleArea = + vertical.protectiveAreas.get(generator.nextInt(vertical.numProtectiveAreas())); + vertical.protectiveAreas.remove(convertibleArea); + + VulnArea[] newVulnAreas = convertibleArea.splitIntoVulnTwo(Player.V); + + vertical.vulnAreasTwo.add(newVulnAreas[0]); + vertical.vulnAreasTwo.add(newVulnAreas[1]); + } + + int addMove = (vertical.numVulnAreasTwo() % 3 != 0 && vertical.numVulnAreasOne() % 2 != 0) ? 1 : 0; + + vertical.lowerBound = (vertical.numProtectiveAreas() + + vertical.numVulnAreasTwo() / 3 + + vertical.numVulnAreasOne() / 2 + + vertical.numSafeAreas() + + addMove + ); + + if (horizontal.numProtectiveAreas() % 2 != 0) { + ProtectiveArea convertibleArea = + horizontal.protectiveAreas.get(generator.nextInt(horizontal.numProtectiveAreas())); + horizontal.protectiveAreas.remove(convertibleArea); + + VulnArea[] newVulnAreas = convertibleArea.splitIntoVulnTwo(Player.H); + + horizontal.vulnAreasTwo.add(newVulnAreas[0]); + horizontal.vulnAreasTwo.add(newVulnAreas[1]); + } + + addMove = (horizontal.numVulnAreasTwo() % 3 != 0 && horizontal.numVulnAreasOne() % 2 != 0) ? 1 : 0; + + horizontal.lowerBound = (horizontal.numProtectiveAreas() + + horizontal.numVulnAreasTwo() / 3 + + horizontal.numVulnAreasOne() / 2 + + horizontal.numSafeAreas() + + addMove + ); + } + + // get the upper bound of moves which could be played by the corresponding player. + private void calcUpperBounds() { + // first, calculate the upper bound for vertical + // number of playable squares after the opponent has played his lower bound of moves + int squaresCurr = (vertical.startAvailableSquares - 2 * horizontal.lowerBound); + // the upper bound is the number of playable squares divided by 2 -> minimum number of playable tiles + vertical.upperBound = (squaresCurr - vertical.unavailableSquares - vertical.unplayableSquares) / 2; + + // calculate the upper bound for horizontal + squaresCurr = (horizontal.startAvailableSquares - 2 * vertical.lowerBound); + horizontal.upperBound = (squaresCurr - horizontal.unavailableSquares - horizontal.unplayableSquares) / 2; + } + + private void calcUnplayable() { + // first, calculate the unplayable squares for vertical (use the data from horizontal player) + BoardLayout current = vertical; + BoardLayout opponent = horizontal; + + for (int i = 0; i < 2; i++) { + int o1 = 0; + int o2 = 0; + int o3 = 0; + + for (OptionArea o : opponent.optionAreas) { + switch (o.getWeight()) { + case 1 -> o1++; + case 2 -> o2++; + case 3 -> o3++; + } + } + + int addMove1 = (opponent.numVulnAreasTwo() % 3 != 0 + && opponent.numVulnAreasOne() % 2 != 0 + && (opponent.numVulnAreasProtectedTwo() > 0 || opponent.numVulnAreasProtectedOne() > 0) + ) ? -1 : 0; + + int addMove2 = 0; + if (!(opponent.numVulnAreasTwo() % 3 != 0 && opponent.numVulnAreasOne() % 2 != 0) + && !(opponent.numVulnAreasTwo() % 3 == 0 && opponent.numVulnAreasOne() % 2 == 0)) { + if (o3 % 2 == 1) { + addMove2 = 3; + } else if (o2 % 2 == 1) { + addMove2 = 2; + } else if (o1 % 2 == 1) addMove2 = 1; + } + + current.unplayableSquares = (opponent.numVulnAreasProtectedTwo() + - (opponent.numVulnAreasTwo() / 3 + - (opponent.numVulnAreasTwo() - opponent.numVulnAreasProtectedTwo()) / 3)) + + (opponent.numVulnAreasProtectedOne() + - (opponent.numVulnAreasOne() / 2 + - (opponent.numVulnAreasOne() - opponent.numVulnAreasProtectedOne()) / 2)) + + 3 * (o3 / 2) + 2 * (o2 / 2) + o1 / 2 + addMove1 + addMove2; + + // then, do the same thing for horizontal + current = horizontal; + opponent = vertical; + } + } +} diff --git a/src/main/java/ai/BoardLayout.java b/src/main/java/ai/BoardLayout.java new file mode 100644 index 0000000..3848bd0 --- /dev/null +++ b/src/main/java/ai/BoardLayout.java @@ -0,0 +1,69 @@ +package ai; + +import java.util.ArrayList; + +/* + A BoardLayout object contains all the calculated board areas for either the vertical or the horizontal player. It + is only used for storing these objects and values. + */ + +public class BoardLayout { + public final ArrayList protectiveAreas; + public final ArrayList safeAreas; + public final ArrayList vulnAreasOne; + public final ArrayList vulnAreasTwo; + public final ArrayList vulnAreasProtectedOne; + public final ArrayList vulnAreasProtectedTwo; + public final ArrayList optionAreas; + + public final Player player; + + public int lowerBound; + public int upperBound; + + public int unavailableSquares; + public int startAvailableSquares; + public int unplayableSquares; + + public BoardLayout(Player player) { + this.player = player; + this.protectiveAreas = new ArrayList<>(20); + this.safeAreas = new ArrayList<>(15); + this.vulnAreasTwo = new ArrayList<>(50); + this.vulnAreasOne = new ArrayList<>(15); + this.vulnAreasProtectedTwo = new ArrayList<>(10); + this.vulnAreasProtectedOne = new ArrayList<>(10); + this.optionAreas = new ArrayList<>(15); + + this.lowerBound = Integer.MIN_VALUE; + this.upperBound = Integer.MIN_VALUE; + this.unplayableSquares = Integer.MIN_VALUE; + + this.unavailableSquares = 0; + this.startAvailableSquares = 0; + } + + public int numProtectiveAreas() { + return protectiveAreas.size(); + } + + public int numSafeAreas() { + return safeAreas.size(); + } + + public int numVulnAreasOne() { + return vulnAreasOne.size(); + } + + public int numVulnAreasTwo() { + return vulnAreasTwo.size(); + } + + public int numVulnAreasProtectedOne() { + return vulnAreasProtectedOne.size(); + } + + public int numVulnAreasProtectedTwo() { + return vulnAreasProtectedTwo.size(); + } +} diff --git a/src/main/java/ai/BoardStorage.java b/src/main/java/ai/BoardStorage.java new file mode 100644 index 0000000..14a7b1b --- /dev/null +++ b/src/main/java/ai/BoardStorage.java @@ -0,0 +1,37 @@ +package ai; + +import java.util.Arrays; +import java.util.HashMap; + +// simple wrapper for java hashmap structure +public class BoardStorage { + private final HashMap scoreStorage = new HashMap<>(); + + public void put(char[][] board, StateInfo info) { + scoreStorage.put(Arrays.hashCode(flatten(board)), info); + } + + // get the score for the given board, returns null if no score is found + public StateInfo get(char[][] board) { + return scoreStorage.get(Arrays.hashCode(flatten(board))); + } + + // small storage class for the values which should be stored for each registered board + public static class StateInfo { + // the calculated score + public float score; + // either the score is a final one, only one for the alpha, or only one for the beta value + public char type; + // at which depth was the score determined? + public int depth; + } + + // convert 2d-array to 1d + private char[] flatten(char[][] board) { + char[] output = new char[board.length * board[0].length]; + for (int x = 0; x < board.length; x++) { + System.arraycopy(board[x], 0, output, x * board[0].length, board[0].length); + } + return output; + } +} diff --git a/src/main/java/ai/Coordinate.java b/src/main/java/ai/Coordinate.java new file mode 100644 index 0000000..6c264ed --- /dev/null +++ b/src/main/java/ai/Coordinate.java @@ -0,0 +1,43 @@ +package ai; + +import java.util.Objects; + +public class Coordinate { + private int x; + private int y; + + public Coordinate(int x, int y) { + this.x = x; + this.y = y; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + @Override + public String toString() { + return "(" + x + "," + y + ")"; + } + + @Override + public int hashCode() { + return Objects.hash(x, y); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Coordinate)) { + return false; + } + Coordinate other = (Coordinate) obj; + return x == other.x && y == other.y; + } +} \ No newline at end of file diff --git a/src/main/java/ai/Game.java b/src/main/java/ai/Game.java new file mode 100644 index 0000000..54240c6 --- /dev/null +++ b/src/main/java/ai/Game.java @@ -0,0 +1,119 @@ +package ai; + +import java.util.Arrays; + +public class Game { + private static final int BOARD_LENGTH = 13; + private final AI verticalAI; + private final AI horizontalAI; + private final boolean visual; + private Player winner; + + public Game(AI verticalAI, AI horizontalAI) { + this.verticalAI = verticalAI; + this.horizontalAI = horizontalAI; + this.visual = false; + } + + // a visual game prints the current state of the board to the console after each move + public Game(AI verticalAI, AI horizontalAI, boolean visual) { + this.verticalAI = verticalAI; + this.horizontalAI = horizontalAI; + this.visual = visual; + } + + public void runGame() { + // start by initializing a new game, this way, runGame() could potentially be run more than once + char[][] board = generateEmptyBoard(); + Coordinate move; + + // starting player -> always the vertical player for our game version + Player currentPlayer = Player.V; + + if (visual) GameVisualizer.printBoard(board); + while (true) { + // is there another valid move possible for the current player + if (!canPlay(board, currentPlayer)) { + if (visual) System.out.println("" + currentPlayer + " can't place another piece, he lost!"); + break; + } + if (visual) System.out.println("" + currentPlayer + "'s move:"); + // ask the AI for a new move (coordinates for a new piece) + if (currentPlayer == Player.V) { + move = verticalAI.playMove(board, currentPlayer); + } else { + move = horizontalAI.playMove(board, currentPlayer); + } + + if (visual) System.out.println("Places a piece on " + move); + + // is the returned move actually valid + if (checkInvalidMoveSimple(board, move, currentPlayer)) { + if (visual) System.out.println("!!!! INVALID MOVE BY " + currentPlayer + " !!!!"); + break; + } + + makeMove(board, move, currentPlayer); + if (visual) GameVisualizer.printBoard(board); + + // change the play for the next round + currentPlayer = currentPlayer.getOtherPlayer(); + } + winner = currentPlayer.getOtherPlayer(); + } + + // generate an empty board filled with 'E' + private char[][] generateEmptyBoard() { + char[][] board = new char[BOARD_LENGTH][BOARD_LENGTH]; + for (char[] column : board) { + Arrays.fill(column, 'E'); + } + return board; + } + + // fulfill a returned move if it is valid + private void makeMove(char[][] board, Coordinate move, Player p) { + board[move.getX()][move.getY()] = (p == Player.H ? 'H' : 'V'); + board[move.getX() + (p == Player.H ? 1 : 0)][move.getY() + (p == Player.H ? 0 : 1)] = + (p == Player.H ? 'H' : 'V'); + } + + // test if a returned move is valid + private boolean checkInvalidMoveSimple(char[][] board, Coordinate move, Player p) { + return move.getX() < 0 + || move.getX() >= BOARD_LENGTH + || move.getY() < 0 + || move.getY() >= BOARD_LENGTH + || (p == Player.H && move.getX() + 1 >= BOARD_LENGTH) + || (p == Player.V && move.getY() + 1 >= BOARD_LENGTH) + || board[move.getX()][move.getY()] != 'E' + || (p == Player.H && board[move.getX() + 1][move.getY()] != 'E') + || (p == Player.V && board[move.getX()][move.getY() + 1] != 'E'); + } + + // is there any move left for the current player? If not, the other player has one! + private boolean canPlay(char[][] board, Player p) { + if (p == Player.H) { + for (int y = 0; y < board[0].length; y++) { + for (int x = 0; x < board.length - 1; x++) { + if (board[x][y] == 'E' && board[x + 1][y] == 'E') { + return true; + } + } + } + } else { + for (char[] column : board) { + for (int y = 0; y < column.length - 1; y++) { + if (column[y] == 'E' && column[y + 1] == 'E') { + return true; + } + } + } + } + return false; + } + + public AI getWinner() { + return winner == Player.V ? verticalAI : horizontalAI; + } +} \ No newline at end of file diff --git a/src/main/java/ai/GameVisualizer.java b/src/main/java/ai/GameVisualizer.java new file mode 100644 index 0000000..61565ee --- /dev/null +++ b/src/main/java/ai/GameVisualizer.java @@ -0,0 +1,42 @@ +package ai; + +public class GameVisualizer { + + public static void printBoard(char[][] board) { + System.out.print(" "); + for (int x = 0; x < board.length; x++) { + System.out.print(" " + Integer.toHexString(x)); + } + System.out.println(); + + System.out.print(" ┏"); + for (int x = 0; x < board.length - 1; x++) { + System.out.print("━┳"); + } + System.out.println("━┓"); + + for (int y = 0; y < board[0].length; y++) { + System.out.print(Integer.toHexString(y) + "┃"); + for (char[] chars : board) { + if (chars[y] != 'E') { + System.out.print(chars[y] + "┃"); + } else { + System.out.print(" ┃"); + } + } + System.out.println(); + if (y != board[0].length - 1) { + System.out.print(" ┣"); + for (int x = 0; x < board.length - 1; x++) { + System.out.print("━╋"); + } + System.out.println("━┫"); + } + } + System.out.print(" ┗"); + for (int x = 0; x < board.length - 1; x++) { + System.out.print("━┻"); + } + System.out.println("━┛"); + } +} diff --git a/src/main/java/ai/HardMinMax.java b/src/main/java/ai/HardMinMax.java new file mode 100644 index 0000000..3e249a2 --- /dev/null +++ b/src/main/java/ai/HardMinMax.java @@ -0,0 +1,369 @@ +package ai; + +import java.util.Arrays; + +public class HardMinMax extends AI { + public float[] factors = null; + + // store the already calculated scores for each board configuration for the ultimate performance boost + static BoardStorage scoreMapHorizontalStarter = new BoardStorage(); + static BoardStorage scoreMapVerticalStarter = new BoardStorage(); + + @Override + public synchronized Coordinate playMove(char[][] board, Player player) { + // play an opening -> for better performance (an empty board is pretty expensive to calculate) + Coordinate opening = BoardAnalyser.trySimpleOpening(board, player); + if (opening != null) { + return opening; + } + + // for medium and hard mode, we use our minimax-algorithm + return findBestMove(anonymizeBoard(board), player); + } + + /* + Because of the nature of our board layout, we only take into consideration whether a square is occupied (X) or + empty (E). This greatly increases the performance! + */ + private char[][] anonymizeBoard(char[][] board) { + char[][] boardCopy = new char[board.length][board[0].length]; + for (int x = 0; x < board.length; x++) { + for (int y = 0; y < board[0].length; y++) { + boardCopy[x][y] = (board[x][y] == 'V' || board[x][y] == 'H' ? 'X' : 'E'); + } + } + return boardCopy; + } + + // tests how far the game has already commenced and adjusts the depth limit accordingly + private int depthForBoardState(char[][] board) { + int blocked = 0; + for (char[] row : board) { + for (char c : row) { + if (c != 'E') blocked++; + } + } + + // for the dynamic depth adjustment, I used a sigmoid-like curve which was fitted by probing values in geogebra + return (int) (20 / (1 + Math.pow(1.035, (-blocked + 95)))) + 1; + } + + /* + Recursively tests all possible moves (really slow for early board stages) + + The approach is based on the concept of a minimax tree. The root configuration is the current board. Each branch + is a possible move for either us or the opponent. As soon as one of the players looses or the depth limit is + reached, the current board configuration is evaluated. Each player (stage in the tree) tries to play the best + strategy. This means is the "score" is how well we (Player A) are doing, than Player B tries to play the move + which has the lowest score associated with it. Player A tries to play the move with the maximum score and thus + the resulting tree is composed of alternating minimum and maximum phases. + */ + private Coordinate findBestMove(char[][] board, Player player) { + Coordinate[] possibleMoves = generateNextPossibleMoves(board, player, null, false); + Coordinate currentBestMove = null; + + // this "currentBestScore" should be updated as soon as a score higher than the lowest possible score is found + float currentBestScore = Float.NEGATIVE_INFINITY; + + int maxDepth = depthForBoardState(board); + + // just try these possibleMoves in their natural order + for (Coordinate move : possibleMoves) { + applyMove(board, move, player); + float nextBestScore = minimaxAlphaBeta( + board, + player.getOtherPlayer(), + player, + maxDepth, + currentBestScore, + Float.POSITIVE_INFINITY + ); + undoMove(board, move, player); + // because the current player is always the maximizing player and we can't prune, we have to go through + // each entry and always update the current maximum score and the associated move + if (nextBestScore > currentBestScore) { + currentBestScore = nextBestScore; + currentBestMove = move; + } + } + if (currentBestMove == null) { + // if all fails and all scores are somehow equal to Integer.MIN_VALUE, we take the first item from the + // generated possible moves and return it. + return generateNextPossibleMoves(board, player, null, true)[0]; + } + return currentBestMove; + } + + /* + The key in efficient solution finding is reducing the unnecessary calculations in our tree. The "scoreSituation" + method always tests if winning is still possible and otherwise returns a low score. + + Alpha-Beta-Pruning: + -> alpha: currently the highest score (reference for the maximising player) + -> beta: currently the lowest score (reference for the minimising player) + + Case 1: + ------- + We are currently the maximizing player. The previous player (minimizing) gave us his updated "beta"-score. + Only if our score is lower than the given beta score, our branch is of interest, otherwise there exists + another branch with a lower score and because the previous player was minimizing, he would then take the other + branch. This means that if our current score is higher than the beta score, we can break the loop and return. + + Case 2: + ------- + We are the minimizing player. The previous player was maximizing and gave the current maximum score in the + alpha parameter. If our "best score" (the minimum) drops below the alpha score, we can break the search and + return, because even though this situation would be better for us, the previous player wouldn't take our + branch into consideration. + + This special implementation of the minimax algorithm which negates the next call with changed alpha and beta + values is described in the doctoral thesis of Prof. dr. H.J. van den Herik: "Memory versus Search in Games". + It also involves storing the values which were calculated for even higher performance. Some more ideas came + from the already often cited master thesis of Nathan Bullock about the game domineering. Both papers didn't + contain actual code or the code was not reviewed by me. + */ + private float minimaxAlphaBeta(char[][] board, Player currentPlayer, Player startingPlayer, int depth, + float alpha, float beta) { + float oldAlpha = alpha; + float oldBeta = beta; + + // first, try to load the score from the scoreMap + BoardStorage.StateInfo saveState = loadScore(board, startingPlayer); + + if (saveState != null && saveState.depth >= depth) { + if (saveState.type == '-' && saveState.score > alpha) { + alpha = saveState.score; + } else if (saveState.type == '+' && saveState.score < beta) { + beta = saveState.score; + } + + if (saveState.type == '=' || alpha >= beta) { + return saveState.score; + } + } + + // The new BoardAnalyser object is used by both the scoring function and the possible moves generator. For + // performance improvement, it is only created once. + BoardAnalyser bA = new BoardAnalyser(board, false); + float score = scoreSituation( + depth, + (startingPlayer == Player.V) ? bA.vertical : bA.horizontal, + (startingPlayer == Player.V) ? bA.horizontal : bA.vertical + ); + + if (score != Float.NEGATIVE_INFINITY) { + return score; + } + + Coordinate[] possibleMoves = generateNextPossibleMoves(board, currentPlayer, bA, false); + float nextBestScore; + + // if true -> current player tries to maximize the score + boolean max = (currentPlayer == startingPlayer); + + // never go down / up with the score, but set the given best to be the lowest score for the "current best" + float currentBestScore = max ? alpha : beta; + + // goes through all possible moves which are basically just placing tiles in all calculated board cover + // regions. These regions are generated using a BoardAnalyser object. + for (Coordinate move : possibleMoves) { + applyMove(board, move, currentPlayer); + nextBestScore = minimaxAlphaBeta( + board, + currentPlayer.getOtherPlayer(), + startingPlayer, + depth - 1, + max ? currentBestScore : alpha, + max ? beta : currentBestScore + ); + undoMove(board, move, currentPlayer); + + // either we try to maximize the score, then update if the new score is higher than the current best + // or we try to minimize the score and therefore only update if the new score is lower than the current best + if ((max && nextBestScore > currentBestScore) || (!max && nextBestScore < currentBestScore)) { + currentBestScore = nextBestScore; + } + + // this is the important alpha-beta-pruning improvement over the classic minimax-algorithm. The details + // of the implementations are written in the comment above this method. + if ((max && currentBestScore >= beta) || (!max && currentBestScore <= alpha)) { + break; + } + } + + BoardStorage.StateInfo boardState = new BoardStorage.StateInfo(); + boardState.score = currentBestScore; + boardState.depth = depth; + + if (currentBestScore <= oldAlpha) { + boardState.type = '+'; + } else if (currentBestScore >= oldBeta) { + boardState.type = '-'; + } else { + boardState.type = '='; + } + + saveScore(board, boardState, startingPlayer); + return currentBestScore; + } + + private BoardStorage.StateInfo loadScore(char[][] board, Player starter) { + return (starter == Player.V ? scoreMapVerticalStarter.get(board) : scoreMapHorizontalStarter.get(board)); + } + + private void saveScore(char[][] board, BoardStorage.StateInfo state, Player starter) { + if (starter == Player.V) { + scoreMapVerticalStarter.put(board, state); + } else { + scoreMapHorizontalStarter.put(board, state); + } + } + + /* + This "exit-function" calculates the current score if a tree leaf is reached. The current method call + represents a tree leaf if either the maximum recursion depth is reached or either one of the players can't + play another tile or the current player already won the game because he could always place more tiles than + the other player. + + Most switches contained in this function are based on the calculated board cover (BoardAnalyser) and the + findings of Nathan Bullock's master thesis "Domineering: Solving Large Combinatorial Search Spaces". + + The current approach is to assign weights to each parameter we have available and figure out the optimal values. + */ + private float scoreSituation(int currentDepth, BoardLayout starter, BoardLayout opponent) { + if (currentDepth <= 0 + || starter.lowerBound <= 0 // we already lost + || opponent.lowerBound <= 0 // the opponent already lost + || starter.lowerBound > opponent.upperBound // win for the starting player (NB) + || opponent.lowerBound >= starter.upperBound // win for the opponent (NB) + ) { + /* + Available values and proposed weights / factors + + (these are just first guesses and still have to be refined) + Basically we "punish" the ai / give worse scores for good situations of the opponent than for good + situations for the current player. + + The concrete values were determined using an evolutional approach where two instances of + the HardMinMax each with different factors played against each other over multiple round, with the winner + proceeding. + */ + + if (factors == null) { + factors = new float[]{ + 6.141892f, // lower bound + 3.323705f, + 1.5304062f, // upper bound + 2.5675583f, + 10.425653f, // safe areas + -15.922241f, + 2.0729046f, // vuln areas + -3.497818f, + -3.3107972f, // protective areas + -11.012934f, + -0.85778457f, // unavailable areas + 5.7875576f, + 0.80485183f, // unplayable areas + 1.9488539f + }; + } + + return (starter.lowerBound * factors[0] + + opponent.lowerBound * factors[1] + + starter.upperBound * factors[2] + + opponent.upperBound * factors[3] + + starter.numSafeAreas() * factors[4] + + opponent.numSafeAreas() * factors[5] + + (starter.numVulnAreasOne() + starter.numVulnAreasTwo()) * factors[6] + + (opponent.numVulnAreasOne() + opponent.numVulnAreasTwo()) * factors[7] + + starter.numProtectiveAreas() * factors[8] + + opponent.numProtectiveAreas() * factors[9] + + starter.unavailableSquares * factors[10] + + opponent.unavailableSquares * factors[11] + + starter.unplayableSquares * factors[12] + + opponent.unplayableSquares * factors[13] + ); + } + // if the recursion should not be stopped, return a score that would never be calculated + // I don't know if NEGATIVE_INFINITY is better than MIN_VALUE + return Float.NEGATIVE_INFINITY; + } + + // returns the entered board configuration with the given move applied + private void applyMove(char[][] board, Coordinate move, Player player) { + // set the first square occupied + board[move.getX()][move.getY()] = 'X'; + + // based on the player, set the second square occupied + if (player == Player.V) { + board[move.getX()][move.getY() + 1] = 'X'; + } else { + board[move.getX() + 1][move.getY()] = 'X'; + } + } + + private void undoMove(char[][] board, Coordinate move, Player player) { + // set the first square unoccupied + board[move.getX()][move.getY()] = 'E'; + + // based on the player, set the second square unoccupied + if (player == Player.V) { + board[move.getX()][move.getY() + 1] = 'E'; + } else { + board[move.getX() + 1][move.getY()] = 'E'; + } + } + + // runs an entered board analyzer or creates a new one. Returns the concatenated board cover areas. + private Coordinate[] generateNextPossibleMoves(char[][] board, Player player, BoardAnalyser bA, boolean include) { + if (bA == null) { + bA = new BoardAnalyser(board, true); + } + // select the required BoardLayout object + BoardLayout bL = (player == Player.V ? bA.vertical : bA.horizontal); + + // generate a new Array with approximately the maximum size required + Coordinate[] outputMoves = new Coordinate[2 * bL.numProtectiveAreas() + + bL.numVulnAreasOne() + + bL.numVulnAreasTwo() + + bL.numSafeAreas() * 3]; + /* + The areas are concatenated to be filled in the following order: + + 1. Protect spots of protective areas -> placing a tile here converts the protective area to a safe area + 2. Vulnerable areas type II + 3. Vulnerable areas type I + 4. The part of protective areas which is not the protect spot + 5. Safe areas: first the option area possibilities and then the normal safe areas + */ + int index = 0; + for (ProtectiveArea area : bL.protectiveAreas) { + outputMoves[index++] = area.getProtectSpot().getCornerUL(); + } + for (VulnArea area : bL.vulnAreasTwo) { + outputMoves[index++] = area.getCornerUL(); + } + for (VulnArea area : bL.vulnAreasOne) { + outputMoves[index++] = area.getCornerUL(); + } + for (ProtectiveArea area : bL.protectiveAreas) { + VulnArea[] split = area.splitIntoVulnTwo(player); + // always only add the part of the protective are which is NOT the protect spot + if (include) { + outputMoves[index++] = split[0].getCornerUL().equals(area.getProtectSpot().getCornerUL()) + ? split[1].getCornerUL() + : split[0].getCornerUL(); + } + } + for (SafeArea area : bL.safeAreas) { + if (area.getOptionSafeLower() != null) { + outputMoves[index++] = area.getOptionSafeLower().getCornerUL(); + } + if (area.getOptionSafeUpper() != null) { + outputMoves[index++] = area.getOptionSafeUpper().getCornerUL(); + } + if (include) outputMoves[index++] = area.getCornerUL(); + } + return Arrays.copyOf(outputMoves, index); + } +} \ No newline at end of file diff --git a/src/main/java/ai/OptionArea.java b/src/main/java/ai/OptionArea.java new file mode 100644 index 0000000..8e0e16d --- /dev/null +++ b/src/main/java/ai/OptionArea.java @@ -0,0 +1,65 @@ +package ai; + +public class OptionArea extends Area { + private final int weight; + + public OptionArea(int startX, int startY, int endX, int endY, int type) { + super(startX, startY, endX, endY); + this.weight = type; + } + + /* + An option area is a spot below or above a safe area which can be occupied by vertical. It could potentially decrease + the opponent's squares (squares where he could place another tile) by 0-3. We won't count the ones which only reduce + the square count by 0. The other's could be in one of 2 possible configurations: + + S + S + # E O E # + +1 1 +1 + ---------------- + S + S + # # O E # + -> 2 + */ + + public static OptionArea getOptionArea(char[][] board, Player player, int x, int y) { + // minimum count for optionArea + int count = 1; + + if (player == Player.V) { + // a 0-OptionArea + if (board[x][y] != 'E' + || ((x - 1 < 0 || board[x - 1][y] != 'E') && (x + 1 >= board.length || board[x + 1][y] != 'E'))) { + return null; + } + // minimum count for optionArea + if ((x - 2 < 0 || board[x - 2][y] == 'X') && (x > 0 && board[x - 1][y] == 'E')) { + count++; + } + if ((x + 2 >= board.length || board[x + 2][y] == 'X') + && (x < board.length - 1 && board[x + 1][y] == 'E')) { + count++; + } + return new OptionArea(x, y, x, y, count); + } + // a 0-OptionArea + if (board[x][y] != 'E' + || ((y - 1 < 0 || board[x][y - 1] != 'E') && (y + 1 >= board[0].length || board[x][y + 1] != 'E'))) { + return null; + } + if ((y - 2 < 0 || board[x][y - 2] == 'X') && (y > 0 && board[x][y - 1] == 'E')) { + count++; + } + if ((y + 2 >= board[0].length || board[x][y + 2] == 'X') + && (y < board[0].length - 1 && board[x][y + 1] == 'E')) { + count++; + } + return new OptionArea(x, y, x, y, count); + } + + public int getWeight() { + return weight; + } +} diff --git a/src/main/java/ai/Player.java b/src/main/java/ai/Player.java new file mode 100644 index 0000000..0624269 --- /dev/null +++ b/src/main/java/ai/Player.java @@ -0,0 +1,9 @@ +package ai; + +public enum Player { + H, V; + + public Player getOtherPlayer() { + return this == H ? V : H; + } +} diff --git a/src/main/java/ai/ProtectiveArea.java b/src/main/java/ai/ProtectiveArea.java new file mode 100644 index 0000000..1430279 --- /dev/null +++ b/src/main/java/ai/ProtectiveArea.java @@ -0,0 +1,133 @@ +package ai; + +import java.util.ArrayList; + +public class ProtectiveArea extends Area { + + private final VulnArea protectSpot; + + public ProtectiveArea(int startX, int startY, int endX, int endY, char[][] board, Player p) { + super(startX, startY, endX, endY); + + // switch for the player to position the protect spot correctly (horizontally or vertically) + if (p == Player.V) { + if (endX + 1 >= board.length || (board[endX + 1][startY] != 'E' && board[endX + 1][endY] != 'E')) { + protectSpot = new VulnArea(startX, startY, startX, startY + 1); + } else { + protectSpot = new VulnArea(endX, startY, endX, startY + 1); + } + } else if (endY + 1 >= board[0].length || (board[startX][endY + 1] != 'E' && board[endX][endY + 1] != 'E')) { + protectSpot = new VulnArea(startX, startY, startX + 1, startY); + } else { + protectSpot = new VulnArea(startX, endY, endX, endY); + } + } + + /* + The input coordinates describe a protective area if both sides (left and right for Player.V and up and down for Player.H) + are contained with other tiles or the borders. These conditions are tested in the overlong expressions below. + */ + public static boolean isProtectiveArea(char[][] board, Player p, int x, int y, + ArrayList addedAreas) { + if (p == Player.V) { + return (isNotOccupied(board, p, x, y) + && noAdjacentProtectiveArea(p, x, y, addedAreas) + + && (((x - 1 < 0) + || ((board[x - 1][y] == 'X') + && (board[x - 1][y + 1] == 'X'))) + + || ((x + 2 >= board.length) + || ((board[x + 2][y] == 'X') + && (board[x + 2][y + 1] == 'X'))))); + } else { + return (isNotOccupied(board, p, x, y) + && noAdjacentProtectiveArea(p, x, y, addedAreas) + + && (((y - 1 < 0) + || ((board[x][y - 1] == 'X') + && (board[x + 1][y - 1] == 'X'))) + + || ((y + 2 >= board[0].length) + || ((board[x][y + 2] == 'X') + && (board[x + 1][y + 2] == 'X'))))); + } + } + + private static boolean noAdjacentProtectiveArea(Player p, int x, int y, ArrayList addedAreas) { + // an area is considered adjacent if the other player can occupy both areas by placing one piece on the board + if (p == Player.V) { + for (ProtectiveArea protectiveArea : addedAreas) { + if (protectiveArea.getCornerUL().getY() >= y - 1 && protectiveArea.getCornerLR().getY() <= y + 2) { + if (x - 1 == protectiveArea.getCornerLR().getX() || x + 2 == protectiveArea.getCornerUL().getX()) { + return false; + } + } + } + } else { + for (ProtectiveArea protectiveArea : addedAreas) { + if (protectiveArea.getCornerUL().getX() >= x - 1 && protectiveArea.getCornerLR().getX() <= x + 2) { + if (y - 1 == protectiveArea.getCornerLR().getY() || y + 2 == protectiveArea.getCornerUL().getY()) { + return false; + } + } + } + } + return true; + } + + /* + Is the given x/y - coordinate already occupied: + + - part of the area is outside of the board + - the area is not empty + + */ + public static boolean isNotOccupied(char[][] board, Player player, int x, int y) { + return board[x][y] == 'E' + && (!(x + 1 >= board.length) && board[x + 1][y] == 'E') + && (!(y + 1 >= board[0].length) && board[x][y + 1] == 'E') + && board[x + 1][y + 1] == 'E'; +// return (board[x][y] == 'E' || board[x][y] == 'P') +// && (!(x + 1 >= board.length) && (board[x + 1][y] == 'E' || board[x + 1][y] == 'P')) +// && (!(y + 1 >= board[0].length) && (board[x][y + 1] == 'E' || board[x][y + 1] == 'P')) +// && (board[x + 1][y + 1] == 'E' || board[x + 1][y + 1] == 'P'); + } + + // Split the protective area into two vulnerable areas + public VulnArea[] splitIntoVulnTwo(Player player) { + if (player == Player.H) { + if (protectSpot.getCornerLR().getY() == cornerLR.getY()) { + return new VulnArea[]{ + protectSpot, new VulnArea( + cornerUL.getX(), cornerUL.getY(), cornerLR.getX(), cornerUL.getY() + ) + }; + } else { + return new VulnArea[]{ + protectSpot, new VulnArea( + cornerUL.getX(), cornerLR.getY(), cornerLR.getX(), cornerLR.getY() + ) + }; + } + } else if (protectSpot.getCornerLR().getX() == cornerLR.getX()) { + return new VulnArea[]{ + protectSpot, new VulnArea( + cornerUL.getX(), cornerUL.getY(), cornerUL.getX(), cornerLR.getY() + ) + }; + } else { + return new VulnArea[]{ + protectSpot, new VulnArea( + cornerLR.getX(), cornerUL.getY(), cornerLR.getX(), cornerLR.getY() + ) + }; + } + } + + // the protect spot is the half of the protective area which should be occupied first in order to convert the + // protective area to a safe area! + public VulnArea getProtectSpot() { + return protectSpot; + } +} diff --git a/src/main/java/ai/SafeArea.java b/src/main/java/ai/SafeArea.java new file mode 100644 index 0000000..8967817 --- /dev/null +++ b/src/main/java/ai/SafeArea.java @@ -0,0 +1,56 @@ +package ai; + +public class SafeArea extends Area { + + private SafeArea optionSafeUpper; + private SafeArea optionSafeLower; + + public SafeArea(int xOne, int yOne, int xTwo, int yTwo) { + super(xOne, yOne, xTwo, yTwo); + } + + // same as for the other areas, tests if the given coordinates qualify for being a safe area + public static boolean isSafeArea(char[][] board, Player p, int x, int y) { + if (p == Player.V) { + return ((board[x][y] == 'E' && board[x][y + 1] == 'E') + + && ((x - 1 < 0) + || ((board[x - 1][y] == 'X') + && (board[x - 1][y + 1] == 'X'))) + + && ((x + 1 >= board.length) + || ((board[x + 1][y] == 'X') + && (board[x + 1][y + 1] == 'X')))); + } else { + return ((board[x][y] == 'E' && board[x + 1][y] == 'E') + + && ((y - 1 < 0) + || ((board[x][y - 1] == 'X') + && (board[x + 1][y - 1] == 'X'))) + + && ((y + 1 >= board[0].length) + || ((board[x][y + 1] == 'X') + && (board[x + 1][y + 1] == 'X')))); + } + } + + public void addOptionAreaHigher(OptionArea oaHigher) { + optionSafeUpper = new SafeArea(oaHigher.getCornerUL().getX(), oaHigher.getCornerUL().getY(), cornerUL.getX(), + cornerUL.getY() + ); + } + + public void addOptionAreaLower(OptionArea oaLower) { + optionSafeLower = new SafeArea(cornerLR.getX(), cornerLR.getY(), oaLower.getCornerUL().getX(), + oaLower.getCornerUL().getY() + ); + } + + public SafeArea getOptionSafeUpper() { + return optionSafeUpper; + } + + public SafeArea getOptionSafeLower() { + return optionSafeLower; + } +} diff --git a/src/main/java/ai/Tester.java b/src/main/java/ai/Tester.java new file mode 100644 index 0000000..03f6333 --- /dev/null +++ b/src/main/java/ai/Tester.java @@ -0,0 +1,12 @@ +package ai; + +// small test class for running games +public class Tester { + public static void main(String[] args) { + AI vertical = new HardMinMax(); + AI horizontal = new HardMinMax(); + Game g = new Game(vertical, horizontal, true); + g.runGame(); + System.out.println(g.getWinner() == vertical ? "Vertical wins!" : "Horizontal wins!"); + } +} diff --git a/src/main/java/ai/VulnArea.java b/src/main/java/ai/VulnArea.java new file mode 100644 index 0000000..6a7cc6d --- /dev/null +++ b/src/main/java/ai/VulnArea.java @@ -0,0 +1,68 @@ +package ai; + +/* + Vulnerable areas are all spots which are not protective areas or safe areas + The weight is determined by the neighbourhood. If another special area is adjacent to the vulnerable area, the other + player could place a tile in a way which would obstruct both areas. We give these vulnerable areas a weight of 2. All + others get a weight of one. +*/ +public class VulnArea extends Area { + + public VulnArea(int xOne, int yOne, int xTwo, int yTwo) { + super(xOne, yOne, xTwo, yTwo); + } + + public static int isVulnArea(char[][] board, int x, int y, Player player) { + // Horizontal and vertical modifier + int vM = (player == Player.V ? 1 : 0); + int hM = (player == Player.H ? 1 : 0); + + int type = 0; + + if (board[x][y] == 'E' && board[x + hM][y + vM] == 'E') { + // if other already existing areas are adjacent to the vulnerable area, it is considered a type II + if (player == Player.V && ( + (x - 1 >= 0 && (board[x - 1][y] == 'P' || board[x - 1][y] == 'S' || board[x - 1][y] == 'D')) + || (x + 1 < board.length && y < board.length + && (board[x + 1][y] == 'P' || board[x + 1][y] == 'S' || board[x + 1][y] == 'D')) + || (x - 1 >= 0 && y + 1 <= board.length + && (board[x - 1][y + 1] == 'P' || board[x - 1][y + 1] == 'S' || board[x - 1][y + 1] == 'D')) + || (x + 1 < board.length && y + 1 < board.length + && (board[x + 1][y + 1] == 'P' || board[x + 1][y + 1] == 'S' + || board[x + 1][y + 1] == 'D')))) { + type = 2; + } else if (player == Player.H && ((y > 0 && (board[x][y - 1] == 'P' || board[x][y - 1] == 'S')) + || (y + 1 < board[0].length && (board[x][y + 1] == 'P' || board[x][y + 1] == 'S' + || board[x][y + 1] == 'D')) + || (x + 1 < board.length && y > 0 + && (board[x + 1][y - 1] == 'P' || board[x + 1][y - 1] == 'S' || board[x + 1][y - 1] == 'D')) + || (x + 1 < board.length && y + 1 < board[0].length + && (board[x + 1][y + 1] == 'P' || board[x + 1][y + 1] == 'S' || board[x + 1][y + 1] == 'D')))) { + type = 2; + } else { + // Vulnerable Area Type I if no other area is adjacent to it + type = 1; + } + } + + if (type > 0) { + if (player == Player.V && (((x <= 0 || board[x - 1][y] == 'X') + && (x >= board.length - 1 || board[x + 1][y] == 'X')) + || ((x <= 0 || board[x - 1][y + 1] == 'X') + && (x >= board.length - 1 || board[x + 1][y + 1] == 'X')))) { + type += 2; + } + if (player == Player.H && (((y <= 0 || board[x][y - 1] == 'X') + && (y >= board[0].length - 1 || board[x][y + 1] == 'X')) + || ((y <= 0 || board[x + 1][y - 1] == 'X') + && (y >= board[0].length - 1 || board[x + 1][y + 1] == 'X')))) { + type += 2; + } + } + return type; + } + + public static VulnArea getVulnArea(int x, int y, Player player) { + return new VulnArea(x, y, x + (player == Player.H ? 1 : 0), y + (player == Player.V ? 1 : 0)); + } +} diff --git a/src/main/java/frontend/BoardVisualizer.java b/src/main/java/frontend/BoardVisualizer.java new file mode 100644 index 0000000..89a3e79 --- /dev/null +++ b/src/main/java/frontend/BoardVisualizer.java @@ -0,0 +1,111 @@ +package frontend; + +import ai.Coordinate; +import ai.HardMinMax; +import ai.Player; +import processing.core.PApplet; + +import java.util.Arrays; + +public class BoardVisualizer extends PApplet { + + char[][] board; + int rectLength; + + HardMinMax ai; + HardMinMax ai2; + + Player curr; + Coordinate move; + + // The argument passed to main must match the class name + public static void main(String[] args) { + PApplet.main("frontend.BoardVisualizer"); + } + + // method for setting the size of the window + public void settings() { + size(800, 800); + } + + // identical use to setup in Processing IDE except for size() + public void setup() { + background(120); + board = new char[13][13]; + for (char[] b : board) { + Arrays.fill(b, 'E'); + } + + rectLength = width / 13; + frameRate(60); + ai = new HardMinMax(); + ai2 = new HardMinMax(); + + strokeWeight(2); + curr = Player.V; + } + + // identical use to draw in Processing IDE + public void draw() { + if (curr == Player.V) { + move = ai.playMove(board, Player.V); + + // is the returned move actually valid + if (checkInvalidMoveSimple(board, move, curr)) { + noLoop(); + } + + makeMove(board, move, curr); + + drawBoard(); + + curr = curr.getOtherPlayer(); + noLoop(); + } + } + + @Override + public void mouseClicked() { + move = new Coordinate(mouseX / (width / 13), mouseY / (width / 13)); + if (!checkInvalidMoveSimple(board, move, curr)) { + makeMove(board, move, curr); + curr = curr.getOtherPlayer(); + loop(); + } + drawBoard(); + } + + private void drawBoard() { + for (int x = 0; x < board.length; x++) { + for (int y = 0; y < board[0].length; y++) { + if (board[x][y] == 'E') { + fill(255); + rect(x * rectLength, y * rectLength, rectLength, rectLength); + } else if (board[x][y] == 'V') { + fill(200, 0, 0); + rect(x * rectLength, y * rectLength, rectLength, rectLength); + } else if (board[x][y] == 'H') { + fill(0, 0, 200); + rect(x * rectLength, y * rectLength, rectLength, rectLength); + } + } + } + } + + private void makeMove(char[][] board, Coordinate move, Player p) { + board[move.getX()][move.getY()] = (p == Player.H ? 'H' : 'V'); + board[move.getX() + (p == Player.H ? 1 : 0)][move.getY() + (p == Player.H ? 0 : 1)] = + (p == Player.H ? 'H' : 'V'); + } + + // test if a returned move is valid + private boolean checkInvalidMoveSimple(char[][] board, Coordinate move, Player p) { + return move.getX() < 0 + || move.getX() > 13 + || move.getY() < 0 + || move.getY() > 13 + || board[move.getX()][move.getY()] != 'E' + || (p == Player.H && board[move.getX() + 1][move.getY()] != 'E') + || (p == Player.V && board[move.getX()][move.getY() + 1] != 'E'); + } +} \ No newline at end of file