diff --git a/backend/src/test/java/djmil/cordacheckers/cordaclient/GameBoardTests.java b/backend/src/test/java/djmil/cordacheckers/cordaclient/GameBoardTests.java index 86d79ab..3d9ccfd 100644 --- a/backend/src/test/java/djmil/cordacheckers/cordaclient/GameBoardTests.java +++ b/backend/src/test/java/djmil/cordacheckers/cordaclient/GameBoardTests.java @@ -95,7 +95,21 @@ public class GameBoardTests { assertThat(m0.board().get(18)).isNull(); assertThat(m1.board().get(22)).isNull(); assertThat(m1.board().get(18)).isEqualTo(WHITE_MAN); + assertThat(m1.moveNumber() == 1); assertThat(m1.status()).isEqualByComparingTo(Status.GAME_BOARD_WAIT_FOR_OPPONENT); + + assertThatThrownBy(() -> { + cordaClient.gameBoardMove(hiBlack, game.uuid(), move(12, 13), + "Prohibitted move shall be rejected"); + }); + + final var m2 = cordaClient.gameBoardMove(hiBlack, game.uuid(), move(11, 15), null); + assertThat(m1.board().get(11)).isEqualTo(BLACK_MAN); + assertThat(m1.board().get(15)).isNull(); + assertThat(m2.board().get(11)).isNull(); + assertThat(m2.board().get(15)).isEqualTo(BLACK_MAN); + assertThat(m2.moveNumber() == 2); + assertThat(m2.status()).isEqualByComparingTo(Status.GAME_BOARD_WAIT_FOR_OPPONENT); } ArrayList move(int from, int to) { diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/checkers/Move.java b/corda/contracts/src/main/java/djmil/cordacheckers/checkers/Move.java new file mode 100644 index 0000000..d6e4196 --- /dev/null +++ b/corda/contracts/src/main/java/djmil/cordacheckers/checkers/Move.java @@ -0,0 +1,194 @@ +package djmil.cordacheckers.checkers; + +import static java.lang.Math.abs; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class Move { + final public Integer from; + final public Integer to; + final public Integer jumpOver; + + public Move(int from, int to) { + this.from = from; + this.to = to; + + Set fromSteps = getSteps(from); + if (fromSteps.contains(to)) + this.jumpOver = null; + else + this.jumpOver = intersection(fromSteps, getSteps(to)); + } + + public static Stone.Color apply(List moves, Map board, Stone.Color moveColor) { + Move move = new Move(moves.get(0), moves.get(1)); + return move.apply(board, moveColor); + } + + private Stone.Color apply(Map board, Stone.Color expectedMoveColor) { + Stone movingStone = board.remove(from); + if (movingStone == null) + throw new Exception("An empty starting tile"); + + if (movingStone.getColor() != expectedMoveColor) + throw new Exception("Only " +expectedMoveColor.name() +" color is expected to move"); + + final Set allMoves = movingStone.getMoves(from); + if (!allMoves.contains(this)) + throw new Exception("Prohibited move"); + + // TODO: check for mandatory captures + + movingStone = movingStone.promoteIfPossible(to); + if (board.put(to, movingStone) != null) + throw new Exception("An occupied finishing tile"); + + if (isJump()) { + final Stone jumpOver = board.remove(this.jumpOver); + if (jumpOver == null || jumpOver.getColor() != expectedMoveColor.opposite()) + throw new Exception("Must jump over an opponent's stone"); + } + + return expectedMoveColor.opposite(); + } + + public boolean isJump() { + return this.jumpOver != null; + } + + static Integer intersection(Set a, Set b) { + Set intersection = new HashSet(a); + + intersection.retainAll(b); + if (intersection.size() != 1) + // A legit move is characterized by single intersection point + throw new Exception("Prohibited move"); + + return intersection.iterator().next(); + } + + static Set getSteps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> abs(idx - cur) <= 5) + .collect(Collectors.toSet()); + } + + static Set getJumps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> abs(idx - cur) > 5) + .collect(Collectors.toSet()); + } + + static Set getBlackSteps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> cur > idx && cur - idx <= 5) + .collect(Collectors.toSet()); + } + + static Set getWhiteSteps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> cur < idx && idx - cur <= 5) + .collect(Collectors.toSet()); + } + + static Set getBlackJumps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> cur > idx && cur - idx > 5) + .collect(Collectors.toSet()); + } + + static Set getWhiteJumps(Integer idx) { + return adjacentCell.get(idx).stream() + .filter(cur -> cur < idx && idx - cur > 5) + .collect(Collectors.toSet()); + } + + final static Map> adjacentCell = Map.ofEntries( + Map.entry(1, Arrays.asList(5, 6, 10)), + Map.entry(2, Arrays.asList(6, 7, 9, 11)), + Map.entry(3, Arrays.asList(7, 8, 10, 12)), + Map.entry(4, Arrays.asList(8, 11)), + Map.entry(5, Arrays.asList(1, 9, 14, 9, 11)), + Map.entry(6, Arrays.asList(1, 2, 9, 10, 13, 15)), + Map.entry(7, Arrays.asList(2, 3, 10, 11, 14, 16)), + Map.entry(8, Arrays.asList(3, 4, 11, 12, 15)), + Map.entry(9, Arrays.asList(2, 5, 6, 13, 14, 18)), + Map.entry(10, Arrays.asList(1, 3, 6, 7, 14, 15, 17, 19)), + Map.entry(11, Arrays.asList(2, 4, 7, 8, 15, 16, 18, 20)), + Map.entry(12, Arrays.asList(3, 8, 16, 19)), + Map.entry(13, Arrays.asList(6, 9, 17, 22)), + Map.entry(14, Arrays.asList(5, 7, 9, 10, 17, 18, 21, 23)), + Map.entry(15, Arrays.asList(6, 8, 10, 11, 18, 19, 22, 24)), + Map.entry(16, Arrays.asList(7, 11, 12, 19, 20, 23)), + Map.entry(17, Arrays.asList(10, 13, 14, 21, 22, 26)), + Map.entry(18, Arrays.asList(9, 11, 14, 15, 22, 23, 25, 27)), + Map.entry(19, Arrays.asList(10, 12, 15, 16, 23, 24)), + Map.entry(20, Arrays.asList(11, 16, 24, 27)), + Map.entry(21, Arrays.asList(14, 17, 25, 30)), + Map.entry(22, Arrays.asList(13, 15, 17, 18, 25, 26, 29, 31)), + Map.entry(23, Arrays.asList(14, 16, 18, 19, 26, 27, 30, 32)), + Map.entry(24, Arrays.asList(15, 19, 20, 27, 28, 31)), + Map.entry(25, Arrays.asList(18, 21, 22, 29, 30)), + Map.entry(26, Arrays.asList(17, 19, 22, 23, 30, 31)), + Map.entry(27, Arrays.asList(18, 20, 23, 24, 31, 32)), + Map.entry(28, Arrays.asList(19, 24, 32)), + Map.entry(29, Arrays.asList(22, 25)), + Map.entry(30, Arrays.asList(21, 23, 25, 26)), + Map.entry(31, Arrays.asList(22, 24, 26, 27)), + Map.entry(32, Arrays.asList(23, 27, 28)) + ); + + public static class Exception extends RuntimeException { + public Exception(String message) { + super(message); + } + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((from == null) ? 0 : from.hashCode()); + result = prime * result + ((to == null) ? 0 : to.hashCode()); + result = prime * result + ((jumpOver == null) ? 0 : jumpOver.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Move other = (Move) obj; + if (from == null) { + if (other.from != null) + return false; + } else if (!from.equals(other.from)) + return false; + if (to == null) { + if (other.to != null) + return false; + } else if (!to.equals(other.to)) + return false; + if (jumpOver == null) { + if (other.jumpOver != null) + return false; + } else if (!jumpOver.equals(other.jumpOver)) + return false; + return true; + } + + @Override + public String toString() { + return "[from=" + from + ", to=" + to + ", jumpOver=" + jumpOver + "]"; + } + +} diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/Stone.java b/corda/contracts/src/main/java/djmil/cordacheckers/checkers/Stone.java similarity index 50% rename from corda/contracts/src/main/java/djmil/cordacheckers/states/Stone.java rename to corda/contracts/src/main/java/djmil/cordacheckers/checkers/Stone.java index 2e2c93a..0c82ba8 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/states/Stone.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/checkers/Stone.java @@ -1,4 +1,7 @@ -package djmil.cordacheckers.states; +package djmil.cordacheckers.checkers; + +import java.util.HashSet; +import java.util.Set; import net.corda.v5.base.annotations.ConstructorForDeserialization; import net.corda.v5.base.annotations.CordaSerializable; @@ -31,11 +34,6 @@ public class Stone { private final Color color; private final Type type; - public final static Stone WHITE_MAN = new Stone(Stone.Color.WHITE, Stone.Type.MAN); - public final static Stone WHITE_KING = new Stone(Stone.Color.WHITE, Stone.Type.KING); - public final static Stone BLACK_MAN = new Stone(Stone.Color.BLACK, Stone.Type.MAN); - public final static Stone BLACK_KING = new Stone(Stone.Color.BLACK, Stone.Type.KING); - @ConstructorForDeserialization public Stone(Color color, Type type) { this.color = color; @@ -66,4 +64,49 @@ public class Stone { return true; } + public Set getJumps(Integer from) { + Set jumps; + + if (type == Type.KING) + jumps = Move.getJumps(from); + else if (color == Color.BLACK) + jumps = Move.getBlackJumps(from); + else + jumps = Move.getWhiteJumps(from); + + Set res = new HashSet<>(); + + for (Integer jump : jumps) + res.add(new Move(from, jump)); + + return res; + } + + public Set getMoves(Integer from) { + Set steps; + + if (type == Type.KING) + steps = Move.getSteps(from); + else if (color == Color.BLACK) + steps = Move.getBlackSteps(from); + else + steps = Move.getWhiteSteps(from); + + Set moves = getJumps(from); // <<--- Steps + Jumps + + for (Integer step : steps) { + moves.add(new Move(from, step)); + } + + return moves; + } + + public Stone promoteIfPossible(Integer to) { + if ((color == Color.WHITE && to >= 1 && to <= 4) || + (color == Color.BLACK && to >= 29 && to <= 32)) + return new Stone(color, Type.KING); + else + return this; + } + } diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameBoardContract.java b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameBoardContract.java index 407aca3..ce66b15 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameBoardContract.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameBoardContract.java @@ -3,35 +3,16 @@ package djmil.cordacheckers.contracts; import static djmil.cordacheckers.contracts.GameCommand.requireThat; import static djmil.cordacheckers.contracts.UtxoLedgerTransactionUtil.getSingleCommand; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import djmil.cordacheckers.states.Stone; -import djmil.cordacheckers.states.Stone.Color; +import djmil.cordacheckers.checkers.Stone; import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; public class GameBoardContract implements net.corda.v5.ledger.utxo.Contract { - public static class MoveResult { - final public Map board; - final public Stone.Color moveColor; - - public MoveResult(Map board, Color moveColor) { - this.board = board; - this.moveColor = moveColor; - } - - public static class Exception extends RuntimeException { - public Exception(String message) { - super(message); - } - } - } - private final static Logger log = LoggerFactory.getLogger(GameBoardContract.class); @Override @@ -59,55 +40,33 @@ public class GameBoardContract implements net.corda.v5.ledger.utxo.Contract { } } - public static MoveResult applyMove(List move, Map board, Stone.Color moveColor) { - final int mFrom = move.get(0); - final int mTo = move.get(1); - - final Stone piece = board.get(mFrom); - if (piece == null) - throw new MoveResult.Exception("An empty starting tile"); - - if (piece.getColor() != moveColor) - throw new MoveResult.Exception("Can not move opponent's piece"); - - if (board.get(mTo) != null) - throw new MoveResult.Exception("An occupied finishing tile"); - - final Map newBoard = new LinkedHashMap(board); - newBoard.remove(mFrom); - newBoard.put(mTo, piece); - - return new GameBoardContract.MoveResult(newBoard, moveColor.opposite()); - } - public final static Map initialBoard = Map.ofEntries( // Inspired by Checkers notation rules: https://www.bobnewell.net/nucleus/checkers.php - Map.entry( 1, Stone.BLACK_MAN), - Map.entry( 2, Stone.BLACK_MAN), - Map.entry( 3, Stone.BLACK_MAN), - Map.entry( 4, Stone.BLACK_MAN), - Map.entry( 5, Stone.BLACK_MAN), - Map.entry( 6, Stone.BLACK_MAN), - Map.entry( 7, Stone.BLACK_MAN), - Map.entry( 8, Stone.BLACK_MAN), - Map.entry( 9, Stone.BLACK_MAN), - Map.entry(10, Stone.BLACK_MAN), - Map.entry(11, Stone.BLACK_MAN), - Map.entry(12, Stone.BLACK_MAN), - - Map.entry(21, Stone.WHITE_MAN), - Map.entry(22, Stone.WHITE_MAN), - Map.entry(23, Stone.WHITE_MAN), - Map.entry(24, Stone.WHITE_MAN), - Map.entry(25, Stone.WHITE_MAN), - Map.entry(26, Stone.WHITE_MAN), - Map.entry(27, Stone.WHITE_MAN), - Map.entry(28, Stone.WHITE_MAN), - Map.entry(29, Stone.WHITE_MAN), - Map.entry(30, Stone.WHITE_MAN), - Map.entry(31, Stone.WHITE_MAN), - Map.entry(32, Stone.WHITE_MAN) - ); + Map.entry( 1, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 2, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 3, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 4, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 5, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 6, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 7, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 8, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry( 9, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry(10, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry(11, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry(12, new Stone(Stone.Color.BLACK, Stone.Type.MAN)), + Map.entry(21, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(22, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(23, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(24, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(25, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(26, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(27, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(28, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(29, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(30, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(31, new Stone(Stone.Color.WHITE, Stone.Type.MAN)), + Map.entry(32, new Stone(Stone.Color.WHITE, Stone.Type.MAN)) + ); } diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameBoardState.java b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameBoardState.java index 6f5f28c..cfc56ed 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameBoardState.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameBoardState.java @@ -6,9 +6,9 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import djmil.cordacheckers.checkers.Move; +import djmil.cordacheckers.checkers.Stone; import djmil.cordacheckers.contracts.GameBoardContract; -import djmil.cordacheckers.contracts.GameBoardContract.MoveResult; -import djmil.cordacheckers.states.Stone.Color; import net.corda.v5.base.annotations.ConstructorForDeserialization; import net.corda.v5.base.types.MemberX500Name; import net.corda.v5.ledger.utxo.BelongsToContract; @@ -31,11 +31,10 @@ public class GameBoardState extends GameState { public GameBoardState(GameBoardState currentGameBoardState, List move, String message) { super(currentGameBoardState.whitePlayer, currentGameBoardState.blackPlayer, currentGameBoardState.gameUuid, message, currentGameBoardState.participants); - - final MoveResult moveResult = GameBoardContract.applyMove(move, currentGameBoardState.getBoard(), currentGameBoardState.getMoveColor()); - this.moveColor = moveResult.moveColor; - this.board = moveResult.board; - + + this.board = new LinkedHashMap(currentGameBoardState.getBoard()); + this.moveColor = Move.apply(move, this.board, currentGameBoardState.getMoveColor()); + this.moveNumber = (currentGameBoardState.moveColor == this.moveColor) ? currentGameBoardState.getMoveNumber() // current player has not finished his move jet : currentGameBoardState.getMoveNumber() +1; @@ -43,7 +42,7 @@ public class GameBoardState extends GameState { @ConstructorForDeserialization public GameBoardState(MemberX500Name whitePlayer, MemberX500Name blackPlayer, - Color moveColor, Integer moveNumber, Map board, String message, + Stone.Color moveColor, Integer moveNumber, Map board, String message, UUID gameUuid, List participants) { super(whitePlayer, blackPlayer, gameUuid, message, participants); diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameState.java b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameState.java index 8b93508..76dd5d5 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameState.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameState.java @@ -5,6 +5,7 @@ import java.util.LinkedList; import java.util.List; import java.util.UUID; +import djmil.cordacheckers.checkers.Stone; import net.corda.v5.base.annotations.CordaSerializable; import net.corda.v5.base.types.MemberX500Name; import net.corda.v5.ledger.utxo.ContractState; diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java index 4199280..fc59632 100644 --- a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java @@ -10,13 +10,13 @@ import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import djmil.cordacheckers.checkers.Stone; import djmil.cordacheckers.contracts.GameCommand; import djmil.cordacheckers.gamestate.CommitSubFlow; import djmil.cordacheckers.gamestate.FlowResponce; import djmil.cordacheckers.gamestate.View; import djmil.cordacheckers.gamestate.ViewBuilder; import djmil.cordacheckers.states.GameProposalState; -import djmil.cordacheckers.states.Stone; import net.corda.v5.application.flows.ClientRequestBody; import net.corda.v5.application.flows.ClientStartableFlow; import net.corda.v5.application.flows.CordaInject; diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gamestate/View.java b/corda/workflows/src/main/java/djmil/cordacheckers/gamestate/View.java index 84d1234..a6e2737 100644 --- a/corda/workflows/src/main/java/djmil/cordacheckers/gamestate/View.java +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gamestate/View.java @@ -4,10 +4,10 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import djmil.cordacheckers.checkers.Stone; import djmil.cordacheckers.states.GameBoardState; import djmil.cordacheckers.states.GameProposalState; import djmil.cordacheckers.states.GameResultState; -import djmil.cordacheckers.states.Stone; import net.corda.v5.base.types.MemberX500Name; // GameBoard from the player's point of view