diff --git a/pom.xml b/pom.xml index 6f02aa141..49139edda 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,9 @@ 1.8 1.8 + + -Xlint:all + @@ -52,6 +55,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.9 + + + false + + @@ -63,5 +75,12 @@ 20180130 + + + junit + junit + 4.11 + test + diff --git a/src/main/java/featurecat/lizzie/rules/Board.java b/src/main/java/featurecat/lizzie/rules/Board.java index 81fe97825..c58a4d433 100644 --- a/src/main/java/featurecat/lizzie/rules/Board.java +++ b/src/main/java/featurecat/lizzie/rules/Board.java @@ -100,6 +100,19 @@ public static boolean isValid(int x, int y) { return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE; } + /** + * The comment. Thread safe + * @param comment the comment of stone + */ + public void comment(String comment) { + synchronized (this) { + + if (history.getData() != null) { + history.getData().comment = comment; + } + } + } + /** * The pass. Thread safe * diff --git a/src/main/java/featurecat/lizzie/rules/BoardData.java b/src/main/java/featurecat/lizzie/rules/BoardData.java index 1fce99366..be77d1619 100644 --- a/src/main/java/featurecat/lizzie/rules/BoardData.java +++ b/src/main/java/featurecat/lizzie/rules/BoardData.java @@ -18,6 +18,9 @@ public class BoardData { public int blackCaptures; public int whiteCaptures; + // Comment in the Sgf move + public String comment; + public BoardData(Stone[] stones, int[] lastMove, Stone lastMoveColor, boolean blackToPlay, Zobrist zobrist, int moveNumber, int[] moveNumberList, int blackCaptures, int whiteCaptures, double winrate, int playouts) { this.moveNumber = moveNumber; this.lastMove = lastMove; diff --git a/src/main/java/featurecat/lizzie/rules/BoardHistoryList.java b/src/main/java/featurecat/lizzie/rules/BoardHistoryList.java index 30ba2b81c..044ec0f02 100644 --- a/src/main/java/featurecat/lizzie/rules/BoardHistoryList.java +++ b/src/main/java/featurecat/lizzie/rules/BoardHistoryList.java @@ -88,6 +88,20 @@ public BoardData next() { return head.getData(); } + /** + * moves the pointer to the right, returns the node stored there + * + * @return the next node, null if there is no next node + */ + public BoardHistoryNode nextNode() { + if (head.next() == null) + return null; + else + head = head.next(); + + return head; + } + /** * moves the pointer to the variation number idx, returns the data stored there * @@ -120,7 +134,7 @@ public BoardData getNext() { public List getNexts() { return head.getNexts(); } - + /** * Does not change the pointer position * diff --git a/src/main/java/featurecat/lizzie/rules/SGFParser.java b/src/main/java/featurecat/lizzie/rules/SGFParser.java index c5f274b12..42b725814 100644 --- a/src/main/java/featurecat/lizzie/rules/SGFParser.java +++ b/src/main/java/featurecat/lizzie/rules/SGFParser.java @@ -1,5 +1,7 @@ package featurecat.lizzie.rules; +import java.util.HashMap; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -66,6 +68,12 @@ private static boolean parse(String value) { return false; } int subTreeDepth = 0; + // Save the variation step count + Map subTreeStepMap = new HashMap(); + // Comment of the AW/AB (Add White/Add Black) stone + String awabComment = null; + // Previous Tag + String prevTag = null; boolean inTag = false, isMultiGo = false, escaping = false; String tag = null; StringBuilder tagBuilder = new StringBuilder(); @@ -78,13 +86,9 @@ private static boolean parse(String value) { String blackPlayer = "", whitePlayer = ""; - PARSE_LOOP: - for (byte b : value.getBytes()) { - // Check unicode charactors (UTF-8) - char c = (char) b; - if (((int) b & 0x80) != 0) { - continue; - } + // Support unicode characters (UTF-8) + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); if (escaping) { // Any char following "\" is inserted verbatim // (ref) "3.2. Text" in https://www.red-bean.com/sgf/sgf4.html @@ -96,14 +100,28 @@ private static boolean parse(String value) { case '(': if (!inTag) { subTreeDepth += 1; + // Initialize the step count + subTreeStepMap.put(subTreeDepth, 0); + } else { + if (i > 0) { + // Allow the comment tag includes '(' + tagContentBuilder.append(c); + } } break; case ')': if (!inTag) { - subTreeDepth -= 1; if (isMultiGo) { - break PARSE_LOOP; + // Restore to the variation node + int varStep = subTreeStepMap.get(subTreeDepth); + for (int s = 0; s < varStep; s++) { + Lizzie.board.previousMove(); + } } + subTreeDepth -= 1; + } else { + // Allow the comment tag includes '(' + tagContentBuilder.append(c); } break; case '[': @@ -134,6 +152,8 @@ private static boolean parse(String value) { if (move == null) { Lizzie.board.pass(Stone.BLACK); } else { + // Save the step count + subTreeStepMap.put(subTreeDepth, subTreeStepMap.get(subTreeDepth) + 1); Lizzie.board.place(move[0], move[1], Stone.BLACK); } } else if (tag.equals("W")) { @@ -141,8 +161,17 @@ private static boolean parse(String value) { if (move == null) { Lizzie.board.pass(Stone.WHITE); } else { + // Save the step count + subTreeStepMap.put(subTreeDepth, subTreeStepMap.get(subTreeDepth) + 1); Lizzie.board.place(move[0], move[1], Stone.WHITE); } + } else if (tag.equals("C")) { + // Support comment + if ("AW".equals(prevTag) || "AB".equals(prevTag)) { + awabComment = tagContent; + } else { + Lizzie.board.comment(tagContent); + } } else if (tag.equals("AB")) { int[] move = convertSgfPosToCoord(tagContent); if (move == null) { @@ -173,6 +202,7 @@ private static boolean parse(String value) { e.printStackTrace(); } } + prevTag = tag; break; case ';': break; @@ -199,6 +229,11 @@ private static boolean parse(String value) { // Rewind to game start while (Lizzie.board.previousMove()) ; + // Set AW/AB Comment + if (awabComment != null) { + Lizzie.board.comment(awabComment); + } + return true; } @@ -250,65 +285,93 @@ private static void saveToStream(Board board, Writer writer) throws IOException builder.append(String.format("[%c%c]", x, y)); } } + } else { + // Process the AW/AB stone + Stone[] stones = history.getStones(); + StringBuilder abStone = new StringBuilder(); + StringBuilder awStone = new StringBuilder(); + for (int i = 0; i < stones.length; i++) { + Stone stone = stones[i]; + if (stone.isBlack() || stone.isWhite()) { + // i = x * Board.BOARD_SIZE + y; + int corY = i % Board.BOARD_SIZE; + int corX = (i - corY) / Board.BOARD_SIZE; + + char x = (char) (corX + 'a'); + char y = (char) (corY + 'a'); + + if (stone.isBlack()) { + abStone.append(String.format("[%c%c]", x, y)); + } else { + awStone.append(String.format("[%c%c]", x, y)); + } + } + } + if (abStone.length() > 0) { + builder.append("AB").append(abStone); + } + if (awStone.length() > 0) { + builder.append("AW").append(awStone); + } + } + + // The AW/AB Comment + if (history.getData().comment != null) { + builder.append(String.format("C[%s]", history.getData().comment)); } // replay moves, and convert them to tags. // * format: ";B[xy]" or ";W[xy]" // * with 'xy' = coordinates ; or 'tt' for pass. - BoardData data; - - // TODO: this code comes from cngoodboy's plugin PR #65. It looks like it might be useful for handling - // AB/AW commands for sgfs in general -- we can extend it beyond just handicap. TODO integrate it -// data = history.getData(); -// -// // For handicap -// ArrayList abList = new ArrayList(); -// ArrayList awList = new ArrayList(); -// -// for (int i = 0; i < Board.BOARD_SIZE; i++) { -// for (int j = 0; j < Board.BOARD_SIZE; j++) { -// switch (data.stones[Board.getIndex(i, j)]) { -// case BLACK: -// abList.add(new int[]{i, j}); -// break; -// case WHITE: -// awList.add(new int[]{i, j}); -// break; -// default: -// break; -// } -// } -// } -// -// if (!abList.isEmpty()) { -// builder.append(";AB"); -// for (int i = 0; i < abList.size(); i++) { -// builder.append(String.format("[%s]", convertCoordToSgfPos(abList.get(i)))); -// } -// } -// -// if (!awList.isEmpty()) { -// builder.append(";AW"); -// for (int i = 0; i < awList.size(); i++) { -// builder.append(String.format("[%s]", convertCoordToSgfPos(awList.get(i)))); -// } -// } - - while ((data = history.next()) != null) { - - String stone; - if (Stone.BLACK.equals(data.lastMoveColor)) stone = "B"; - else if (Stone.WHITE.equals(data.lastMoveColor)) stone = "W"; - else continue; - - char x = data.lastMove == null ? 't' : (char) (data.lastMove[0] + 'a'); - char y = data.lastMove == null ? 't' : (char) (data.lastMove[1] + 'a'); - - builder.append(String.format(";%s[%c%c]", stone, x, y)); - } + + // Write variation tree + builder.append(generateNode(board, history.getCurrentHistoryNode())); // close file builder.append(')'); writer.append(builder.toString()); } + + /** + * Generate node with variations + */ + private static String generateNode(Board board, BoardHistoryNode node) throws IOException { + StringBuilder builder = new StringBuilder(""); + + if (node != null) { + + BoardData data = node.getData(); + String stone = ""; + if (Stone.BLACK.equals(data.lastMoveColor) || Stone.WHITE.equals(data.lastMoveColor)) { + + if (Stone.BLACK.equals(data.lastMoveColor)) stone = "B"; + else if (Stone.WHITE.equals(data.lastMoveColor)) stone = "W"; + + char x = data.lastMove == null ? 't' : (char) (data.lastMove[0] + 'a'); + char y = data.lastMove == null ? 't' : (char) (data.lastMove[1] + 'a'); + + builder.append(String.format(";%s[%c%c]", stone, x, y)); + + // Write the comment + if (data.comment != null) { + builder.append(String.format("C[%s]", data.comment)); + } + } + + if (node.numberOfChildren() > 1) { + // Variation + for (BoardHistoryNode sub : node.getNexts()) { + builder.append("("); + builder.append(generateNode(board, sub)); + builder.append(")"); + } + } else if (node.numberOfChildren() == 1) { + builder.append(generateNode(board, node.next())); + } else { + return builder.toString(); + } + } + + return builder.toString(); + } } diff --git a/src/test/java/common/Util.java b/src/test/java/common/Util.java new file mode 100644 index 000000000..e201d57f3 --- /dev/null +++ b/src/test/java/common/Util.java @@ -0,0 +1,149 @@ +package common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.UnsupportedLookAndFeelException; + +import org.json.JSONException; +import org.junit.Test; + +import featurecat.lizzie.Config; +import featurecat.lizzie.Lizzie; +import featurecat.lizzie.analysis.Leelaz; +import featurecat.lizzie.analysis.MoveData; +import featurecat.lizzie.gui.LizzieFrame; +import featurecat.lizzie.rules.Board; +import featurecat.lizzie.rules.BoardData; +import featurecat.lizzie.rules.BoardHistoryList; +import featurecat.lizzie.rules.BoardHistoryNode; +import featurecat.lizzie.rules.SGFParser; +import featurecat.lizzie.rules.Stone; + +public class Util { + + private static ArrayList laneUsageList = new ArrayList(); + + /** + * Get Variation Tree as String List + * The logic is same as the function VariationTree.drawTree + * + * @param startLane + * @param startNode + * @param variationNumber + * @param isMain + */ + public static void getVariationTree(List moveList, int startLane, BoardHistoryNode startNode, int variationNumber, boolean isMain) { + // Finds depth on leftmost variation of this tree + int depth = BoardHistoryList.getDepth(startNode) + 1; + int lane = startLane; + // Figures out how far out too the right (which lane) we have to go not to collide with other variations + while (lane < laneUsageList.size() && laneUsageList.get(lane) <= startNode.getData().moveNumber + depth) { + // laneUsageList keeps a list of how far down it is to a variation in the different "lanes" + laneUsageList.set(lane, startNode.getData().moveNumber - 1); + lane++; + } + if (lane >= laneUsageList.size()) + { + laneUsageList.add(0); + } + if (variationNumber > 1) + laneUsageList.set(lane - 1, startNode.getData().moveNumber - 1); + laneUsageList.set(lane, startNode.getData().moveNumber); + + // At this point, lane contains the lane we should use (the main branch is in lane 0) + BoardHistoryNode cur = startNode; + + // Draw main line + StringBuilder sb = new StringBuilder(); + sb.append(formatMove(cur.getData())); + while (cur.next() != null) { + cur = cur.next(); + sb.append(formatMove(cur.getData())); + } + moveList.add(sb.toString()); + // Now we have drawn all the nodes in this variation, and has reached the bottom of this variation + // Move back up, and for each, draw any variations we find + while (cur.previous() != null && cur != startNode) { + cur = cur.previous(); + int curwidth = lane; + // Draw each variation, uses recursion + for (int i = 1; i < cur.numberOfChildren(); i++) { + curwidth++; + // Recursion, depth of recursion will normally not be very deep (one recursion level for every variation that has a variation (sort of)) + getVariationTree(moveList, curwidth, cur.getVariation(i), i, false); + } + } + } + + private static String formatMove(BoardData data) { + String stone = ""; + if (Stone.BLACK.equals(data.lastMoveColor)) stone = "B"; + else if (Stone.WHITE.equals(data.lastMoveColor)) stone = "W"; + else return stone; + + char x = data.lastMove == null ? 't' : (char) (data.lastMove[0] + 'a'); + char y = data.lastMove == null ? 't' : (char) (data.lastMove[1] + 'a'); + + String comment = ""; + if (data.comment != null && data.comment.trim().length() > 0) { + comment = String.format("C[%s]", data.comment); + } + return String.format(";%s[%c%c]%s", stone, x, y, comment); + } + + public static String trimGameInfo(String sgf) { + String gameInfo = String.format("(?s).*AP\\[Lizzie: %s\\]", + Lizzie.lizzieVersion); + return sgf.replaceFirst(gameInfo, "("); + } + + public static String[] splitAwAbSgf(String sgf) { + String[] ret = new String[2]; + String regex = "(A[BW]{1}(\\[[a-z]{2}\\])+)"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(sgf); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + sb.append(matcher.group(0)); + } + ret[0] = sb.toString(); + ret[1] = sgf.replaceAll(regex, ""); + return ret; + } + + public static Stone[] convertStones(String awAb) { + Stone[] stones = new Stone[Board.BOARD_SIZE * Board.BOARD_SIZE]; + for (int i = 0; i < stones.length; i++) { + stones[i] = Stone.EMPTY; + } + String regex = "(A[BW]{1})|(?<=\\[)([a-z]{2})(?=\\])"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(awAb); + StringBuilder sb = new StringBuilder(); + Stone stone = Stone.EMPTY; + while (matcher.find()) { + String str = matcher.group(0); + if("AB".equals(str)) { + stone = Stone.BLACK; + } else if("AW".equals(str)) { + stone = Stone.WHITE; + } else { + int[] move = SGFParser.convertSgfPosToCoord(str); + int index = Board.getIndex(move[0], move[1]); + stones[index] = stone; + } + } + return stones; + } +} diff --git a/src/test/java/featurecat/lizzie/rules/SGFParserTest.java b/src/test/java/featurecat/lizzie/rules/SGFParserTest.java new file mode 100644 index 000000000..893cb64e2 --- /dev/null +++ b/src/test/java/featurecat/lizzie/rules/SGFParserTest.java @@ -0,0 +1,124 @@ +package featurecat.lizzie.rules; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertArrayEquals; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import common.Util; + +import featurecat.lizzie.Config; +import featurecat.lizzie.Lizzie; +import featurecat.lizzie.analysis.Leelaz; +import featurecat.lizzie.gui.LizzieFrame; + +public class SGFParserTest { + + private Lizzie lizzie = null; + + @Test + + public void run() throws IOException { + lizzie = new Lizzie(); + lizzie.config = new Config(); + lizzie.board = new Board(); + lizzie.frame = new LizzieFrame(); + // new Thread( () -> { + lizzie.leelaz = new Leelaz(); + // }).start(); + + testVariaionOnly1(); + testFull1(); + } + + public void testVariaionOnly1() throws IOException { + + String sgfString = "(;B[pd];W[dp];B[pp];W[dd];B[fq]" + + "(;W[cn];B[cc];W[cd];B[dc];W[ed];B[fc];W[fd]" + + "(;B[gb]" + + "(;W[hc];B[nq])" + + "(;W[gc];B[ec];W[hc];B[hb];W[ic]))" + + "(;B[gc];W[ec];B[eb];W[fb];B[db];W[hc];B[gb];W[gd];B[hb]))" + + "(;W[nq];B[cn];W[fp];B[gp];W[fo];B[dq];W[cq];B[eq];W[cp];B[dm];W[fm]))"; + + int variationNum = 4; + String mainBranch = ";B[pd];W[dp];B[pp];W[dd];B[fq];W[cn];B[cc];W[cd];B[dc];W[ed];B[fc];W[fd];B[gb];W[hc];B[nq]"; + String variation1 = ";W[gc];B[ec];W[hc];B[hb];W[ic]"; + String variation2 = ";B[gc];W[ec];B[eb];W[fb];B[db];W[hc];B[gb];W[gd];B[hb]"; + String variation3 = ";W[nq];B[cn];W[fp];B[gp];W[fo];B[dq];W[cq];B[eq];W[cp];B[dm];W[fm]"; + + // Load correctly + boolean loaded = SGFParser.loadFromString(sgfString); + assertTrue(loaded); + + // Variations + List moveList = new ArrayList(); + Util.getVariationTree(moveList, 0, lizzie.board.getHistory().getCurrentHistoryNode(), 0, true); + + assertTrue(moveList != null); + assertEquals(moveList.size(), variationNum); + + assertEquals(moveList.get(0), mainBranch); + assertEquals(moveList.get(1), variation1); + assertEquals(moveList.get(2), variation2); + assertEquals(moveList.get(3), variation3); + + // Save correctly + String saveSgf = SGFParser.saveToString(); + assertTrue(saveSgf != null && saveSgf.trim().length() > 0); + + assertEquals(sgfString, Util.trimGameInfo(saveSgf)); + } + + public void testFull1() throws IOException { + + String sgfInfo = "(;CA[utf8]AP[MultiGo:4.4.4]SZ[19]"; + String sgfAwAb = "AB[pe][pq][oq][nq][mq][cp][dq][eq][fp]AB[qd]AW[dc][cf][oc][qo][op][np][mp][ep][fq]"; + String sgfContent = ";W[lp]C[25th question Overall view Black first Superior    White 1 has a long hand. The first requirement in the layout phase is to have a big picture.    What is the next black point in this situation?]" + + "(;B[qi]C[Correct Answer Limiting the thickness    Black 1 is broken. The reason why Black is under the command of four hands is to win the first hand and occupy the black one.    That is to say, on the lower side, the bigger one is the right side. Black 1 is both good and bad, and it limits the development of white and thick. It is good chess. Black 1 is appropriate, and it will not work if you go all the way or take a break.];W[lq];B[rp]C[1 Figure (turning head value?)    After black 1 , white is like 2 songs, then it is not too late to fly black again. There is a saying that \"the head is worth a thousand dollars\" in the chessboard, but in the situation of this picture, the white song has no such value.    Because after the next white A, black B, white must be on the lower side to be complete. It can be seen that for Black, the meaning of playing chess below is also not significant.    The following is a gesture that has come to an end. Both sides have no need to rush to settle down here.])" + + "(;B[kq];W[pi]C[2 diagram (failure)    Black 1 jump failed. The reason is not difficult to understand from the above analysis. If Black wants to jump out, he shouldn’t have four hands in the first place. By the white 2 on the right side of the hand, it immediately constitutes a strong appearance, black is not good. Although the black got some fixed ground below, but the position was too low, and it became a condensate, black is not worth the candle. ]))"; + String sgfString = sgfInfo + sgfAwAb + sgfContent; + + int variationNum = 2; + String mainBranch = ";W[lp]C[25th question Overall view Black first Superior    White 1 has a long hand. The first requirement in the layout phase is to have a big picture.    What is the next black point in this situation?];B[qi]C[Correct Answer Limiting the thickness    Black 1 is broken. The reason why Black is under the command of four hands is to win the first hand and occupy the black one.    That is to say, on the lower side, the bigger one is the right side. Black 1 is both good and bad, and it limits the development of white and thick. It is good chess. Black 1 is appropriate, and it will not work if you go all the way or take a break.];W[lq];B[rp]C[1 Figure (turning head value?)    After black 1 , white is like 2 songs, then it is not too late to fly black again. There is a saying that \"the head is worth a thousand dollars\" in the chessboard, but in the situation of this picture, the white song has no such value.    Because after the next white A, black B, white must be on the lower side to be complete. It can be seen that for Black, the meaning of playing chess below is also not significant.    The following is a gesture that has come to an end. Both sides have no need to rush to settle down here.]"; + String variation1 = ";B[kq];W[pi]C[2 diagram (failure)    Black 1 jump failed. The reason is not difficult to understand from the above analysis. If Black wants to jump out, he shouldn’t have four hands in the first place. By the white 2 on the right side of the hand, it immediately constitutes a strong appearance, black is not good. Although the black got some fixed ground below, but the position was too low, and it became a condensate, black is not worth the candle. ]"; + + Stone[] expectStones = Util.convertStones(sgfAwAb); + + // Load correctly + boolean loaded = SGFParser.loadFromString(sgfString); + assertTrue(loaded); + + // Variations + List moveList = new ArrayList(); + Util.getVariationTree(moveList, 0, lizzie.board.getHistory().getCurrentHistoryNode(), 0, true); + + assertTrue(moveList != null); + assertEquals(moveList.size(), variationNum); + assertEquals(moveList.get(0), mainBranch); + assertEquals(moveList.get(1), variation1); + + // AW/AB + assertArrayEquals(expectStones, Lizzie.board.getHistory().getStones()); + + // Save correctly + String saveSgf = SGFParser.saveToString(); + assertTrue(saveSgf != null && saveSgf.trim().length() > 0); + + String sgf = Util.trimGameInfo(saveSgf); + String[] ret = Util.splitAwAbSgf(sgf); + Stone[] actualStones = Util.convertStones(ret[0]); + + // AW/AB + assertArrayEquals(expectStones, actualStones); + + // Content + assertEquals("(" + sgfContent, ret[1]); + } + +}