From cabb928ffeebf44ca5934155abcc7042eb5725db Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 27 Dec 2025 11:58:27 +0000 Subject: [PATCH 1/4] Spades -fixed main redeterminisation issue --- src/main/java/games/GameType.java | 11 +- .../java/games/spades/SpadesForwardModel.java | 341 ++++++++++ .../java/games/spades/SpadesGameState.java | 335 ++++++++++ .../java/games/spades/SpadesHeuristic.java | 148 +++++ .../java/games/spades/SpadesParameters.java | 60 ++ src/main/java/games/spades/actions/Bid.java | 83 +++ .../java/games/spades/actions/PlayCard.java | 84 +++ .../games/spades/gui/SpadesGUIManager.java | 302 +++++++++ .../games/spades/gui/SpadesPlayerView.java | 164 +++++ .../games/spades/gui/SpadesScoreView.java | 141 +++++ .../games/spades/gui/SpadesTrickView.java | 174 ++++++ src/test/java/games/spades/TestSpades.java | 589 ++++++++++++++++++ 12 files changed, 2431 insertions(+), 1 deletion(-) create mode 100644 src/main/java/games/spades/SpadesForwardModel.java create mode 100644 src/main/java/games/spades/SpadesGameState.java create mode 100644 src/main/java/games/spades/SpadesHeuristic.java create mode 100644 src/main/java/games/spades/SpadesParameters.java create mode 100644 src/main/java/games/spades/actions/Bid.java create mode 100644 src/main/java/games/spades/actions/PlayCard.java create mode 100644 src/main/java/games/spades/gui/SpadesGUIManager.java create mode 100644 src/main/java/games/spades/gui/SpadesPlayerView.java create mode 100644 src/main/java/games/spades/gui/SpadesScoreView.java create mode 100644 src/main/java/games/spades/gui/SpadesTrickView.java create mode 100644 src/test/java/games/spades/TestSpades.java diff --git a/src/main/java/games/GameType.java b/src/main/java/games/GameType.java index 4ead38c05..fabd301ef 100644 --- a/src/main/java/games/GameType.java +++ b/src/main/java/games/GameType.java @@ -109,6 +109,10 @@ import games.saboteur.SaboteurGameParameters; import games.saboteur.SaboteurGameState; import games.saboteur.gui.SaboteurGUIManager; +import games.spades.SpadesForwardModel; +import games.spades.SpadesGameState; +import games.spades.SpadesParameters; +import games.spades.gui.SpadesGUIManager; import games.stratego.StrategoForwardModel; import games.stratego.StrategoGameState; import games.stratego.StrategoParams; @@ -338,7 +342,12 @@ public enum GameType { Arrays.asList(Strategy, Abstract), Arrays.asList(GridMovement), ChessGameState.class, ChessForwardModel.class, ChessParameters.class, ChessGUIManager.class), - Pickomino(2, 7, Collections.singletonList(Dice), Collections.singletonList(DiceRolling), PickominoGameState.class, PickominoForwardModel.class, PickominoParameters.class, PickominoGUIManager.class); + Pickomino(2, 7, Collections.singletonList(Dice), Collections.singletonList(DiceRolling), + PickominoGameState.class, PickominoForwardModel.class, PickominoParameters.class, + PickominoGUIManager.class), + Spades(4,4,Arrays.asList(Cards, Strategy), + Arrays.asList(TrickTaking, HandManagement, TakeThat), + SpadesGameState.class, SpadesForwardModel.class, SpadesParameters.class, SpadesGUIManager.class); // Core classes where the game is defined diff --git a/src/main/java/games/spades/SpadesForwardModel.java b/src/main/java/games/spades/SpadesForwardModel.java new file mode 100644 index 000000000..93d155244 --- /dev/null +++ b/src/main/java/games/spades/SpadesForwardModel.java @@ -0,0 +1,341 @@ +package games.spades; + +import core.AbstractGameState; +import core.CoreConstants; +import core.StandardForwardModel; +import core.actions.AbstractAction; +import core.components.Deck; +import core.components.FrenchCard; +import games.spades.actions.Bid; +import games.spades.actions.PlayCard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class SpadesForwardModel extends StandardForwardModel { + + @Override + protected void _setup(AbstractGameState firstState) { + SpadesGameState state = (SpadesGameState) firstState; + if (firstState.getRoundCounter() == 0) { + Arrays.fill(state.teamScores, 0); + Arrays.fill(state.teamSandbags, 0); + for (int i = 0; i < 4; i++) { + state.playerBids[i] = -1; + state.tricksTaken[i] = 0; + state.tricksWon.get(i).clear(); + } + state.currentTrick.clear(); + state.spadesBroken = false; + state.leadSuit = null; + } + + for (int i = 0; i < 4; i++) { + Deck hand = state.getPlayerHands().get(i); + hand.clear(); + hand.setOwnerId(i); + } + + Deck deck = FrenchCard.generateDeck("MainDeck", CoreConstants.VisibilityMode.HIDDEN_TO_ALL); + deck.shuffle(state.getRnd()); + + for (int i = 0; i < 13; i++) { + for (int p = 0; p < 4; p++) { + FrenchCard card = deck.draw(); + state.getPlayerHands().get(p).add(card); + card.setOwnerId(p); + } + } + + state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); + + endPlayerTurn(state, 1); + state.setLeadPlayer(1); + } + + @Override + protected List _computeAvailableActions(AbstractGameState gameState) { + SpadesGameState state = (SpadesGameState) gameState; + List actions = new ArrayList<>(); + int currentPlayer = state.getCurrentPlayer(); + SpadesParameters params = (SpadesParameters) state.getGameParameters(); + + if (state.getSpadesGamePhase() == SpadesGameState.Phase.BIDDING) { + int team = state.getTeam(currentPlayer); + boolean nilAllowed = params.allowNilOverbid || state.getTeamScore(team) < 500; + int minBid = Math.max(0, params.minBid); + int maxBid = Math.min(13, params.maxBid); + for (int bid = minBid; bid <= maxBid; bid++) { + if (bid == 0 && !nilAllowed) continue; // restrict Nil if house rule disallows it at high scores + actions.add(new Bid(currentPlayer, bid)); + } + if (params.allowBlindNil && nilAllowed) { + // Offer Blind Nil as a distinct bid option (uses Bid with blind flag) + actions.add(new Bid(currentPlayer, 0, true)); + } + } else if (state.getSpadesGamePhase() == SpadesGameState.Phase.PLAYING) { + Deck playerHand = state.getPlayerHands().get(currentPlayer); + + for (FrenchCard card : playerHand.getComponents()) { + if (isValidPlay(state, card)) { + actions.add(new PlayCard(currentPlayer, card)); + } + } + } + + return actions; + } + + /** + * Determines legal card plays + */ + private boolean isValidPlay(SpadesGameState state, FrenchCard card) { + List> currentTrick = state.getCurrentTrick(); + int currentPlayer = state.getCurrentPlayer(); + Deck playerHand = state.getPlayerHands().get(currentPlayer); + + if (currentTrick.isEmpty()) { + if (card.suite == FrenchCard.Suite.Spades) { + if (!state.isSpadesBroken()) { + boolean hasOnlySpades = playerHand.getComponents().stream() + .allMatch(c -> c.suite == FrenchCard.Suite.Spades); + return hasOnlySpades; + } + } + return true; + } else { + FrenchCard.Suite leadSuit = state.getLeadSuit(); + if (card.suite == leadSuit) { + return true; + } + + boolean hasLeadSuit = playerHand.getComponents().stream() + .anyMatch(c -> c.suite == leadSuit); + + return !hasLeadSuit; + } + } + + @Override + protected void _afterAction(AbstractGameState currentState, AbstractAction actionTaken) { + SpadesGameState state = (SpadesGameState) currentState; + + if (actionTaken instanceof Bid) { + if (state.allPlayersBid()) { + state.setSpadesGamePhase(SpadesGameState.Phase.PLAYING); + endPlayerTurn(state, 1); + state.setLeadPlayer(1); + } else { + endPlayerTurn(state); + } + } else if (actionTaken instanceof PlayCard) { + PlayCard playAction = (PlayCard) actionTaken; + + if (state.getCurrentTrick().size() == 1) { + state.setLeadSuit(playAction.card.suite); + } + + if (playAction.card.suite == FrenchCard.Suite.Spades) { + state.setSpadesBroken(true); + } + + if (state.getCurrentTrick().size() == 4) { + int trickWinner = determineTrickWinner(state); + state.incrementTricksTaken(trickWinner); + + Deck trickDeck = new Deck<>("Trick", CoreConstants.VisibilityMode.VISIBLE_TO_ALL); + for (Map.Entry entry : state.getCurrentTrick()) { + trickDeck.add(entry.getValue()); + } + state.tricksWon.get(trickWinner).add(trickDeck); + + state.clearCurrentTrick(); + + endPlayerTurn(state, trickWinner); + state.setLeadPlayer(trickWinner); + + if (state.getPlayerHands().get(0).getSize() == 0) { + endRound(state); + } else { + // Continue with next trick + } + } else { + endPlayerTurn(state); + } + } + } + + /** + * Determines the winner of a completed trick + */ + private int determineTrickWinner(SpadesGameState state) { + List> trick = state.getCurrentTrick(); + FrenchCard.Suite leadSuit = state.getLeadSuit(); + + int winner = trick.get(0).getKey(); + FrenchCard winningCard = trick.get(0).getValue(); + + for (Map.Entry entry : trick) { + FrenchCard card = entry.getValue(); + + if (card.suite == FrenchCard.Suite.Spades && winningCard.suite != FrenchCard.Suite.Spades) { + winner = entry.getKey(); + winningCard = card; + } else if (card.suite == FrenchCard.Suite.Spades && winningCard.suite == FrenchCard.Suite.Spades) { + if (getCardValue(card) > getCardValue(winningCard)) { + winner = entry.getKey(); + winningCard = card; + } + } else if (card.suite == leadSuit && winningCard.suite != FrenchCard.Suite.Spades) { + if (winningCard.suite != leadSuit || getCardValue(card) > getCardValue(winningCard)) { + winner = entry.getKey(); + winningCard = card; + } + } + } + + return winner; + } + + /** + * Gets the value of a card for comparison (higher is better) + */ + private int getCardValue(FrenchCard card) { + if (card.type == FrenchCard.FrenchCardType.Ace) return 14; + if (card.type == FrenchCard.FrenchCardType.King) return 13; + if (card.type == FrenchCard.FrenchCardType.Queen) return 12; + if (card.type == FrenchCard.FrenchCardType.Jack) return 11; + return card.number; + } + + /** + * Handles end of round scoring and checks for game end + */ + private void endRound(SpadesGameState state) { + SpadesParameters params = (SpadesParameters) state.getGameParameters(); + + for (int team = 0; team < 2; team++) { + // Teams in Spades are (0,2) and (1,3) + int player1 = team; // 0 for team 0, 1 for team 1 + int player2 = team + 2; // 2 for team 0, 3 for team 1 + + int bid1 = state.getPlayerBid(player1); + int bid2 = state.getPlayerBid(player2); + int tricks1 = state.getTricksTaken(player1); + int tricks2 = state.getTricksTaken(player2); + int teamTricks = tricks1 + tricks2; + + int teamBid = 0; + // Only positive bids contribute to team bid; 0 is Nil and scored separately + if (bid1 > 0) teamBid += bid1; + + if (bid2 > 0) teamBid += bid2; + + int teamScore = state.getTeamScore(team); + + // Score Nil bids per player + if (bid1 == 0) { + boolean blind1 = state.playerBlindNil[player1]; + int bonus = blind1 ? params.blindNilBonusPoints : params.nilBonusPoints; + int penalty = blind1 ? params.blindNilPenaltyPoints : params.nilPenaltyPoints; + teamScore += (tricks1 == 0) ? bonus : -penalty; + } + if (bid2 == 0) { + boolean blind2 = state.playerBlindNil[player2]; + int bonus = blind2 ? params.blindNilBonusPoints : params.nilBonusPoints; + int penalty = blind2 ? params.blindNilPenaltyPoints : params.nilPenaltyPoints; + teamScore += (tricks2 == 0) ? bonus : -penalty; + } + + // Team contract score (for non-nil bids) + if (teamBid > 0) { + if (teamTricks >= teamBid) { + int basePoints = teamBid * 10; + int sandBags = teamTricks - teamBid; + teamScore += basePoints + sandBags; + + state.addTeamSandbags(team, sandBags); + + while (state.getTeamSandbags(team) >= params.sandbagsPerPenalty) { + teamScore -= params.sandbagsRandPenalty; + state.addTeamSandbags(team, -params.sandbagsPerPenalty); + } + } else { + // If any teammate bid Nil, do not double-penalize the team for missing the non-nil contract. + // Standard house rules apply contract penalty regardless; keep it simple but bounded. + int penalty = teamBid * 10; + teamScore -= penalty; + } + } else { + // Both players bid Nil: no contract, and by default do NOT count tricks as sandbags + // (only Nil bonuses/penalties apply) + } + + state.setTeamScore(team, teamScore); + } + + boolean gameEnded = false; + for (int team = 0; team < 2; team++) { + if (state.getTeamScore(team) >= params.winningScore) { + gameEnded = true; + break; + } + } + + if (gameEnded) { + int winningTeam = state.getTeamScore(0) > state.getTeamScore(1) ? 0 : 1; + for (int p = 0; p < 4; p++) { + if (state.getTeam(p) == winningTeam) { + state.setPlayerResult(CoreConstants.GameResult.WIN_GAME, p); + } else { + state.setPlayerResult(CoreConstants.GameResult.LOSE_GAME, p); + } + } + state.setGameStatus(CoreConstants.GameResult.GAME_END); + } else { + super.endRound(state); + if (state.getGameStatus() == CoreConstants.GameResult.GAME_ONGOING) { + startNewRound(state); + } + } + } + + /** + * Starts a new round of play + */ + private void startNewRound(SpadesGameState state) { + for (int i = 0; i < 4; i++) { + state.playerBids[i] = -1; + state.tricksTaken[i] = 0; + state.tricksWon.get(i).clear(); + } + + state.currentTrick.clear(); + state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); + state.setSpadesBroken(false); + state.leadSuit = null; + + _setup(state); + } + + @Override + protected void endGame(AbstractGameState gs) { + // Override to set team-based winners by score when framework triggers end (e.g., maxRounds) + SpadesGameState state = (SpadesGameState) gs; + int team0 = state.getTeamScore(0); + int team1 = state.getTeamScore(1); + if (team0 == team1) { + for (int p = 0; p < 4; p++) state.setPlayerResult(CoreConstants.GameResult.DRAW_GAME, p); + } else { + int winningTeam = team0 > team1 ? 0 : 1; + for (int p = 0; p < 4; p++) { + if (state.getTeam(p) == winningTeam) state.setPlayerResult(CoreConstants.GameResult.WIN_GAME, p); + else state.setPlayerResult(CoreConstants.GameResult.LOSE_GAME, p); + } + } + state.setGameStatus(CoreConstants.GameResult.GAME_END); + if (gs.getCoreGameParameters().verbose) System.out.println(Arrays.toString(gs.getPlayerResults())); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/SpadesGameState.java b/src/main/java/games/spades/SpadesGameState.java new file mode 100644 index 000000000..e2123014a --- /dev/null +++ b/src/main/java/games/spades/SpadesGameState.java @@ -0,0 +1,335 @@ +package games.spades; + +import core.AbstractGameState; +import core.AbstractParameters; +import core.CoreConstants; +import core.components.Component; +import core.components.Deck; +import core.components.FrenchCard; +import core.interfaces.IGamePhase; +import core.interfaces.IPrintable; +import games.GameType; +import utilities.DeterminisationUtilities; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SpadesGameState extends AbstractGameState implements IPrintable { + + List> playerHands; + public List> currentTrick = new ArrayList<>(); + public List>> tricksWon; + public int[] playerBids; + public int[] tricksTaken; + public int[] teamScores; + public int[] teamSandbags; + public boolean[] playerBlindNil; + public int leadPlayer; + public Phase gamePhase = Phase.BIDDING; + public FrenchCard.Suite leadSuit; + public boolean spadesBroken = false; + + public enum Phase implements IGamePhase { + BIDDING, + PLAYING + } + + public SpadesGameState(AbstractParameters gameParameters, int nPlayers) { + super(gameParameters, nPlayers); + + this.nTeams = 2; + + playerHands = new ArrayList<>(); + tricksWon = new ArrayList<>(); + playerBids = new int[4]; + Arrays.fill(playerBids, -1); // -1 means not bid yet + tricksTaken = new int[4]; + teamScores = new int[2]; + teamSandbags = new int[2]; + playerBlindNil = new boolean[4]; + leadPlayer = 0; + + for (int i = 0; i < 4; i++) { + Deck hand = new Deck<>("Player" + i + "Hand", CoreConstants.VisibilityMode.VISIBLE_TO_OWNER); + hand.setOwnerId(i); + playerHands.add(hand); + tricksWon.add(new ArrayList<>()); + } + } + + @Override + protected GameType _getGameType() { + return GameType.Spades; + } + + @Override + protected List _getAllComponents() { + List components = new ArrayList<>(); + + for (Deck hand : playerHands) { + components.add(hand); + components.addAll(hand.getComponents()); + } + + for (List> playerTricks : tricksWon) { + for (Deck trick : playerTricks) { + components.add(trick); + components.addAll(trick.getComponents()); + } + } + + for (Map.Entry entry : currentTrick) { + components.add(entry.getValue()); + } + + return components; + } + + @Override + protected SpadesGameState _copy(int playerId) { + SpadesGameState copy = new SpadesGameState(gameParameters, getNPlayers()); + + copy.currentTrick = new ArrayList<>(); + for (Map.Entry entry : currentTrick) { + copy.currentTrick.add(new HashMap.SimpleEntry<>(entry.getKey(), entry.getValue().copy())); + } + + copy.tricksWon = new ArrayList<>(); + for (List> playerTricks : tricksWon) { + List> copyTricks = new ArrayList<>(); + for (Deck trick : playerTricks) { + copyTricks.add(trick.copy()); + } + copy.tricksWon.add(copyTricks); + } + + copy.playerBids = Arrays.copyOf(playerBids, playerBids.length); + copy.tricksTaken = Arrays.copyOf(tricksTaken, tricksTaken.length); + copy.teamScores = Arrays.copyOf(teamScores, teamScores.length); + copy.teamSandbags = Arrays.copyOf(teamSandbags, teamSandbags.length); + copy.playerBlindNil = Arrays.copyOf(playerBlindNil, playerBlindNil.length); + copy.leadPlayer = leadPlayer; + copy.gamePhase = gamePhase; + copy.leadSuit = leadSuit; + copy.spadesBroken = spadesBroken; + + copy.playerHands = new ArrayList<>(); + for (int i = 0; i < playerHands.size(); i++) { + Deck originalHand = playerHands.get(i); + Deck copiedHand = originalHand.copy(); + copy.playerHands.add(copiedHand); + } + // Now redeterminise + if (playerId != -1) { + // we reshuffle all other player hands + List> otherPlayerDecks = new ArrayList<>(); + for (int p = 0; p < playerHands.size(); p++) { + if (p != playerId) + otherPlayerDecks.add(copy.playerHands.get(p)); + } + DeterminisationUtilities.reshuffle(playerId, otherPlayerDecks, x -> true, redeterminisationRnd); + } + + return copy; + } + + @Override + protected double _getHeuristicScore(int playerId) { + if (isNotTerminal()) { + SpadesHeuristic heuristic = new SpadesHeuristic(); + return heuristic.evaluateState(this, playerId); + } else { + return getPlayerResults()[playerId].value; + } + } + + @Override + public double getGameScore(int playerId) { + int team = getTeam(playerId); + return teamScores[team]; + } + + public int getTeam(int playerId) { + return playerId % 2; + } + + public int getNTeams() { + return 2; + } + + public List> getPlayerHands() { + return playerHands; + } + + public int getPlayerBid(int playerId) { + return playerBids[playerId]; + } + + public void setPlayerBid(int playerId, int bid) { + playerBids[playerId] = bid; + } + + public boolean allPlayersBid() { + for (int bid : playerBids) { + if (bid == -1) return false; + } + return true; + } + + public int getTricksTaken(int playerId) { + return tricksTaken[playerId]; + } + + public void incrementTricksTaken(int playerId) { + tricksTaken[playerId]++; + } + + public int getTeamScore(int team) { + return teamScores[team]; + } + + public void setTeamScore(int team, int score) { + teamScores[team] = score; + } + + public int getTeamSandbags(int team) { + return teamSandbags[team]; + } + + public void addTeamSandbags(int team, int sandbags) { + teamSandbags[team] += sandbags; + } + + public Phase getSpadesGamePhase() { + return gamePhase; + } + + public void setSpadesGamePhase(Phase phase) { + this.gamePhase = phase; + } + + public boolean isSpadesBroken() { + return spadesBroken; + } + + public void setSpadesBroken(boolean broken) { + this.spadesBroken = broken; + } + + public FrenchCard.Suite getLeadSuit() { + return leadSuit; + } + + public void setLeadSuit(FrenchCard.Suite suit) { + this.leadSuit = suit; + } + + public int getLeadPlayer() { + return leadPlayer; + } + + public void setLeadPlayer(int playerId) { + this.leadPlayer = playerId; + } + + public List> getCurrentTrick() { + return currentTrick; + } + + public void clearCurrentTrick() { + currentTrick.clear(); + leadSuit = null; + } + + @Override + protected boolean _equals(Object o) { + if (this == o) return true; + if (!(o instanceof SpadesGameState)) return false; + if (!super.equals(o)) return false; + + SpadesGameState that = (SpadesGameState) o; + return Objects.equals(playerHands, that.playerHands) && + Objects.equals(currentTrick, that.currentTrick) && + Objects.equals(tricksWon, that.tricksWon) && + Arrays.equals(playerBids, that.playerBids) && + Arrays.equals(tricksTaken, that.tricksTaken) && + Arrays.equals(teamScores, that.teamScores) && + Arrays.equals(teamSandbags, that.teamSandbags) && + leadPlayer == that.leadPlayer && + gamePhase == that.gamePhase && + leadSuit == that.leadSuit && + spadesBroken == that.spadesBroken; + } + + @Override + public int hashCode() { + int result = Objects.hash(super.hashCode(), playerHands, currentTrick, tricksWon, + leadPlayer, gamePhase, leadSuit, spadesBroken); + result = 31 * result + Arrays.hashCode(playerBids); + result = 31 * result + Arrays.hashCode(tricksTaken); + result = 31 * result + Arrays.hashCode(teamScores); + result = 31 * result + Arrays.hashCode(teamSandbags); + return result; + } + + @Override + public void printToConsole() { + System.out.println("=== SPADES GAME STATE ==="); + System.out.println("Round: " + getRoundCounter() + ", Turn: " + getTurnCounter()); + System.out.println("Phase: " + gamePhase + ", Current Player: " + getCurrentPlayer()); + System.out.println("Spades Broken: " + spadesBroken); + + // Current trick information + if (!currentTrick.isEmpty()) { + System.out.println("\nCurrent Trick (Lead Suit: " + leadSuit + "):"); + for (Map.Entry entry : currentTrick) { + System.out.println(" Player " + entry.getKey() + ": " + entry.getValue()); + } + } + + // Player information + System.out.println("\nPLAYERS:"); + for (int i = 0; i < 4; i++) { + String marker = (i == getCurrentPlayer()) ? ">>> " : " "; + System.out.print(marker + "Player " + i + " (Team " + getTeam(i) + ")"); + + // Bid information + if (playerBids[i] != -1) { + String bidText = (playerBids[i] == 0) ? "Nil" : String.valueOf(playerBids[i]); + System.out.print(" - Bid: " + bidText); + } else { + System.out.print(" - Bid: Not set"); + } + + // Tricks taken + System.out.print(", Tricks: " + tricksTaken[i]); + + // Hand size + System.out.println(", Cards: " + playerHands.get(i).getSize()); + + // Show actual cards for current player or if game is over + if (i == getCurrentPlayer() || !isNotTerminal()) { + System.out.println(marker + "Hand: " + playerHands.get(i).toString()); + } + } + + // Team scores + System.out.println("\nTEAM SCORES:"); + System.out.println("Team 0 (Players 0 & 2): " + teamScores[0] + " points, " + teamSandbags[0] + " sandbags"); + System.out.println("Team 1 (Players 1 & 3): " + teamScores[1] + " points, " + teamSandbags[1] + " sandbags"); + + // Game status + if (!isNotTerminal()) { + System.out.println("\nGAME OVER!"); + for (int i = 0; i < 4; i++) { + System.out.println("Player " + i + " result: " + getPlayerResults()[i]); + } + } + + System.out.println("========================"); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/SpadesHeuristic.java b/src/main/java/games/spades/SpadesHeuristic.java new file mode 100644 index 000000000..4cf563135 --- /dev/null +++ b/src/main/java/games/spades/SpadesHeuristic.java @@ -0,0 +1,148 @@ +package games.spades; + +import core.AbstractGameState; +import core.interfaces.IStateHeuristic; +import core.components.FrenchCard; + +public class SpadesHeuristic implements IStateHeuristic { + + @Override + public double evaluateState(AbstractGameState gs, int playerId) { + SpadesGameState state = (SpadesGameState) gs; + + if (state.isNotTerminal()) { + int team = state.getTeam(playerId); + int opponentTeam = 1 - team; + + double ourScore = state.getTeamScore(team); + double opponentScore = state.getTeamScore(opponentTeam); + + // Handle score difference more robustly for negative scores + double scoreDiff = ourScore - opponentScore; + double scoreAdvantage = 0.0; + + // Normalize score difference to [-1, +1] range + if (Math.abs(scoreDiff) > 0) { + // Use a sigmoid-like function to handle extreme negative scores + scoreAdvantage = Math.tanh(scoreDiff / 1000.0); + } + + // Add tactical evaluation based on current game phase + if (state.getSpadesGamePhase() == SpadesGameState.Phase.PLAYING) { + int ourBid = state.getPlayerBid(playerId) + state.getPlayerBid((playerId + 2) % 4); + int ourTricks = state.getTricksTaken(playerId) + state.getTricksTaken((playerId + 2) % 4); + + // Evaluate bid progress + if (ourBid > 0) { + double bidProgress = Math.min(1.0, (double) ourTricks / ourBid); + scoreAdvantage += bidProgress * 0.3; + + // Penalize severe overbidding (too many sandbags) + if (ourTricks > ourBid + 3) { + scoreAdvantage -= 0.2; + } + } + + // Add hand strength evaluation + double handStrength = evaluateHandStrength(state, playerId); + scoreAdvantage += handStrength * 0.2; + } else if (state.getSpadesGamePhase() == SpadesGameState.Phase.BIDDING) { + // During bidding, focus more on hand strength + double handStrength = evaluateHandStrength(state, playerId); + scoreAdvantage += handStrength * 0.4; + } + + // Convert to [0,1] range for MCTS + return Math.max(0.0, Math.min(1.0, 0.5 + scoreAdvantage * 0.5)); + } else { + // Terminal state - use actual game results + return state.getPlayerResults()[playerId].value; + } + } + + /** + * Evaluates the strength of a player's hand and suggests appropriate bid + */ + private double evaluateHandStrength(SpadesGameState state, int playerId) { + if (playerId < 0 || playerId >= state.getPlayerHands().size()) { + return 0.0; + } + + double strength = 0.0; + int spadeCount = 0; + + for (FrenchCard card : state.getPlayerHands().get(playerId).getComponents()) { + if (card.suite == FrenchCard.Suite.Spades) { + spadeCount++; + if (card.type == FrenchCard.FrenchCardType.Ace) { + strength += 1.5; + } else if (card.type == FrenchCard.FrenchCardType.King) { + strength += 1.2; + } else if (card.type == FrenchCard.FrenchCardType.Queen) { + strength += 1.0; + } else if (card.type == FrenchCard.FrenchCardType.Jack) { + strength += 0.8; + } else if (card.number >= 10) { + strength += 0.5; + } else { + strength += 0.2; + } + } else { + if (card.type == FrenchCard.FrenchCardType.Ace) { + strength += 0.8; + } else if (card.type == FrenchCard.FrenchCardType.King) { + strength += 0.5; + } else if (card.type == FrenchCard.FrenchCardType.Queen) { + strength += 0.3; + } else if (card.type == FrenchCard.FrenchCardType.Jack) { + strength += 0.2; + } else if (card.number >= 10) { + strength += 0.1; + } + } + } + + if (spadeCount >= 5) strength += 0.5; + if (spadeCount >= 7) strength += 0.5; + + return Math.min(1.0, strength / 8.0); + } + + /** + * Suggests a reasonable bid based on hand strength + * This helps AI make better bidding decisions + */ + public int suggestBid(SpadesGameState state, int playerId) { + if (playerId < 0 || playerId >= state.getPlayerHands().size()) { + return 1; + } + + double handStrength = evaluateHandStrength(state, playerId); + int spadeCount = 0; + int highCards = 0; + + for (FrenchCard card : state.getPlayerHands().get(playerId).getComponents()) { + if (card.suite == FrenchCard.Suite.Spades) { + spadeCount++; + } + + if (card.type == FrenchCard.FrenchCardType.Ace) { + highCards++; + } else if (card.type == FrenchCard.FrenchCardType.King) { + highCards++; + } else if (card.suite == FrenchCard.Suite.Spades && card.type == FrenchCard.FrenchCardType.Queen) { + highCards++; + } + } + + int suggestedBid = Math.max(1, highCards + (spadeCount / 3)); + + if (handStrength < 0.3) { + suggestedBid = Math.max(1, suggestedBid - 1); + } else if (handStrength > 0.7) { + suggestedBid = Math.min(6, suggestedBid + 1); + } + + return Math.min(6, Math.max(1, suggestedBid)); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/SpadesParameters.java b/src/main/java/games/spades/SpadesParameters.java new file mode 100644 index 000000000..e8ef35b62 --- /dev/null +++ b/src/main/java/games/spades/SpadesParameters.java @@ -0,0 +1,60 @@ +package games.spades; + +import core.AbstractParameters; + +import java.util.Objects; + +public class SpadesParameters extends AbstractParameters { + + public final int winningScore = 500; + public final int sandbagsPerPenalty = 10; + public final int sandbagsRandPenalty = 100; + public final int nilBonusPoints = 100; + public final int nilPenaltyPoints = 100; + public final int blindNilBonusPoints = 200; + public final int blindNilPenaltyPoints = 200; + + public final int minBid = 0; + public final int maxBid = 13; + + public boolean allowBlindNil = false; + public boolean allowNilOverbid = false; // Nil can be bid even if team has >= 500 points + + public SpadesParameters() { + super(); + // Use maxRounds to end by score comparison; disable TIMEOUT endings + setMaxRounds(30); + setTimeoutRounds(-1); + } + + public SpadesParameters(long seed) { + super(); + setRandomSeed(seed); + setMaxRounds(30); + setTimeoutRounds(-1); + } + + @Override + protected AbstractParameters _copy() { + SpadesParameters copy = new SpadesParameters(getRandomSeed()); + copy.allowBlindNil = this.allowBlindNil; + copy.allowNilOverbid = this.allowNilOverbid; + return copy; + } + + @Override + protected boolean _equals(Object o) { + if (this == o) return true; + if (!(o instanceof SpadesParameters)) return false; + if (!super.equals(o)) return false; + + SpadesParameters that = (SpadesParameters) o; + return allowBlindNil == that.allowBlindNil && + allowNilOverbid == that.allowNilOverbid; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), allowBlindNil, allowNilOverbid); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/actions/Bid.java b/src/main/java/games/spades/actions/Bid.java new file mode 100644 index 000000000..8ff983afb --- /dev/null +++ b/src/main/java/games/spades/actions/Bid.java @@ -0,0 +1,83 @@ +package games.spades.actions; + +import core.AbstractGameState; +import core.actions.AbstractAction; +import games.spades.SpadesGameState; + +import java.util.Objects; + +/** + * Action representing a player's bid in Spades. + * A bid is the number of tricks the player expects to win. + */ +public class Bid extends AbstractAction { + + public final int playerId; + public final int bidAmount; + public final boolean blind; // true for Blind Nil + + public Bid(int playerId, int bidAmount) { + this(playerId, bidAmount, false); + } + + public Bid(int playerId, int bidAmount, boolean blind) { + this.playerId = playerId; + this.bidAmount = bidAmount; + this.blind = blind; + } + + @Override + public boolean execute(AbstractGameState gameState) { + SpadesGameState state = (SpadesGameState) gameState; + + if (state.getCurrentPlayer() != playerId) { + throw new AssertionError("Player " + playerId + " tried to bid out of turn"); + } + + if (state.getSpadesGamePhase() != SpadesGameState.Phase.BIDDING) { + throw new AssertionError("Bid action called outside of bidding phase"); + } + + state.setPlayerBid(playerId, bidAmount); + if (bidAmount == 0) { + state.playerBlindNil[playerId] = blind; + } else { + state.playerBlindNil[playerId] = false; + } + + return true; + } + + @Override + public AbstractAction copy() { + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Bid)) return false; + Bid bid = (Bid) o; + return playerId == bid.playerId && bidAmount == bid.bidAmount && blind == bid.blind; + } + + @Override + public int hashCode() { + return Objects.hash(playerId, bidAmount, blind); + } + + @Override + public void printToConsole() { + System.out.println(toString()); + } + + @Override + public String getString(AbstractGameState gameState) { + return toString(); + } + + @Override + public String toString() { + return "Player " + playerId + " bids " + (bidAmount == 0 ? (blind ? "Blind Nil" : "Nil") : bidAmount); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/actions/PlayCard.java b/src/main/java/games/spades/actions/PlayCard.java new file mode 100644 index 000000000..0cd67e8c7 --- /dev/null +++ b/src/main/java/games/spades/actions/PlayCard.java @@ -0,0 +1,84 @@ +package games.spades.actions; + +import core.AbstractGameState; +import core.actions.AbstractAction; +import core.components.Deck; +import core.components.FrenchCard; +import core.interfaces.IPrintable; +import games.spades.SpadesGameState; + +import java.util.AbstractMap; +import java.util.Objects; + +/** + * Action representing playing a card in Spades. + */ +public class PlayCard extends AbstractAction implements IPrintable { + + public final int playerId; + public final FrenchCard card; + + public PlayCard(int playerId, FrenchCard card) { + this.playerId = playerId; + this.card = card; + } + + @Override + public boolean execute(AbstractGameState gameState) { + SpadesGameState state = (SpadesGameState) gameState; + + if (state.getCurrentPlayer() != playerId) { + throw new AssertionError("Player " + playerId + " tried to play out of turn"); + } + + if (state.getSpadesGamePhase() != SpadesGameState.Phase.PLAYING) { + throw new AssertionError("PlayCard action called outside of playing phase"); + } + + // Get player's hand + Deck playerHand = state.getPlayerHands().get(playerId); + + // Remove card from player's hand + if (!playerHand.getComponents().remove(card)) { + throw new AssertionError("Card not found in player's hand: " + card.toString()); + } + + // Add card to current trick + state.getCurrentTrick().add(new AbstractMap.SimpleEntry<>(playerId, card)); + + return true; + } + + @Override + public AbstractAction copy() { + return this; // immutable + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PlayCard)) return false; + PlayCard playCard = (PlayCard) o; + return playerId == playCard.playerId && Objects.equals(card, playCard.card); + } + + @Override + public int hashCode() { + return Objects.hash(playerId, card); + } + + @Override + public void printToConsole() { + System.out.println(toString()); + } + + @Override + public String getString(AbstractGameState gameState) { + return toString(); + } + + @Override + public String toString() { + return "Player " + playerId + " plays " + card.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/gui/SpadesGUIManager.java b/src/main/java/games/spades/gui/SpadesGUIManager.java new file mode 100644 index 000000000..5fedcfd52 --- /dev/null +++ b/src/main/java/games/spades/gui/SpadesGUIManager.java @@ -0,0 +1,302 @@ +package games.spades.gui; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import games.spades.SpadesGameState; +import games.spades.SpadesParameters; +import games.spades.actions.Bid; +import games.spades.actions.PlayCard; +import gui.AbstractGUIManager; +import gui.GamePanel; +import gui.IScreenHighlight; +import players.human.ActionController; +import players.human.HumanGUIPlayer; +import utilities.ImageIO; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.EtchedBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.List; +import java.util.Set; + +/** + * GUI Manager for Spades game providing a complete graphical interface. + */ +public class SpadesGUIManager extends AbstractGUIManager { + + // Layout constants + private static final int WINDOW_WIDTH = 1000; + private static final int WINDOW_HEIGHT = 700; + + // GUI components + private SpadesPlayerView[] playerViews; + private SpadesTrickView trickView; + private SpadesScoreView scoreView; + private SpadesGameState gameState; + + // Player highlighting + private int activePlayer = -1; + private Border highlightActive = BorderFactory.createLineBorder(new Color(47, 132, 220), 3); + private Border[] playerBorders; + + public SpadesGUIManager(GamePanel parent, Game game, ActionController ac, Set humanID) { + super(parent, game, ac, humanID); + + if (game != null && game.getGameState() instanceof SpadesGameState) { + this.gameState = (SpadesGameState) game.getGameState(); + setupGUI(); + } + } + + private void setupGUI() { + if (parent == null) return; + + // Set background + parent.setBackground(ImageIO.GetInstance().getImage("data/FrenchCards/table-background.jpg")); + + // Create tabbed pane for different views + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.setOpaque(false); + + // Main game panel + JPanel mainPanel = createMainGamePanel(); + tabbedPane.add("Game", mainPanel); + + // Rules panel + JPanel rulesPanel = createRulesPanel(); + tabbedPane.add("Rules", rulesPanel); + + parent.setLayout(new BorderLayout()); + parent.add(tabbedPane, BorderLayout.CENTER); + parent.setPreferredSize(new Dimension(WINDOW_WIDTH, WINDOW_HEIGHT)); + parent.revalidate(); + parent.repaint(); + } + + private JPanel createMainGamePanel() { + JPanel mainPanel = new JPanel(new BorderLayout()); + mainPanel.setOpaque(false); + + // Get game parameters + SpadesParameters params = (SpadesParameters) gameState.getGameParameters(); + String dataPath = "data/FrenchCards/"; + + // Initialize player views + playerViews = new SpadesPlayerView[4]; + playerBorders = new Border[4]; + + // Create center area with trick view and score + JPanel centerPanel = new JPanel(new BorderLayout()); + centerPanel.setOpaque(false); + + // Trick view in center + trickView = new SpadesTrickView(dataPath); + centerPanel.add(trickView, BorderLayout.CENTER); + + // Score view on the right + scoreView = new SpadesScoreView(); + centerPanel.add(scoreView, BorderLayout.EAST); + + // Create player areas around the center + JPanel gameArea = new JPanel(new BorderLayout()); + gameArea.setOpaque(false); + + // Create player panels for each position + for (int i = 0; i < 4; i++) { + SpadesPlayerView playerView = new SpadesPlayerView(gameState.getPlayerHands().get(i), i, dataPath); + playerViews[i] = playerView; + + // Create border with player info + String playerName = "Player " + i; + if (game.getPlayers() != null && i < game.getPlayers().size()) { + String[] agentParts = game.getPlayers().get(i).getClass().getSimpleName().split("\\."); + String agentName = agentParts[agentParts.length - 1]; + playerName += " [" + agentName + "]"; + } + + TitledBorder border = BorderFactory.createTitledBorder( + BorderFactory.createEtchedBorder(EtchedBorder.LOWERED), + playerName, + TitledBorder.CENTER, + TitledBorder.BELOW_BOTTOM + ); + playerBorders[i] = border; + playerView.setBorder(border); + + // Position players around the game area + String position; + switch (i) { + case 0: position = BorderLayout.SOUTH; break; // Bottom + case 1: position = BorderLayout.WEST; break; // Left + case 2: position = BorderLayout.NORTH; break; // Top + case 3: position = BorderLayout.EAST; break; // Right + default: position = BorderLayout.CENTER; break; + } + + gameArea.add(playerView, position); + } + + gameArea.add(centerPanel, BorderLayout.CENTER); + + // Info panel at top + JPanel infoPanel = createGameStateInfoPanel("Spades", gameState, WINDOW_WIDTH, defaultInfoPanelHeight); + + // Action panel at bottom + JComponent actionPanel = createActionPanel(new IScreenHighlight[0], WINDOW_WIDTH, defaultActionPanelHeight, false); + + mainPanel.add(infoPanel, BorderLayout.NORTH); + mainPanel.add(gameArea, BorderLayout.CENTER); + mainPanel.add(actionPanel, BorderLayout.SOUTH); + + return mainPanel; + } + + private JPanel createRulesPanel() { + JPanel rulesPanel = new JPanel(); + rulesPanel.setBackground(new Color(43, 108, 25, 111)); + + JLabel rulesLabel = new JLabel(getRulesText()); + rulesLabel.setVerticalAlignment(SwingConstants.TOP); + + JScrollPane scrollPane = new JScrollPane(rulesLabel); + scrollPane.setPreferredSize(new Dimension(WINDOW_WIDTH * 2/3, WINDOW_HEIGHT * 2/3)); + + rulesPanel.add(scrollPane); + return rulesPanel; + } + + private String getRulesText() { + return "

Spades




" + + "

Spades is a trick-taking card game for 4 players in 2 partnerships.

" + + "
    " + + "
  • Teams: Players 0 & 2 vs Players 1 & 3
  • " + + "
  • Goal: First team to reach 500 points wins
  • " + + "
  • Trump: Spades are always trump cards
  • " + + "
" + + "

Bidding Phase:

" + + "
    " + + "
  • Each player bids the number of tricks they expect to win (0-13)
  • " + + "
  • Bid of 0 is called 'Nil' - attempting to win no tricks
  • " + + "
  • All players must bid before playing begins
  • " + + "
" + + "

Playing Phase:

" + + "
    " + + "
  • Players must follow suit if possible
  • " + + "
  • Spades beat all other suits (trump)
  • " + + "
  • Cannot lead spades until 'broken' (someone plays a spade)
  • " + + "
  • Exception: Can lead spades if only spades remaining
  • " + + "
" + + "

Scoring:

" + + "
    " + + "
  • Made bid: 10 points per bid trick + 1 per overtrick
  • " + + "
  • Failed bid: Lose 10 points per bid trick
  • " + + "
  • Sandbags: Every 10 overtricks = 100 point penalty
  • " + + "
  • Nil: +100 if successful, -100 if failed
  • " + + "
" + + "

INTERFACE: Click cards to play them. Use action buttons to bid.

" + + ""; + } + + @Override + protected void _update(AbstractPlayer player, AbstractGameState gameState) { + if (!(gameState instanceof SpadesGameState)) return; + + this.gameState = (SpadesGameState) gameState; + + // Update active player highlighting + int currentPlayer = gameState.getCurrentPlayer(); + if (currentPlayer != activePlayer) { + // Remove old highlighting + if (activePlayer >= 0 && activePlayer < playerViews.length) { + playerViews[activePlayer].setActivePlayer(false); + playerViews[activePlayer].setBorder(playerBorders[activePlayer]); + } + + // Add new highlighting + activePlayer = currentPlayer; + if (activePlayer >= 0 && activePlayer < playerViews.length) { + playerViews[activePlayer].setActivePlayer(true); + Border compound = BorderFactory.createCompoundBorder(highlightActive, playerBorders[activePlayer]); + playerViews[activePlayer].setBorder(compound); + } + } + + // Update all components + if (playerViews != null) { + for (int i = 0; i < playerViews.length && i < this.gameState.getPlayerHands().size(); i++) { + if (playerViews[i] != null) { + playerViews[i].setDeck(this.gameState.getPlayerHands().get(i)); + // Show cards for human players + playerViews[i].setVisible(humanPlayerIds.contains(i) || i == 0); // Always show player 0 for demo + } + } + } + + if (trickView != null) { + trickView.updateTrick(this.gameState); + } + if (scoreView != null) { + scoreView.updateGameState(this.gameState); + } + + updateGameStateInfo(gameState); + + if (parent != null) { + parent.repaint(); + } + } + + @Override + protected void updateActionButtons(AbstractPlayer current, AbstractGameState gameState) { + if (!(current instanceof HumanGUIPlayer) || !(gameState instanceof SpadesGameState)) { + return; + } + + SpadesGameState spadesState = (SpadesGameState) gameState; + + // Clear existing actions + for (ActionButton button : actionButtons) { + button.setVisible(false); + } + + // Get available actions + List actions = game.getForwardModel().computeAvailableActions(gameState); + + int buttonIndex = 0; + for (AbstractAction action : actions) { + if (buttonIndex >= actionButtons.length) break; + + ActionButton button = actionButtons[buttonIndex]; + button.setButtonAction(action, gameState); + + // Set button text based on action type + if (action instanceof Bid) { + Bid bidAction = (Bid) action; + if (bidAction.bidAmount == 0) { + button.setText("Bid Nil"); + } else { + button.setText("Bid " + bidAction.bidAmount); + } + } else if (action instanceof PlayCard) { + PlayCard playAction = (PlayCard) action; + button.setText("Play " + playAction.card.toString()); + } else { + button.setText(action.toString()); + } + + button.setVisible(true); + buttonIndex++; + } + } + + @Override + public int getMaxActionSpace() { + return 14; // Max 14 bids (0-13) or 13 cards + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/gui/SpadesPlayerView.java b/src/main/java/games/spades/gui/SpadesPlayerView.java new file mode 100644 index 000000000..ce8b7f067 --- /dev/null +++ b/src/main/java/games/spades/gui/SpadesPlayerView.java @@ -0,0 +1,164 @@ +package games.spades.gui; + +import core.components.Deck; +import core.components.FrenchCard; +import gui.views.CardView; +import gui.views.ComponentView; +import utilities.ImageIO; + +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +/** + * GUI component for displaying a player's hand in Spades. + */ +public class SpadesPlayerView extends ComponentView { + + private final int playerId; + private final String dataPath; + private boolean isActivePlayer = false; + private int highlightedCard = -1; + private boolean isVisible = false; + private Deck deck; + private Image backOfCard; + private Rectangle[] cardRects; + + public static final int CARD_WIDTH = 70; + public static final int CARD_HEIGHT = 90; + public static final int PLAYER_WIDTH = 400; + public static final int PLAYER_HEIGHT = 120; + + public SpadesPlayerView(Deck deck, int playerId, String dataPath) { + super(deck, PLAYER_WIDTH, PLAYER_HEIGHT); + this.deck = deck; + this.playerId = playerId; + this.dataPath = dataPath; + this.backOfCard = ImageIO.GetInstance().getImage(dataPath + "gray_back.png"); + + setOpaque(false); + + // Add mouse listener for card selection + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (isVisible && cardRects != null && deck != null) { + for (int i = 0; i < cardRects.length && i < deck.getSize(); i++) { + if (cardRects[i] != null && cardRects[i].contains(e.getPoint())) { + setCardHighlight(i); + break; + } + } + } + } + }); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Draw background for active player + if (isActivePlayer) { + g2d.setColor(new Color(255, 255, 0, 50)); // Light yellow highlight + g2d.fillRect(0, 0, getWidth(), getHeight()); + } + + drawHand(g2d, new Rectangle(5, 10, getWidth() - 10, CARD_HEIGHT)); + } + + private void drawHand(Graphics2D g, Rectangle rect) { + if (deck == null || deck.getSize() == 0) return; + + // Take a snapshot to avoid concurrent modification + int cardCount = deck.getSize(); + if (cardCount == 0) return; + + cardRects = new Rectangle[cardCount]; + int spacing = Math.min(15, Math.max(5, (rect.width - CARD_WIDTH) / Math.max(1, cardCount - 1))); + + for (int i = 0; i < cardCount; i++) { + // Defensive check to prevent IndexOutOfBoundsException + if (i >= deck.getSize()) break; + + FrenchCard card; + try { + card = deck.get(i); + } catch (IndexOutOfBoundsException e) { + // Card was removed during painting, skip + break; + } + + if (card == null) continue; + + int x = rect.x + i * spacing; + int y = rect.y; + + cardRects[i] = new Rectangle(x, y, CARD_WIDTH, CARD_HEIGHT); + + // Highlight selected card + if (i == highlightedCard) { + g.setColor(new Color(0, 255, 0, 150)); // Green highlight + g.fillRoundRect(x - 2, y - 2, CARD_WIDTH + 4, CARD_HEIGHT + 4, 10, 10); + } + + // Draw card + Image cardImage = getCardImage(card); + CardView.drawCard(g, x, y, CARD_WIDTH, CARD_HEIGHT, card, cardImage, backOfCard, isVisible); + + // Draw card border + g.setColor(Color.BLACK); + g.drawRoundRect(x, y, CARD_WIDTH, CARD_HEIGHT, 8, 8); + } + } + + private Image getCardImage(FrenchCard card) { + String imageName; + if (card.type == FrenchCard.FrenchCardType.Number) { + imageName = card.number + card.suite.name() + ".png"; + } else { + imageName = card.type.name() + card.suite.name() + ".png"; + } + return ImageIO.GetInstance().getImage(dataPath + imageName); + } + + public void setActivePlayer(boolean active) { + this.isActivePlayer = active; + repaint(); + } + + public void setCardHighlight(int cardIndex) { + this.highlightedCard = cardIndex; + repaint(); + } + + public void setVisible(boolean visible) { + this.isVisible = visible; + repaint(); + } + + public FrenchCard getHighlightedCard() { + if (deck != null && highlightedCard >= 0 && highlightedCard < deck.getSize()) { + try { + return deck.get(highlightedCard); + } catch (IndexOutOfBoundsException e) { + // Card was removed, reset highlight + highlightedCard = -1; + return null; + } + } + return null; + } + + public void setDeck(Deck newDeck) { + this.deck = newDeck; + this.component = newDeck; + repaint(); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(PLAYER_WIDTH, PLAYER_HEIGHT); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/gui/SpadesScoreView.java b/src/main/java/games/spades/gui/SpadesScoreView.java new file mode 100644 index 000000000..279358e65 --- /dev/null +++ b/src/main/java/games/spades/gui/SpadesScoreView.java @@ -0,0 +1,141 @@ +package games.spades.gui; + +import games.spades.SpadesGameState; + +import javax.swing.*; +import java.awt.*; + +/** + * GUI component for displaying scores, bids, and tricks taken in Spades. + */ +public class SpadesScoreView extends JPanel { + + private SpadesGameState gameState; + + public SpadesScoreView() { + setOpaque(false); + setPreferredSize(new Dimension(300, 200)); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Draw background + g2d.setColor(new Color(240, 240, 240, 200)); + g2d.fillRoundRect(5, 5, getWidth() - 10, getHeight() - 10, 15, 15); + g2d.setColor(Color.BLACK); + g2d.drawRoundRect(5, 5, getWidth() - 10, getHeight() - 10, 15, 15); + + if (gameState == null) { + g2d.setFont(new Font("Arial", Font.PLAIN, 12)); + g2d.drawString("No game data", 20, 30); + return; + } + + int y = 25; + int lineHeight = 18; + + // Title + g2d.setFont(new Font("Arial", Font.BOLD, 14)); + g2d.setColor(Color.BLUE); + g2d.drawString("SPADES SCORECARD", 15, y); + y += lineHeight + 5; + + // Team scores + g2d.setFont(new Font("Arial", Font.BOLD, 12)); + g2d.setColor(Color.BLACK); + g2d.drawString("TEAM SCORES:", 15, y); + y += lineHeight; + + g2d.setFont(new Font("Arial", Font.PLAIN, 11)); + g2d.setColor(new Color(0, 100, 0)); + g2d.drawString("Team 1 (P0 & P2): " + gameState.getTeamScore(0), 20, y); + y += lineHeight; + + g2d.setColor(new Color(100, 0, 0)); + g2d.drawString("Team 2 (P1 & P3): " + gameState.getTeamScore(1), 20, y); + y += lineHeight + 5; + + // Current round bids and tricks + g2d.setFont(new Font("Arial", Font.BOLD, 12)); + g2d.setColor(Color.BLACK); + g2d.drawString("CURRENT ROUND:", 15, y); + y += lineHeight; + + g2d.setFont(new Font("Arial", Font.PLAIN, 11)); + + // Show bids and tricks for each player + String[] playerNames = {"P0", "P1", "P2", "P3"}; + Color[] playerColors = { + new Color(0, 100, 0), // P0 - Green + new Color(100, 0, 0), // P1 - Red + new Color(0, 100, 0), // P2 - Green + new Color(100, 0, 0) // P3 - Red + }; + + for (int i = 0; i < 4; i++) { + try { + g2d.setColor(playerColors[i]); + + int bid = gameState.getPlayerBid(i); + int tricks = gameState.getTricksTaken(i); + + String bidText = (bid == -1) ? "?" : (bid == 0 ? "Nil" : String.valueOf(bid)); + String status = (bid == -1) ? "Bidding..." : tricks + "/" + bidText; + + g2d.drawString(playerNames[i] + ": " + status, 20, y); + y += lineHeight; + } catch (Exception e) { + // Skip this player if there's an error accessing game state + g2d.drawString(playerNames[i] + ": ?", 20, y); + y += lineHeight; + } + } + + y += 5; + + // Sandbags + g2d.setFont(new Font("Arial", Font.BOLD, 10)); + g2d.setColor(Color.ORANGE); + g2d.drawString("Sandbags:", 15, y); + y += 15; + + g2d.setFont(new Font("Arial", Font.PLAIN, 10)); + g2d.setColor(new Color(0, 100, 0)); + g2d.drawString("Team 1: " + gameState.getTeamSandbags(0), 20, y); + y += 12; + + g2d.setColor(new Color(100, 0, 0)); + g2d.drawString("Team 2: " + gameState.getTeamSandbags(1), 20, y); + y += 12; + + // Game phase + g2d.setFont(new Font("Arial", Font.ITALIC, 10)); + g2d.setColor(Color.GRAY); + String phase = gameState.getSpadesGamePhase().name(); + g2d.drawString("Phase: " + phase, 15, y); + + // Spades broken indicator + if (gameState.isSpadesBroken()) { + g2d.setColor(Color.RED); + g2d.setFont(new Font("Arial", Font.BOLD, 10)); + g2d.drawString("♠ SPADES BROKEN ♠", 15, y + 15); + } + } + + public void updateGameState(SpadesGameState gameState) { + // Create a defensive copy or reference + this.gameState = gameState; + // Use SwingUtilities.invokeLater to ensure painting happens on EDT + SwingUtilities.invokeLater(this::repaint); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(250, 200); + } +} \ No newline at end of file diff --git a/src/main/java/games/spades/gui/SpadesTrickView.java b/src/main/java/games/spades/gui/SpadesTrickView.java new file mode 100644 index 000000000..b1a9a87f6 --- /dev/null +++ b/src/main/java/games/spades/gui/SpadesTrickView.java @@ -0,0 +1,174 @@ +package games.spades.gui; + +import core.components.FrenchCard; +import gui.views.CardView; +import games.spades.SpadesGameState; +import utilities.ImageIO; + +import javax.swing.*; +import java.awt.*; +import java.util.List; +import java.util.Map; + +/** + * GUI component for displaying the current trick being played in Spades. + */ +public class SpadesTrickView extends JPanel { + + private final String dataPath; + private List> currentTrick; + private Image backOfCard; + private int leadPlayer = -1; + + public static final int CARD_WIDTH = 60; + public static final int CARD_HEIGHT = 80; + public static final int TRICK_AREA_WIDTH = 300; + public static final int TRICK_AREA_HEIGHT = 200; + + public SpadesTrickView(String dataPath) { + this.dataPath = dataPath; + this.backOfCard = ImageIO.GetInstance().getImage(dataPath + "gray_back.png"); + + setOpaque(false); + setPreferredSize(new Dimension(TRICK_AREA_WIDTH, TRICK_AREA_HEIGHT)); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Draw background area for trick + g2d.setColor(new Color(0, 100, 0, 100)); // Dark green semi-transparent + g2d.fillRoundRect(10, 10, getWidth() - 20, getHeight() - 20, 20, 20); + g2d.setColor(Color.BLACK); + g2d.drawRoundRect(10, 10, getWidth() - 20, getHeight() - 20, 20, 20); + + // Draw label + g2d.setFont(new Font("Arial", Font.BOLD, 14)); + g2d.setColor(Color.WHITE); + g2d.drawString("Current Trick", 20, 30); + + // Draw played cards + if (currentTrick != null && !currentTrick.isEmpty()) { + drawTrickCards(g2d); + } else { + // Draw placeholder text + g2d.setFont(new Font("Arial", Font.ITALIC, 12)); + g2d.setColor(Color.LIGHT_GRAY); + g2d.drawString("No cards played yet", 60, getHeight() / 2); + } + } + + private void drawTrickCards(Graphics2D g) { + if (currentTrick == null || currentTrick.isEmpty()) return; + + // Take a snapshot to avoid concurrent modification + int cardCount = currentTrick.size(); + if (cardCount == 0) return; + + // Position cards in a cross pattern (North, East, South, West) + int centerX = getWidth() / 2; + int centerY = getHeight() / 2; + int offset = 40; + + for (int i = 0; i < cardCount; i++) { + // Defensive check to prevent IndexOutOfBoundsException + if (i >= currentTrick.size()) break; + + Map.Entry entry; + try { + entry = currentTrick.get(i); + } catch (IndexOutOfBoundsException e) { + // Trick was modified during painting, skip + break; + } + + if (entry == null) continue; + + int playerId = entry.getKey(); + FrenchCard card = entry.getValue(); + + if (card == null) continue; + + // Calculate position based on player ID + int x, y; + switch (playerId) { + case 0: // Bottom (South) + x = centerX - CARD_WIDTH / 2; + y = centerY + offset; + break; + case 1: // Left (West) + x = centerX - offset - CARD_WIDTH; + y = centerY - CARD_HEIGHT / 2; + break; + case 2: // Top (North) + x = centerX - CARD_WIDTH / 2; + y = centerY - offset - CARD_HEIGHT; + break; + case 3: // Right (East) + x = centerX + offset; + y = centerY - CARD_HEIGHT / 2; + break; + default: + x = centerX - CARD_WIDTH / 2; + y = centerY - CARD_HEIGHT / 2; + break; + } + + // Highlight lead card + if (playerId == leadPlayer) { + g.setColor(new Color(255, 255, 0, 150)); // Yellow highlight + g.fillRoundRect(x - 3, y - 3, CARD_WIDTH + 6, CARD_HEIGHT + 6, 10, 10); + } + + // Draw the card + Image cardImage = getCardImage(card); + CardView.drawCard(g, x, y, CARD_WIDTH, CARD_HEIGHT, card, cardImage, backOfCard, true); + + // Draw card border + g.setColor(Color.BLACK); + g.drawRoundRect(x, y, CARD_WIDTH, CARD_HEIGHT, 6, 6); + + // Draw player ID + g.setColor(Color.WHITE); + g.setFont(new Font("Arial", Font.BOLD, 10)); + g.drawString("P" + playerId, x + 2, y - 2); + } + } + + private Image getCardImage(FrenchCard card) { + String imageName; + if (card.type == FrenchCard.FrenchCardType.Number) { + imageName = card.number + card.suite.name() + ".png"; + } else { + imageName = card.type.name() + card.suite.name() + ".png"; + } + return ImageIO.GetInstance().getImage(dataPath + imageName); + } + + public void updateTrick(SpadesGameState gameState) { + if (gameState != null) { + try { + this.currentTrick = gameState.getCurrentTrick(); + this.leadPlayer = gameState.getLeadPlayer(); + } catch (Exception e) { + // If there's an error, clear the trick + this.currentTrick = null; + this.leadPlayer = -1; + } + } else { + this.currentTrick = null; + this.leadPlayer = -1; + } + repaint(); + } + + public void clearTrick() { + this.currentTrick = null; + this.leadPlayer = -1; + repaint(); + } +} \ No newline at end of file diff --git a/src/test/java/games/spades/TestSpades.java b/src/test/java/games/spades/TestSpades.java new file mode 100644 index 000000000..92975d899 --- /dev/null +++ b/src/test/java/games/spades/TestSpades.java @@ -0,0 +1,589 @@ +package games.spades; + +import core.AbstractParameters; +import core.CoreConstants; +import core.actions.AbstractAction; +import core.components.Deck; +import core.components.FrenchCard; +import games.spades.actions.Bid; +import games.spades.actions.PlayCard; +import org.junit.*; + +import java.util.List; +import java.util.Map; + +import static core.CoreConstants.GameResult.*; +import static org.junit.Assert.*; + + +public class TestSpades { + + private SpadesForwardModel forwardModel; + private SpadesGameState gameState; + private AbstractParameters gameParameters; + + @Before + public void setUp() { + forwardModel = new SpadesForwardModel(); + gameParameters = new SpadesParameters(); + gameState = new SpadesGameState(gameParameters, 4); + forwardModel.setup(gameState); + } + + // ======================================== + // Set-up Phase + // ======================================== + + /** + * 测试游戏初始设置是否正确 + * + * 验证内容: + * - 4 plyaers + * - 13 cards + * - Start as Bidding phase + * - Player 1 (on the left of the dealer) bids first + * - Current bid for every player is -1(=not bid yet) + * - Teamscore = 0 + * - Spades not broken + */ + @Test + public void testGameSetup() { + assertEquals(4, gameState.getNPlayers()); + assertEquals(SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); + assertEquals(1, gameState.getCurrentPlayer()); + + for (int i = 0; i < 4; i++) { + assertEquals(13, gameState.getPlayerHands().get(i).getSize()); + assertEquals(-1, gameState.getPlayerBid(i)); + } + + assertEquals(0, gameState.getTeamScore(0)); + assertEquals(0, gameState.getTeamScore(1)); + assertFalse(gameState.isSpadesBroken()); + + int totalCards = gameState.getPlayerHands().stream() + .mapToInt(hand -> hand.getSize()) + .sum(); + assertEquals("总共应该有52张牌", 52, totalCards); + } + + /** + * player-team + * + * player0&2 - team1, player1&3 - team2 + */ + @Test + public void testPlayerTeamMapping() { + assertEquals(0, gameState.getTeam(0)); + assertEquals(1, gameState.getTeam(1)); + assertEquals(0, gameState.getTeam(2)); + assertEquals(1, gameState.getTeam(3)); + } + + // ======================================== + // Bidding + // ======================================== + + /** + * 测试叫牌阶段的可用动作 + * + * 在叫牌阶段,当前玩家应该能够叫0-13的任意数字 + * 这测试了action generation的正确性 + */ + @Test + public void testBiddingPhaseAvailableActions() { + assertEquals("游戏开始时应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); + + List actions = forwardModel._computeAvailableActions(gameState); + assertEquals("叫牌阶段应该有14个可用动作(0-13)", 14, actions.size()); + + // 验证所有动作都是Bid动作且范围正确 + for (int i = 0; i < 14; i++) { + assertTrue("所有动作都应该是Bid动作", actions.get(i) instanceof Bid); + Bid bid = (Bid) actions.get(i); + assertEquals("叫牌应该由当前玩家执行", gameState.getCurrentPlayer(), bid.playerId); + assertTrue("叫牌值应该在0-13范围内", bid.bidAmount >= 0 && bid.bidAmount <= 13); + } + } + + /** + * 测试叫牌动作的执行和阶段转换 + * + * 验证: + * - 叫牌被正确记录 + * - 轮到下一个玩家 + * - 所有玩家叫牌后转换到出牌阶段 + */ + @Test + public void testBiddingExecution() { + // 玩家1叫5 + Bid bid1 = new Bid(1, 5); + forwardModel.next(gameState, bid1); + + assertEquals("玩家1的叫牌应该被记录", 5, gameState.getPlayerBid(1)); + assertEquals("应该轮到玩家2", 2, gameState.getCurrentPlayer()); + assertEquals("仍然应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); + + // 其他玩家完成叫牌 + forwardModel.next(gameState, new Bid(2, 3)); + forwardModel.next(gameState, new Bid(3, 4)); + assertEquals("应该轮到玩家0", 0, gameState.getCurrentPlayer()); + + // 最后一个玩家叫牌 + forwardModel.next(gameState, new Bid(0, 2)); + assertEquals("所有玩家叫牌后应该转换到出牌阶段", SpadesGameState.Phase.PLAYING, gameState.getSpadesGamePhase()); + assertEquals("出牌阶段应该从玩家1开始(庄家左边)", 1, gameState.getCurrentPlayer()); + } + + /** + * 测试团队叫牌计算 + * + * 团队的总叫牌是两个队友叫牌的和,这对最终得分计算很重要 + */ + @Test + public void testTeamBidCalculation() { + // 完成所有叫牌 + forwardModel.next(gameState, new Bid(1, 5)); // 团队1 + forwardModel.next(gameState, new Bid(2, 3)); // 团队0 + forwardModel.next(gameState, new Bid(3, 4)); // 团队1 + forwardModel.next(gameState, new Bid(0, 2)); // 团队0 + + // 验证团队叫牌总和 + int team0Bid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 2 + 3 = 5 + int team1Bid = gameState.getPlayerBid(1) + gameState.getPlayerBid(3); // 5 + 4 = 9 + + assertEquals("团队0的总叫牌应该是5", 5, team0Bid); + assertEquals("团队1的总叫牌应该是9", 9, team1Bid); + } + + // ======================================== + // 出牌阶段测试 + // ======================================== + + /** + * 测试出牌阶段的基本动作生成 + * + * 在出牌阶段开始时,首家应该能够出任何牌(除了黑桃,除非只有黑桃) + */ + @Test + public void testPlayingPhaseBasicActions() { + // 完成叫牌阶段 + completeBiddingPhase(); + + assertEquals("应该处于出牌阶段", SpadesGameState.Phase.PLAYING, gameState.getSpadesGamePhase()); + + List actions = forwardModel._computeAvailableActions(gameState); + assertTrue("出牌阶段应该有可用动作", actions.size() > 0); + + // 验证所有动作都是PlayCard动作 + for (AbstractAction action : actions) { + assertTrue("所有动作都应该是PlayCard动作", action instanceof PlayCard); + PlayCard playAction = (PlayCard) action; + assertEquals("出牌应该由当前玩家执行", gameState.getCurrentPlayer(), playAction.playerId); + } + } + + /** + * 测试黑桃破门规则 + * + * 在黑桃未破门时,不能首攻黑桃,除非手中只有黑桃 + * 这是Spades游戏的核心规则之一 + */ + @Test + public void testSpadesBreakingRule() { + completeBiddingPhase(); + + // 创建一个有混合花色的手牌来测试黑桃破门规则 + Deck playerHand = gameState.getPlayerHands().get(gameState.getCurrentPlayer()); + playerHand.clear(); + + // 添加非黑桃牌和黑桃牌 + playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts)); + playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Spades)); + + List actions = forwardModel._computeAvailableActions(gameState); + + // 验证不能首攻黑桃(黑桃未破门且有其他花色) + boolean canLeadSpades = actions.stream() + .filter(a -> a instanceof PlayCard) + .map(a -> (PlayCard) a) + .anyMatch(a -> a.card.suite == FrenchCard.Suite.Spades); + + assertFalse("黑桃未破门时,有其他花色时不能首攻黑桃", canLeadSpades); + } + + /** + * 测试只有黑桃时可以首攻黑桃 + * + * 当玩家手中只有黑桃时,即使黑桃未破门也必须出黑桃 + */ + @Test + public void testSpadesOnlyCanLeadSpades() { + completeBiddingPhase(); + + // 创建只有黑桃的手牌 + Deck playerHand = gameState.getPlayerHands().get(gameState.getCurrentPlayer()); + playerHand.clear(); + playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Spades)); + playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Spades)); + + List actions = forwardModel._computeAvailableActions(gameState); + + // 验证可以出黑桃 + boolean canLeadSpades = actions.stream() + .filter(a -> a instanceof PlayCard) + .map(a -> (PlayCard) a) + .anyMatch(a -> a.card.suite == FrenchCard.Suite.Spades); + + assertTrue("手中只有黑桃时应该可以首攻黑桃", canLeadSpades); + } + + /** + * 测试跟牌规则 + * + * 当别人已经出牌时,必须跟牌(如果有的话) + * 这是trick-taking游戏的基本规则 + */ + @Test + public void testFollowSuitRule() { + completeBiddingPhase(); + + // 玩家1出红心A + Deck player1Hand = gameState.getPlayerHands().get(1); + player1Hand.clear(); + FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); + player1Hand.add(heartAce); + + forwardModel.next(gameState, new PlayCard(1, heartAce)); + + // 现在轮到玩家2,设置玩家2的手牌(有红心和其他花色) + Deck player2Hand = gameState.getPlayerHands().get(2); + player2Hand.clear(); + player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts)); + player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Clubs)); + + List actions = forwardModel._computeAvailableActions(gameState); + + // 验证只能出红心 + for (AbstractAction action : actions) { + PlayCard playAction = (PlayCard) action; + assertEquals("必须跟首攻花色(红心)", FrenchCard.Suite.Hearts, playAction.card.suite); + } + } + + /** + * 测试没有跟牌花色时可以出任意牌 + * + * 当玩家没有首攻花色时,可以出任意牌(包括将牌黑桃) + */ + @Test + public void testCannotFollowSuitRule() { + completeBiddingPhase(); + + // 玩家1出红心A + Deck player1Hand = gameState.getPlayerHands().get(1); + player1Hand.clear(); + FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); + player1Hand.add(heartAce); + + forwardModel.next(gameState, new PlayCard(1, heartAce)); + + // 设置玩家2没有红心 + Deck player2Hand = gameState.getPlayerHands().get(2); + player2Hand.clear(); + player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Clubs)); + player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Spades)); + + List actions = forwardModel._computeAvailableActions(gameState); + assertEquals("没有跟牌花色时应该可以出所有手牌", 2, actions.size()); + + // 验证可以出任意花色 + boolean hasClubs = actions.stream().anyMatch(a -> ((PlayCard) a).card.suite == FrenchCard.Suite.Clubs); + boolean hasSpades = actions.stream().anyMatch(a -> ((PlayCard) a).card.suite == FrenchCard.Suite.Spades); + + assertTrue("应该可以出梅花", hasClubs); + assertTrue("应该可以出黑桃", hasSpades); + } + + // ======================================== + // 赢牌判断测试 + // ======================================== + + /** + * 测试黑桃将牌的优先级 + * + * 黑桃是将牌,总是比其他花色大 + * 这是Spades游戏的核心机制 + */ + @Test + public void testSpadesAreTrump() { + completeBiddingPhase(); + + // 创建一个完整的trick + setupCompleteTrick(); + + // 模拟出牌:红心A, 黑桃2, 红心K, 红心Q + FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); + FrenchCard spade2 = new FrenchCard(FrenchCard.FrenchCardType.Number, FrenchCard.Suite.Spades, 2); + FrenchCard heartKing = new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts); + FrenchCard heartQueen = new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Hearts); + + playCompleteRound(heartAce, spade2, heartKing, heartQueen); + + // 验证黑桃2赢了这轮(尽管红心A更大) + assertEquals("出黑桃2的玩家应该赢得这轮", 1, gameState.getCurrentPlayer()); + } + + /** + * 测试同花色牌的大小比较 + * + * 在没有将牌的情况下,同花色中点数大的牌获胜 + */ + @Test + public void testSameSuitComparison() { + completeBiddingPhase(); + setupCompleteTrick(); + + // 模拟出牌:红心2, 红心A, 红心K, 红心Q (都是红心,A最大) + FrenchCard heart2 = new FrenchCard(FrenchCard.FrenchCardType.Number, FrenchCard.Suite.Hearts, 2); + FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); + FrenchCard heartKing = new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts); + FrenchCard heartQueen = new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Hearts); + + playCompleteRound(heart2, heartAce, heartKing, heartQueen); + + // 验证红心A赢了这轮 + assertEquals("出红心A的玩家应该赢得这轮", 1, gameState.getCurrentPlayer()); + } + + // ======================================== + // 得分系统测试 + // ======================================== + + /** + * 测试成功完成合约的得分 + * + * 当团队赢得的墩数 >= 叫牌数时: + * - 基础分 = 叫牌数 * 10 + * - 沙袋分 = (实际墩数 - 叫牌数) * 1 + */ + @Test + public void testSuccessfulContractScoring() { + // 设置一个简单的得分场景 + gameState.setPlayerBid(0, 3); // 团队0叫3 + gameState.setPlayerBid(2, 2); // 团队0叫2,总共5 + + // 模拟团队0赢得6墩 + for (int i = 0; i < 6; i++) { + gameState.incrementTricksTaken(0); // 玩家0赢得6墩 + } + + // 手动调用得分计算逻辑 + int teamBid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 5 + int teamTricks = gameState.getTricksTaken(0) + gameState.getTricksTaken(2); // 6 + + int expectedScore = teamBid * 10 + (teamTricks - teamBid); // 5*10 + 1 = 51 + + assertEquals("团队叫牌应该是5", 5, teamBid); + assertEquals("团队实际墩数应该是6", 6, teamTricks); + // 注意:这里我们测试的是计算逻辑,实际的endRound方法会自动计算 + } + + /** + * 测试未完成合约的惩罚 + * + * 当团队赢得的墩数 < 叫牌数时: + * - 扣分 = 叫牌数 * 10 + */ + @Test + public void testFailedContractPenalty() { + gameState.setPlayerBid(0, 5); // 团队0叫5 + gameState.setPlayerBid(2, 2); // 团队0叫2,总共7 + + // 模拟团队0只赢得5墩(少于叫牌7) + for (int i = 0; i < 5; i++) { + gameState.incrementTricksTaken(0); + } + + int teamBid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 7 + int teamTricks = gameState.getTricksTaken(0) + gameState.getTricksTaken(2); // 5 + + assertTrue("团队实际墩数应该少于叫牌", teamTricks < teamBid); + int expectedPenalty = teamBid * 10; // 70分惩罚 + assertEquals("惩罚应该是70分", 70, expectedPenalty); + } + + /** + * 测试沙袋累积和惩罚 + * + * 每10个沙袋扣100分,这防止玩家故意低叫 + */ + @Test + public void testSandbagPenalty() { + SpadesParameters params = (SpadesParameters) gameState.getGameParameters(); + + // 设置团队已有9个沙袋 + gameState.addTeamSandbags(0, 9); + + // 再添加2个沙袋(总共11个) + gameState.addTeamSandbags(0, 2); + + // 检查是否触发沙袋惩罚 + if (gameState.getTeamSandbags(0) >= params.sandbagsPerPenalty) { + int penalty = params.sandbagsRandPenalty; // 100分 + assertEquals("沙袋惩罚应该是100分", 100, penalty); + + // 验证沙袋数重置 + int remainingSandbags = gameState.getTeamSandbags(0) - params.sandbagsPerPenalty; + assertEquals("剩余沙袋应该是1个", 1, remainingSandbags); + } + } + + // ======================================== + // 游戏结束条件测试 + // ======================================== + + /** + * 测试达到获胜分数的游戏结束 + * + * 当任意团队达到500分时游戏结束,得分高的团队获胜 + */ + @Test + public void testWinningScoreGameEnd() { + // 设置团队0得分为520分 + gameState.setTeamScore(0, 520); + gameState.setTeamScore(1, 300); + + SpadesParameters params = (SpadesParameters) gameState.getGameParameters(); + boolean gameEnded = gameState.getTeamScore(0) >= params.winningScore || + gameState.getTeamScore(1) >= params.winningScore; + + assertTrue("游戏应该结束(有团队达到获胜分数)", gameEnded); + + int winningTeam = gameState.getTeamScore(0) > gameState.getTeamScore(1) ? 0 : 1; + assertEquals("团队0应该获胜", 0, winningTeam); + } + + // ======================================== + // 状态管理和信息隐藏测试 + // ======================================== + + /** + * 测试游戏状态复制的正确性 + * + * 状态复制对MCTS算法至关重要,必须确保: + * - 复制的状态独立于原状态 + * - 信息隐藏正确实现 + * - hashCode一致性 + */ + @Test + public void testGameStateCopy() { + // 复制状态 + SpadesGameState copy = (SpadesGameState) gameState.copy(); + + // 验证复制的状态是独立的 + assertNotSame("复制的状态应该是不同的对象", gameState, copy); + assertEquals("复制的状态应该有相同的玩家数", gameState.getNPlayers(), copy.getNPlayers()); + assertEquals("复制的状态应该有相同的游戏阶段", gameState.getSpadesGamePhase(), copy.getSpadesGamePhase()); + + // 验证手牌复制 + for (int i = 0; i < 4; i++) { + assertNotSame("玩家手牌应该是独立复制的", + gameState.getPlayerHands().get(i), + copy.getPlayerHands().get(i)); + assertEquals("玩家手牌大小应该相同", + gameState.getPlayerHands().get(i).getSize(), + copy.getPlayerHands().get(i).getSize()); + } + } + + /** + * 测试特定玩家视角的状态复制(信息隐藏) + * + * 当为特定玩家复制状态时,其他玩家的手牌应该被随机化 + * 这对Information Set MCTS很重要 + */ + @Test + public void testPlayerSpecificStateCopy() { + // 为玩家0复制状态 + SpadesGameState copyForPlayer0 = (SpadesGameState) gameState.copy(0); + + // 玩家0自己的手牌应该保持不变 + assertEquals("玩家0的手牌应该保持原样", + gameState.getPlayerHands().get(0).getSize(), + copyForPlayer0.getPlayerHands().get(0).getSize()); + + // 其他玩家的手牌应该被随机化(但大小相同) + for (int i = 1; i < 4; i++) { + assertEquals("其他玩家的手牌大小应该相同", + gameState.getPlayerHands().get(i).getSize(), + copyForPlayer0.getPlayerHands().get(i).getSize()); + } + } + + /** + * 测试状态hashCode的一致性 + * + * 相同的状态应该有相同的hashCode,这对状态比较很重要 + */ + @Test + public void testStateHashCodeConsistency() { + SpadesGameState copy = (SpadesGameState) gameState.copy(); + + assertEquals("相同状态的hashCode应该相等", gameState.hashCode(), copy.hashCode()); + + // 修改复制的状态 + copy.setPlayerBid(0, 5); + + assertNotEquals("不同状态的hashCode应该不同", gameState.hashCode(), copy.hashCode()); + } + + // ======================================== + // 辅助方法 + // ======================================== + + /** + * 完成叫牌阶段的辅助方法 + * 让所有玩家完成叫牌,进入出牌阶段 + */ + private void completeBiddingPhase() { + forwardModel.next(gameState, new Bid(1, 3)); + forwardModel.next(gameState, new Bid(2, 4)); + forwardModel.next(gameState, new Bid(3, 2)); + forwardModel.next(gameState, new Bid(0, 4)); + } + + /** + * 设置完整trick的辅助方法 + * 为测试赢牌判断准备游戏状态 + */ + private void setupCompleteTrick() { + // 清空所有玩家手牌,方便控制出牌 + for (int i = 0; i < 4; i++) { + gameState.getPlayerHands().get(i).clear(); + } + } + + /** + * 执行完整一轮出牌的辅助方法 + * + * @param card1 玩家1出的牌 + * @param card2 玩家2出的牌 + * @param card3 玩家3出的牌 + * @param card4 玩家0出的牌 + */ + private void playCompleteRound(FrenchCard card1, FrenchCard card2, FrenchCard card3, FrenchCard card4) { + // 添加牌到对应玩家手中并出牌 + gameState.getPlayerHands().get(1).add(card1); + forwardModel.next(gameState, new PlayCard(1, card1)); + + gameState.getPlayerHands().get(2).add(card2); + forwardModel.next(gameState, new PlayCard(2, card2)); + + gameState.getPlayerHands().get(3).add(card3); + forwardModel.next(gameState, new PlayCard(3, card3)); + + gameState.getPlayerHands().get(0).add(card4); + forwardModel.next(gameState, new PlayCard(0, card4)); + } +} \ No newline at end of file From 54df8fc9fa291d24b08f9c3dfa0f6bc3432ab976 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 27 Dec 2025 12:03:32 +0000 Subject: [PATCH 2/4] equals() and hashcode() to include PlayerBlindNil --- .../java/games/spades/SpadesGameState.java | 123 +++++++++--------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/src/main/java/games/spades/SpadesGameState.java b/src/main/java/games/spades/SpadesGameState.java index e2123014a..53770c9c9 100644 --- a/src/main/java/games/spades/SpadesGameState.java +++ b/src/main/java/games/spades/SpadesGameState.java @@ -32,12 +32,12 @@ public class SpadesGameState extends AbstractGameState implements IPrintable { public Phase gamePhase = Phase.BIDDING; public FrenchCard.Suite leadSuit; public boolean spadesBroken = false; - + public enum Phase implements IGamePhase { BIDDING, PLAYING } - + public SpadesGameState(AbstractParameters gameParameters, int nPlayers) { super(gameParameters, nPlayers); @@ -52,7 +52,7 @@ public SpadesGameState(AbstractParameters gameParameters, int nPlayers) { teamSandbags = new int[2]; playerBlindNil = new boolean[4]; leadPlayer = 0; - + for (int i = 0; i < 4; i++) { Deck hand = new Deck<>("Player" + i + "Hand", CoreConstants.VisibilityMode.VISIBLE_TO_OWNER); hand.setOwnerId(i); @@ -60,35 +60,35 @@ public SpadesGameState(AbstractParameters gameParameters, int nPlayers) { tricksWon.add(new ArrayList<>()); } } - + @Override protected GameType _getGameType() { return GameType.Spades; } - + @Override protected List _getAllComponents() { List components = new ArrayList<>(); - + for (Deck hand : playerHands) { components.add(hand); components.addAll(hand.getComponents()); } - + for (List> playerTricks : tricksWon) { for (Deck trick : playerTricks) { components.add(trick); components.addAll(trick.getComponents()); } } - + for (Map.Entry entry : currentTrick) { components.add(entry.getValue()); } - + return components; } - + @Override protected SpadesGameState _copy(int playerId) { SpadesGameState copy = new SpadesGameState(gameParameters, getNPlayers()); @@ -136,7 +136,7 @@ protected SpadesGameState _copy(int playerId) { return copy; } - + @Override protected double _getHeuristicScore(int playerId) { if (isNotTerminal()) { @@ -146,143 +146,144 @@ protected double _getHeuristicScore(int playerId) { return getPlayerResults()[playerId].value; } } - + @Override public double getGameScore(int playerId) { int team = getTeam(playerId); return teamScores[team]; } - + public int getTeam(int playerId) { return playerId % 2; } - + public int getNTeams() { return 2; } - + public List> getPlayerHands() { return playerHands; } - + public int getPlayerBid(int playerId) { return playerBids[playerId]; } - + public void setPlayerBid(int playerId, int bid) { playerBids[playerId] = bid; } - + public boolean allPlayersBid() { for (int bid : playerBids) { if (bid == -1) return false; } return true; } - + public int getTricksTaken(int playerId) { return tricksTaken[playerId]; } - + public void incrementTricksTaken(int playerId) { tricksTaken[playerId]++; } - + public int getTeamScore(int team) { return teamScores[team]; } - + public void setTeamScore(int team, int score) { teamScores[team] = score; } - + public int getTeamSandbags(int team) { return teamSandbags[team]; } - + public void addTeamSandbags(int team, int sandbags) { teamSandbags[team] += sandbags; } - + public Phase getSpadesGamePhase() { return gamePhase; } - + public void setSpadesGamePhase(Phase phase) { this.gamePhase = phase; } - + public boolean isSpadesBroken() { return spadesBroken; } - + public void setSpadesBroken(boolean broken) { this.spadesBroken = broken; } - + public FrenchCard.Suite getLeadSuit() { return leadSuit; } - + public void setLeadSuit(FrenchCard.Suite suit) { this.leadSuit = suit; } - + public int getLeadPlayer() { return leadPlayer; } - + public void setLeadPlayer(int playerId) { this.leadPlayer = playerId; } - + public List> getCurrentTrick() { return currentTrick; } - + public void clearCurrentTrick() { currentTrick.clear(); leadSuit = null; } - + @Override protected boolean _equals(Object o) { if (this == o) return true; if (!(o instanceof SpadesGameState)) return false; if (!super.equals(o)) return false; - + SpadesGameState that = (SpadesGameState) o; return Objects.equals(playerHands, that.playerHands) && - Objects.equals(currentTrick, that.currentTrick) && - Objects.equals(tricksWon, that.tricksWon) && - Arrays.equals(playerBids, that.playerBids) && - Arrays.equals(tricksTaken, that.tricksTaken) && - Arrays.equals(teamScores, that.teamScores) && - Arrays.equals(teamSandbags, that.teamSandbags) && - leadPlayer == that.leadPlayer && - gamePhase == that.gamePhase && - leadSuit == that.leadSuit && - spadesBroken == that.spadesBroken; - } - + Objects.equals(currentTrick, that.currentTrick) && + Objects.equals(tricksWon, that.tricksWon) && + Arrays.equals(playerBids, that.playerBids) && + Arrays.equals(tricksTaken, that.tricksTaken) && + Arrays.equals(teamScores, that.teamScores) && + Arrays.equals(teamSandbags, that.teamSandbags) && + Arrays.equals(playerBlindNil, that.playerBlindNil) && + leadPlayer == that.leadPlayer && + gamePhase == that.gamePhase && + leadSuit == that.leadSuit && + spadesBroken == that.spadesBroken; + } + @Override public int hashCode() { - int result = Objects.hash(super.hashCode(), playerHands, currentTrick, tricksWon, - leadPlayer, gamePhase, leadSuit, spadesBroken); + int result = Objects.hash(super.hashCode(), playerHands, currentTrick, tricksWon, + leadPlayer, gamePhase, leadSuit.ordinal(), spadesBroken); result = 31 * result + Arrays.hashCode(playerBids); result = 31 * result + Arrays.hashCode(tricksTaken); result = 31 * result + Arrays.hashCode(teamScores); - result = 31 * result + Arrays.hashCode(teamSandbags); + result = 31 * result + Arrays.hashCode(teamSandbags) + 31 * 31 * Arrays.hashCode(playerBlindNil); return result; } - + @Override public void printToConsole() { System.out.println("=== SPADES GAME STATE ==="); System.out.println("Round: " + getRoundCounter() + ", Turn: " + getTurnCounter()); System.out.println("Phase: " + gamePhase + ", Current Player: " + getCurrentPlayer()); System.out.println("Spades Broken: " + spadesBroken); - + // Current trick information if (!currentTrick.isEmpty()) { System.out.println("\nCurrent Trick (Lead Suit: " + leadSuit + "):"); @@ -290,13 +291,13 @@ public void printToConsole() { System.out.println(" Player " + entry.getKey() + ": " + entry.getValue()); } } - + // Player information System.out.println("\nPLAYERS:"); for (int i = 0; i < 4; i++) { String marker = (i == getCurrentPlayer()) ? ">>> " : " "; System.out.print(marker + "Player " + i + " (Team " + getTeam(i) + ")"); - + // Bid information if (playerBids[i] != -1) { String bidText = (playerBids[i] == 0) ? "Nil" : String.valueOf(playerBids[i]); @@ -304,24 +305,24 @@ public void printToConsole() { } else { System.out.print(" - Bid: Not set"); } - + // Tricks taken System.out.print(", Tricks: " + tricksTaken[i]); - + // Hand size System.out.println(", Cards: " + playerHands.get(i).getSize()); - + // Show actual cards for current player or if game is over if (i == getCurrentPlayer() || !isNotTerminal()) { System.out.println(marker + "Hand: " + playerHands.get(i).toString()); } } - + // Team scores System.out.println("\nTEAM SCORES:"); System.out.println("Team 0 (Players 0 & 2): " + teamScores[0] + " points, " + teamSandbags[0] + " sandbags"); System.out.println("Team 1 (Players 1 & 3): " + teamScores[1] + " points, " + teamSandbags[1] + " sandbags"); - + // Game status if (!isNotTerminal()) { System.out.println("\nGAME OVER!"); @@ -329,7 +330,7 @@ public void printToConsole() { System.out.println("Player " + i + " result: " + getPlayerResults()[i]); } } - + System.out.println("========================"); } } \ No newline at end of file From f68d0d7a34e7f92a4d081baae88e3abb382d39dd Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 27 Dec 2025 14:59:17 +0000 Subject: [PATCH 3/4] Removed odd use of Map.Entry --- .../java/games/spades/SpadesForwardModel.java | 163 +++++++++--------- .../java/games/spades/SpadesGameState.java | 25 ++- src/main/java/games/spades/actions/Bid.java | 24 +-- .../java/games/spades/actions/PlayCard.java | 25 ++- .../games/spades/gui/SpadesTrickView.java | 9 +- src/test/java/games/spades/TestSpades.java | 39 ++--- 6 files changed, 134 insertions(+), 151 deletions(-) diff --git a/src/main/java/games/spades/SpadesForwardModel.java b/src/main/java/games/spades/SpadesForwardModel.java index 93d155244..52f9ada8b 100644 --- a/src/main/java/games/spades/SpadesForwardModel.java +++ b/src/main/java/games/spades/SpadesForwardModel.java @@ -8,6 +8,7 @@ import core.components.FrenchCard; import games.spades.actions.Bid; import games.spades.actions.PlayCard; +import utilities.Pair; import java.util.ArrayList; import java.util.Arrays; @@ -15,93 +16,70 @@ import java.util.Map; public class SpadesForwardModel extends StandardForwardModel { - + @Override protected void _setup(AbstractGameState firstState) { SpadesGameState state = (SpadesGameState) firstState; - if (firstState.getRoundCounter() == 0) { - Arrays.fill(state.teamScores, 0); - Arrays.fill(state.teamSandbags, 0); - for (int i = 0; i < 4; i++) { - state.playerBids[i] = -1; - state.tricksTaken[i] = 0; - state.tricksWon.get(i).clear(); - } - state.currentTrick.clear(); - state.spadesBroken = false; - state.leadSuit = null; - } - + Arrays.fill(state.teamScores, 0); + Arrays.fill(state.teamSandbags, 0); for (int i = 0; i < 4; i++) { - Deck hand = state.getPlayerHands().get(i); - hand.clear(); - hand.setOwnerId(i); - } - - Deck deck = FrenchCard.generateDeck("MainDeck", CoreConstants.VisibilityMode.HIDDEN_TO_ALL); - deck.shuffle(state.getRnd()); - - for (int i = 0; i < 13; i++) { - for (int p = 0; p < 4; p++) { - FrenchCard card = deck.draw(); - state.getPlayerHands().get(p).add(card); - card.setOwnerId(p); - } + state.playerBids[i] = -1; + state.tricksTaken[i] = 0; + state.tricksWon.get(i).clear(); } - - state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); + state.currentTrick.clear(); + state.spadesBroken = false; + state.leadSuit = null; - endPlayerTurn(state, 1); - state.setLeadPlayer(1); + startNewRound(state); } - + @Override protected List _computeAvailableActions(AbstractGameState gameState) { SpadesGameState state = (SpadesGameState) gameState; List actions = new ArrayList<>(); int currentPlayer = state.getCurrentPlayer(); SpadesParameters params = (SpadesParameters) state.getGameParameters(); - + if (state.getSpadesGamePhase() == SpadesGameState.Phase.BIDDING) { int team = state.getTeam(currentPlayer); boolean nilAllowed = params.allowNilOverbid || state.getTeamScore(team) < 500; - int minBid = Math.max(0, params.minBid); + int minBid = 0; int maxBid = Math.min(13, params.maxBid); for (int bid = minBid; bid <= maxBid; bid++) { if (bid == 0 && !nilAllowed) continue; // restrict Nil if house rule disallows it at high scores - actions.add(new Bid(currentPlayer, bid)); + actions.add(new Bid(bid)); } if (params.allowBlindNil && nilAllowed) { // Offer Blind Nil as a distinct bid option (uses Bid with blind flag) - actions.add(new Bid(currentPlayer, 0, true)); + actions.add(new Bid(0, true)); } } else if (state.getSpadesGamePhase() == SpadesGameState.Phase.PLAYING) { Deck playerHand = state.getPlayerHands().get(currentPlayer); - + for (FrenchCard card : playerHand.getComponents()) { if (isValidPlay(state, card)) { - actions.add(new PlayCard(currentPlayer, card)); + actions.add(new PlayCard(card)); } } } - + return actions; } - + /** * Determines legal card plays */ private boolean isValidPlay(SpadesGameState state, FrenchCard card) { - List> currentTrick = state.getCurrentTrick(); + List> currentTrick = state.getCurrentTrick(); int currentPlayer = state.getCurrentPlayer(); Deck playerHand = state.getPlayerHands().get(currentPlayer); if (currentTrick.isEmpty()) { if (card.suite == FrenchCard.Suite.Spades) { if (!state.isSpadesBroken()) { - boolean hasOnlySpades = playerHand.getComponents().stream() + return playerHand.getComponents().stream() .allMatch(c -> c.suite == FrenchCard.Suite.Spades); - return hasOnlySpades; } } return true; @@ -110,18 +88,18 @@ private boolean isValidPlay(SpadesGameState state, FrenchCard card) { if (card.suite == leadSuit) { return true; } - + boolean hasLeadSuit = playerHand.getComponents().stream() .anyMatch(c -> c.suite == leadSuit); - + return !hasLeadSuit; } } - + @Override protected void _afterAction(AbstractGameState currentState, AbstractAction actionTaken) { SpadesGameState state = (SpadesGameState) currentState; - + if (actionTaken instanceof Bid) { if (state.allPlayersBid()) { state.setSpadesGamePhase(SpadesGameState.Phase.PLAYING); @@ -130,32 +108,31 @@ protected void _afterAction(AbstractGameState currentState, AbstractAction actio } else { endPlayerTurn(state); } - } else if (actionTaken instanceof PlayCard) { - PlayCard playAction = (PlayCard) actionTaken; - + } else if (actionTaken instanceof PlayCard playAction) { + if (state.getCurrentTrick().size() == 1) { state.setLeadSuit(playAction.card.suite); } - + if (playAction.card.suite == FrenchCard.Suite.Spades) { state.setSpadesBroken(true); } - + if (state.getCurrentTrick().size() == 4) { int trickWinner = determineTrickWinner(state); state.incrementTricksTaken(trickWinner); Deck trickDeck = new Deck<>("Trick", CoreConstants.VisibilityMode.VISIBLE_TO_ALL); - for (Map.Entry entry : state.getCurrentTrick()) { - trickDeck.add(entry.getValue()); + for (Pair entry : state.getCurrentTrick()) { + trickDeck.add(entry.b); } state.tricksWon.get(trickWinner).add(trickDeck); - + state.clearCurrentTrick(); - + endPlayerTurn(state, trickWinner); state.setLeadPlayer(trickWinner); - + if (state.getPlayerHands().get(0).getSize() == 0) { endRound(state); } else { @@ -166,39 +143,39 @@ protected void _afterAction(AbstractGameState currentState, AbstractAction actio } } } - + /** * Determines the winner of a completed trick */ private int determineTrickWinner(SpadesGameState state) { - List> trick = state.getCurrentTrick(); + List> trick = state.getCurrentTrick(); FrenchCard.Suite leadSuit = state.getLeadSuit(); - - int winner = trick.get(0).getKey(); - FrenchCard winningCard = trick.get(0).getValue(); - - for (Map.Entry entry : trick) { - FrenchCard card = entry.getValue(); + + int winner = trick.get(0).a; + FrenchCard winningCard = trick.get(0).b; + + for (Pair entry : trick) { + FrenchCard card = entry.b; if (card.suite == FrenchCard.Suite.Spades && winningCard.suite != FrenchCard.Suite.Spades) { - winner = entry.getKey(); + winner = entry.a; winningCard = card; - } else if (card.suite == FrenchCard.Suite.Spades && winningCard.suite == FrenchCard.Suite.Spades) { + } else if (card.suite == FrenchCard.Suite.Spades) { if (getCardValue(card) > getCardValue(winningCard)) { - winner = entry.getKey(); + winner = entry.a; winningCard = card; } } else if (card.suite == leadSuit && winningCard.suite != FrenchCard.Suite.Spades) { if (winningCard.suite != leadSuit || getCardValue(card) > getCardValue(winningCard)) { - winner = entry.getKey(); + winner = entry.a; winningCard = card; } } } - + return winner; } - + /** * Gets the value of a card for comparison (higher is better) */ @@ -209,18 +186,18 @@ private int getCardValue(FrenchCard card) { if (card.type == FrenchCard.FrenchCardType.Jack) return 11; return card.number; } - + /** * Handles end of round scoring and checks for game end */ private void endRound(SpadesGameState state) { SpadesParameters params = (SpadesParameters) state.getGameParameters(); - + for (int team = 0; team < 2; team++) { // Teams in Spades are (0,2) and (1,3) int player1 = team; // 0 for team 0, 1 for team 1 int player2 = team + 2; // 2 for team 0, 3 for team 1 - + int bid1 = state.getPlayerBid(player1); int bid2 = state.getPlayerBid(player2); int tricks1 = state.getTricksTaken(player1); @@ -275,7 +252,7 @@ private void endRound(SpadesGameState state) { state.setTeamScore(team, teamScore); } - + boolean gameEnded = false; for (int team = 0; team < 2; team++) { if (state.getTeamScore(team) >= params.winningScore) { @@ -283,7 +260,8 @@ private void endRound(SpadesGameState state) { break; } } - + + // TODO: Check game end stuff for overlap with framework in a team game if (gameEnded) { int winningTeam = state.getTeamScore(0) > state.getTeamScore(1) ? 0 : 1; for (int p = 0; p < 4; p++) { @@ -301,7 +279,7 @@ private void endRound(SpadesGameState state) { } } } - + /** * Starts a new round of play */ @@ -311,15 +289,36 @@ private void startNewRound(SpadesGameState state) { state.tricksTaken[i] = 0; state.tricksWon.get(i).clear(); } - + state.currentTrick.clear(); state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); state.setSpadesBroken(false); state.leadSuit = null; - - _setup(state); + + for (int i = 0; i < 4; i++) { + Deck hand = state.getPlayerHands().get(i); + hand.clear(); + hand.setOwnerId(i); + } + + Deck deck = FrenchCard.generateDeck("MainDeck", CoreConstants.VisibilityMode.HIDDEN_TO_ALL); + deck.shuffle(state.getRnd()); + + for (int i = 0; i < 13; i++) { + for (int p = 0; p < 4; p++) { + FrenchCard card = deck.draw(); + state.getPlayerHands().get(p).add(card); + card.setOwnerId(p); + } + } + + state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); + + // TODO: Rotate the lead player properly + endPlayerTurn(state, 1); + state.setLeadPlayer(1); } - + @Override protected void endGame(AbstractGameState gs) { // Override to set team-based winners by score when framework triggers end (e.g., maxRounds) diff --git a/src/main/java/games/spades/SpadesGameState.java b/src/main/java/games/spades/SpadesGameState.java index 53770c9c9..bebb7818d 100644 --- a/src/main/java/games/spades/SpadesGameState.java +++ b/src/main/java/games/spades/SpadesGameState.java @@ -10,6 +10,7 @@ import core.interfaces.IPrintable; import games.GameType; import utilities.DeterminisationUtilities; +import utilities.Pair; import java.util.ArrayList; import java.util.Arrays; @@ -21,7 +22,7 @@ public class SpadesGameState extends AbstractGameState implements IPrintable { List> playerHands; - public List> currentTrick = new ArrayList<>(); + public List> currentTrick = new ArrayList<>(); public List>> tricksWon; public int[] playerBids; public int[] tricksTaken; @@ -82,8 +83,8 @@ protected List _getAllComponents() { } } - for (Map.Entry entry : currentTrick) { - components.add(entry.getValue()); + for (Pair entry : currentTrick) { + components.add(entry.b); } return components; @@ -94,16 +95,12 @@ protected SpadesGameState _copy(int playerId) { SpadesGameState copy = new SpadesGameState(gameParameters, getNPlayers()); copy.currentTrick = new ArrayList<>(); - for (Map.Entry entry : currentTrick) { - copy.currentTrick.add(new HashMap.SimpleEntry<>(entry.getKey(), entry.getValue().copy())); - } + // the Pair that represents a trick is immutable + copy.currentTrick.addAll(currentTrick); copy.tricksWon = new ArrayList<>(); for (List> playerTricks : tricksWon) { - List> copyTricks = new ArrayList<>(); - for (Deck trick : playerTricks) { - copyTricks.add(trick.copy()); - } + List> copyTricks = new ArrayList<>(playerTricks); copy.tricksWon.add(copyTricks); } @@ -236,7 +233,7 @@ public void setLeadPlayer(int playerId) { this.leadPlayer = playerId; } - public List> getCurrentTrick() { + public List> getCurrentTrick() { return currentTrick; } @@ -269,7 +266,7 @@ protected boolean _equals(Object o) { @Override public int hashCode() { int result = Objects.hash(super.hashCode(), playerHands, currentTrick, tricksWon, - leadPlayer, gamePhase, leadSuit.ordinal(), spadesBroken); + leadPlayer, gamePhase, leadSuit == null ? -1 : leadSuit.ordinal(), spadesBroken); result = 31 * result + Arrays.hashCode(playerBids); result = 31 * result + Arrays.hashCode(tricksTaken); result = 31 * result + Arrays.hashCode(teamScores); @@ -287,8 +284,8 @@ public void printToConsole() { // Current trick information if (!currentTrick.isEmpty()) { System.out.println("\nCurrent Trick (Lead Suit: " + leadSuit + "):"); - for (Map.Entry entry : currentTrick) { - System.out.println(" Player " + entry.getKey() + ": " + entry.getValue()); + for (Pair entry : currentTrick) { + System.out.println(" Player " + entry.a + ": " + entry.b); } } diff --git a/src/main/java/games/spades/actions/Bid.java b/src/main/java/games/spades/actions/Bid.java index 8ff983afb..d2258ca1e 100644 --- a/src/main/java/games/spades/actions/Bid.java +++ b/src/main/java/games/spades/actions/Bid.java @@ -12,16 +12,14 @@ */ public class Bid extends AbstractAction { - public final int playerId; public final int bidAmount; public final boolean blind; // true for Blind Nil - public Bid(int playerId, int bidAmount) { - this(playerId, bidAmount, false); + public Bid(int bidAmount) { + this(bidAmount, false); } - public Bid(int playerId, int bidAmount, boolean blind) { - this.playerId = playerId; + public Bid(int bidAmount, boolean blind) { this.bidAmount = bidAmount; this.blind = blind; } @@ -30,13 +28,10 @@ public Bid(int playerId, int bidAmount, boolean blind) { public boolean execute(AbstractGameState gameState) { SpadesGameState state = (SpadesGameState) gameState; - if (state.getCurrentPlayer() != playerId) { - throw new AssertionError("Player " + playerId + " tried to bid out of turn"); - } - if (state.getSpadesGamePhase() != SpadesGameState.Phase.BIDDING) { throw new AssertionError("Bid action called outside of bidding phase"); } + int playerId = state.getCurrentPlayer(); state.setPlayerBid(playerId, bidAmount); if (bidAmount == 0) { @@ -56,19 +51,18 @@ public AbstractAction copy() { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof Bid)) return false; - Bid bid = (Bid) o; - return playerId == bid.playerId && bidAmount == bid.bidAmount && blind == bid.blind; + if (!(o instanceof Bid bid)) return false; + return bidAmount == bid.bidAmount && blind == bid.blind; } @Override public int hashCode() { - return Objects.hash(playerId, bidAmount, blind); + return Objects.hash(bidAmount, blind); } @Override public void printToConsole() { - System.out.println(toString()); + System.out.println(this); } @Override @@ -78,6 +72,6 @@ public String getString(AbstractGameState gameState) { @Override public String toString() { - return "Player " + playerId + " bids " + (bidAmount == 0 ? (blind ? "Blind Nil" : "Nil") : bidAmount); + return "Bids " + (bidAmount == 0 ? (blind ? "Blind Nil" : "Nil") : bidAmount); } } \ No newline at end of file diff --git a/src/main/java/games/spades/actions/PlayCard.java b/src/main/java/games/spades/actions/PlayCard.java index 0cd67e8c7..772c4cfcf 100644 --- a/src/main/java/games/spades/actions/PlayCard.java +++ b/src/main/java/games/spades/actions/PlayCard.java @@ -6,6 +6,7 @@ import core.components.FrenchCard; import core.interfaces.IPrintable; import games.spades.SpadesGameState; +import utilities.Pair; import java.util.AbstractMap; import java.util.Objects; @@ -15,22 +16,17 @@ */ public class PlayCard extends AbstractAction implements IPrintable { - public final int playerId; public final FrenchCard card; - public PlayCard(int playerId, FrenchCard card) { - this.playerId = playerId; + public PlayCard(FrenchCard card) { this.card = card; } @Override public boolean execute(AbstractGameState gameState) { SpadesGameState state = (SpadesGameState) gameState; - - if (state.getCurrentPlayer() != playerId) { - throw new AssertionError("Player " + playerId + " tried to play out of turn"); - } - + int playerId = state.getCurrentPlayer(); + if (state.getSpadesGamePhase() != SpadesGameState.Phase.PLAYING) { throw new AssertionError("PlayCard action called outside of playing phase"); } @@ -44,7 +40,7 @@ public boolean execute(AbstractGameState gameState) { } // Add card to current trick - state.getCurrentTrick().add(new AbstractMap.SimpleEntry<>(playerId, card)); + state.getCurrentTrick().add(new Pair<>(playerId, card)); return true; } @@ -57,19 +53,18 @@ public AbstractAction copy() { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof PlayCard)) return false; - PlayCard playCard = (PlayCard) o; - return playerId == playCard.playerId && Objects.equals(card, playCard.card); + if (!(o instanceof PlayCard playCard)) return false; + return Objects.equals(card, playCard.card); } @Override public int hashCode() { - return Objects.hash(playerId, card); + return card.hashCode() + 2798; } @Override public void printToConsole() { - System.out.println(toString()); + System.out.println(this); } @Override @@ -79,6 +74,6 @@ public String getString(AbstractGameState gameState) { @Override public String toString() { - return "Player " + playerId + " plays " + card.toString(); + return "Plays " + card.toString(); } } \ No newline at end of file diff --git a/src/main/java/games/spades/gui/SpadesTrickView.java b/src/main/java/games/spades/gui/SpadesTrickView.java index b1a9a87f6..8ad9a423a 100644 --- a/src/main/java/games/spades/gui/SpadesTrickView.java +++ b/src/main/java/games/spades/gui/SpadesTrickView.java @@ -4,6 +4,7 @@ import gui.views.CardView; import games.spades.SpadesGameState; import utilities.ImageIO; +import utilities.Pair; import javax.swing.*; import java.awt.*; @@ -16,7 +17,7 @@ public class SpadesTrickView extends JPanel { private final String dataPath; - private List> currentTrick; + private List> currentTrick; private Image backOfCard; private int leadPlayer = -1; @@ -78,7 +79,7 @@ private void drawTrickCards(Graphics2D g) { // Defensive check to prevent IndexOutOfBoundsException if (i >= currentTrick.size()) break; - Map.Entry entry; + Pair entry; try { entry = currentTrick.get(i); } catch (IndexOutOfBoundsException e) { @@ -88,8 +89,8 @@ private void drawTrickCards(Graphics2D g) { if (entry == null) continue; - int playerId = entry.getKey(); - FrenchCard card = entry.getValue(); + int playerId = entry.a; + FrenchCard card = entry.b; if (card == null) continue; diff --git a/src/test/java/games/spades/TestSpades.java b/src/test/java/games/spades/TestSpades.java index 92975d899..c777963a0 100644 --- a/src/test/java/games/spades/TestSpades.java +++ b/src/test/java/games/spades/TestSpades.java @@ -101,7 +101,6 @@ public void testBiddingPhaseAvailableActions() { for (int i = 0; i < 14; i++) { assertTrue("所有动作都应该是Bid动作", actions.get(i) instanceof Bid); Bid bid = (Bid) actions.get(i); - assertEquals("叫牌应该由当前玩家执行", gameState.getCurrentPlayer(), bid.playerId); assertTrue("叫牌值应该在0-13范围内", bid.bidAmount >= 0 && bid.bidAmount <= 13); } } @@ -117,7 +116,7 @@ public void testBiddingPhaseAvailableActions() { @Test public void testBiddingExecution() { // 玩家1叫5 - Bid bid1 = new Bid(1, 5); + Bid bid1 = new Bid(5); forwardModel.next(gameState, bid1); assertEquals("玩家1的叫牌应该被记录", 5, gameState.getPlayerBid(1)); @@ -125,12 +124,12 @@ public void testBiddingExecution() { assertEquals("仍然应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); // 其他玩家完成叫牌 - forwardModel.next(gameState, new Bid(2, 3)); - forwardModel.next(gameState, new Bid(3, 4)); + forwardModel.next(gameState, new Bid(3)); + forwardModel.next(gameState, new Bid( 4)); assertEquals("应该轮到玩家0", 0, gameState.getCurrentPlayer()); // 最后一个玩家叫牌 - forwardModel.next(gameState, new Bid(0, 2)); + forwardModel.next(gameState, new Bid(2)); assertEquals("所有玩家叫牌后应该转换到出牌阶段", SpadesGameState.Phase.PLAYING, gameState.getSpadesGamePhase()); assertEquals("出牌阶段应该从玩家1开始(庄家左边)", 1, gameState.getCurrentPlayer()); } @@ -143,10 +142,10 @@ public void testBiddingExecution() { @Test public void testTeamBidCalculation() { // 完成所有叫牌 - forwardModel.next(gameState, new Bid(1, 5)); // 团队1 - forwardModel.next(gameState, new Bid(2, 3)); // 团队0 - forwardModel.next(gameState, new Bid(3, 4)); // 团队1 - forwardModel.next(gameState, new Bid(0, 2)); // 团队0 + forwardModel.next(gameState, new Bid(5)); // 团队1 + forwardModel.next(gameState, new Bid( 3)); // 团队0 + forwardModel.next(gameState, new Bid( 4)); // 团队1 + forwardModel.next(gameState, new Bid(2)); // 团队0 // 验证团队叫牌总和 int team0Bid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 2 + 3 = 5 @@ -178,8 +177,6 @@ public void testPlayingPhaseBasicActions() { // 验证所有动作都是PlayCard动作 for (AbstractAction action : actions) { assertTrue("所有动作都应该是PlayCard动作", action instanceof PlayCard); - PlayCard playAction = (PlayCard) action; - assertEquals("出牌应该由当前玩家执行", gameState.getCurrentPlayer(), playAction.playerId); } } @@ -254,7 +251,7 @@ public void testFollowSuitRule() { FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); player1Hand.add(heartAce); - forwardModel.next(gameState, new PlayCard(1, heartAce)); + forwardModel.next(gameState, new PlayCard(heartAce)); // 现在轮到玩家2,设置玩家2的手牌(有红心和其他花色) Deck player2Hand = gameState.getPlayerHands().get(2); @@ -286,7 +283,7 @@ public void testCannotFollowSuitRule() { FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); player1Hand.add(heartAce); - forwardModel.next(gameState, new PlayCard(1, heartAce)); + forwardModel.next(gameState, new PlayCard( heartAce)); // 设置玩家2没有红心 Deck player2Hand = gameState.getPlayerHands().get(2); @@ -547,10 +544,10 @@ public void testStateHashCodeConsistency() { * 让所有玩家完成叫牌,进入出牌阶段 */ private void completeBiddingPhase() { - forwardModel.next(gameState, new Bid(1, 3)); - forwardModel.next(gameState, new Bid(2, 4)); - forwardModel.next(gameState, new Bid(3, 2)); - forwardModel.next(gameState, new Bid(0, 4)); + forwardModel.next(gameState, new Bid( 3)); + forwardModel.next(gameState, new Bid(4)); + forwardModel.next(gameState, new Bid(2)); + forwardModel.next(gameState, new Bid(4)); } /** @@ -575,15 +572,15 @@ private void setupCompleteTrick() { private void playCompleteRound(FrenchCard card1, FrenchCard card2, FrenchCard card3, FrenchCard card4) { // 添加牌到对应玩家手中并出牌 gameState.getPlayerHands().get(1).add(card1); - forwardModel.next(gameState, new PlayCard(1, card1)); + forwardModel.next(gameState, new PlayCard(card1)); gameState.getPlayerHands().get(2).add(card2); - forwardModel.next(gameState, new PlayCard(2, card2)); + forwardModel.next(gameState, new PlayCard(card2)); gameState.getPlayerHands().get(3).add(card3); - forwardModel.next(gameState, new PlayCard(3, card3)); + forwardModel.next(gameState, new PlayCard( card3)); gameState.getPlayerHands().get(0).add(card4); - forwardModel.next(gameState, new PlayCard(0, card4)); + forwardModel.next(gameState, new PlayCard( card4)); } } \ No newline at end of file From 001e44496d597a444a5a8100182da8f460295810 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 27 Dec 2025 17:05:33 +0000 Subject: [PATCH 4/4] Tests updates and FM tests added --- .../java/games/spades/SpadesForwardModel.java | 84 ++--- .../java/games/spades/SpadesGameState.java | 25 +- .../java/games/spades/SpadesHeuristic.java | 4 +- .../java/games/spades/SpadesParameters.java | 1 - src/main/java/games/spades/actions/Bid.java | 2 +- .../java/games/spades/actions/PlayCard.java | 2 +- .../games/spades/gui/SpadesScoreView.java | 2 +- .../games/spades/gui/SpadesTrickView.java | 18 +- .../fmtester/ForwardModelTestsWithMCTS.java | 6 + .../fmtester/ForwardModelTestsWithRandom.java | 5 + src/test/java/games/spades/TestSpades.java | 357 +++++++----------- 11 files changed, 194 insertions(+), 312 deletions(-) diff --git a/src/main/java/games/spades/SpadesForwardModel.java b/src/main/java/games/spades/SpadesForwardModel.java index 52f9ada8b..1208a2873 100644 --- a/src/main/java/games/spades/SpadesForwardModel.java +++ b/src/main/java/games/spades/SpadesForwardModel.java @@ -29,7 +29,6 @@ protected void _setup(AbstractGameState firstState) { } state.currentTrick.clear(); state.spadesBroken = false; - state.leadSuit = null; startNewRound(state); } @@ -41,7 +40,7 @@ protected List _computeAvailableActions(AbstractGameState gameSt int currentPlayer = state.getCurrentPlayer(); SpadesParameters params = (SpadesParameters) state.getGameParameters(); - if (state.getSpadesGamePhase() == SpadesGameState.Phase.BIDDING) { + if (state.getGamePhase() == SpadesGameState.Phase.BIDDING) { int team = state.getTeam(currentPlayer); boolean nilAllowed = params.allowNilOverbid || state.getTeamScore(team) < 500; int minBid = 0; @@ -54,7 +53,7 @@ protected List _computeAvailableActions(AbstractGameState gameSt // Offer Blind Nil as a distinct bid option (uses Bid with blind flag) actions.add(new Bid(0, true)); } - } else if (state.getSpadesGamePhase() == SpadesGameState.Phase.PLAYING) { + } else if (state.getGamePhase() == SpadesGameState.Phase.PLAYING) { Deck playerHand = state.getPlayerHands().get(currentPlayer); for (FrenchCard card : playerHand.getComponents()) { @@ -102,12 +101,9 @@ protected void _afterAction(AbstractGameState currentState, AbstractAction actio if (actionTaken instanceof Bid) { if (state.allPlayersBid()) { - state.setSpadesGamePhase(SpadesGameState.Phase.PLAYING); - endPlayerTurn(state, 1); - state.setLeadPlayer(1); - } else { - endPlayerTurn(state); + state.setGamePhase(SpadesGameState.Phase.PLAYING); } + endPlayerTurn(state); } else if (actionTaken instanceof PlayCard playAction) { if (state.getCurrentTrick().size() == 1) { @@ -118,7 +114,8 @@ protected void _afterAction(AbstractGameState currentState, AbstractAction actio state.setSpadesBroken(true); } - if (state.getCurrentTrick().size() == 4) { + if (state.getCurrentTrick().size() == state.getNPlayers()) { + // trick finished int trickWinner = determineTrickWinner(state); state.incrementTricksTaken(trickWinner); @@ -127,20 +124,17 @@ protected void _afterAction(AbstractGameState currentState, AbstractAction actio trickDeck.add(entry.b); } state.tricksWon.get(trickWinner).add(trickDeck); - state.clearCurrentTrick(); - endPlayerTurn(state, trickWinner); - state.setLeadPlayer(trickWinner); if (state.getPlayerHands().get(0).getSize() == 0) { - endRound(state); - } else { - // Continue with next trick + // all cards played + nextRound(state); } } else { - endPlayerTurn(state); + endPlayerTurn(state); // otherwise default to next player } + } } @@ -190,40 +184,29 @@ private int getCardValue(FrenchCard card) { /** * Handles end of round scoring and checks for game end */ - private void endRound(SpadesGameState state) { + private void nextRound(SpadesGameState state) { SpadesParameters params = (SpadesParameters) state.getGameParameters(); for (int team = 0; team < 2; team++) { // Teams in Spades are (0,2) and (1,3) - int player1 = team; // 0 for team 0, 1 for team 1 - int player2 = team + 2; // 2 for team 0, 3 for team 1 - - int bid1 = state.getPlayerBid(player1); - int bid2 = state.getPlayerBid(player2); - int tricks1 = state.getTricksTaken(player1); - int tricks2 = state.getTricksTaken(player2); - int teamTricks = tricks1 + tricks2; + int[] teamPlayers = new int[] {team, team + 2}; + int teamScore = state.getTeamScore(team); + int teamTricks = 0; int teamBid = 0; // Only positive bids contribute to team bid; 0 is Nil and scored separately - if (bid1 > 0) teamBid += bid1; - - if (bid2 > 0) teamBid += bid2; - - int teamScore = state.getTeamScore(team); - - // Score Nil bids per player - if (bid1 == 0) { - boolean blind1 = state.playerBlindNil[player1]; - int bonus = blind1 ? params.blindNilBonusPoints : params.nilBonusPoints; - int penalty = blind1 ? params.blindNilPenaltyPoints : params.nilPenaltyPoints; - teamScore += (tricks1 == 0) ? bonus : -penalty; - } - if (bid2 == 0) { - boolean blind2 = state.playerBlindNil[player2]; - int bonus = blind2 ? params.blindNilBonusPoints : params.nilBonusPoints; - int penalty = blind2 ? params.blindNilPenaltyPoints : params.nilPenaltyPoints; - teamScore += (tricks2 == 0) ? bonus : -penalty; + for (int player : teamPlayers) { + int bid = state.getPlayerBid(player); + int tricks = state.getTricksTaken(player); + teamTricks += tricks; + if (bid > 0) { + teamBid += bid; + } else if (bid == 0) { + boolean blind1 = state.playerBlindNil[player]; + int bonus = blind1 ? params.blindNilBonusPoints : params.nilBonusPoints; + int penalty = blind1 ? params.blindNilPenaltyPoints : params.nilPenaltyPoints; + teamScore += (tricks == 0) ? bonus : -penalty; + } } // Team contract score (for non-nil bids) @@ -261,7 +244,6 @@ private void endRound(SpadesGameState state) { } } - // TODO: Check game end stuff for overlap with framework in a team game if (gameEnded) { int winningTeam = state.getTeamScore(0) > state.getTeamScore(1) ? 0 : 1; for (int p = 0; p < 4; p++) { @@ -273,7 +255,9 @@ private void endRound(SpadesGameState state) { } state.setGameStatus(CoreConstants.GameResult.GAME_END); } else { - super.endRound(state); + // the first player for the round rotates clockwise + int startPlayer = (state.getRoundCounter() + 1) % state.getNPlayers(); + endRound(state, startPlayer); if (state.getGameStatus() == CoreConstants.GameResult.GAME_ONGOING) { startNewRound(state); } @@ -291,14 +275,13 @@ private void startNewRound(SpadesGameState state) { } state.currentTrick.clear(); - state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); + state.setGamePhase(SpadesGameState.Phase.BIDDING); state.setSpadesBroken(false); state.leadSuit = null; for (int i = 0; i < 4; i++) { Deck hand = state.getPlayerHands().get(i); hand.clear(); - hand.setOwnerId(i); } Deck deck = FrenchCard.generateDeck("MainDeck", CoreConstants.VisibilityMode.HIDDEN_TO_ALL); @@ -311,12 +294,7 @@ private void startNewRound(SpadesGameState state) { card.setOwnerId(p); } } - - state.setSpadesGamePhase(SpadesGameState.Phase.BIDDING); - - // TODO: Rotate the lead player properly - endPlayerTurn(state, 1); - state.setLeadPlayer(1); + state.setGamePhase(SpadesGameState.Phase.BIDDING); } @Override diff --git a/src/main/java/games/spades/SpadesGameState.java b/src/main/java/games/spades/SpadesGameState.java index bebb7818d..dcc869307 100644 --- a/src/main/java/games/spades/SpadesGameState.java +++ b/src/main/java/games/spades/SpadesGameState.java @@ -29,8 +29,6 @@ public class SpadesGameState extends AbstractGameState implements IPrintable { public int[] teamScores; public int[] teamSandbags; public boolean[] playerBlindNil; - public int leadPlayer; - public Phase gamePhase = Phase.BIDDING; public FrenchCard.Suite leadSuit; public boolean spadesBroken = false; @@ -52,7 +50,6 @@ public SpadesGameState(AbstractParameters gameParameters, int nPlayers) { teamScores = new int[2]; teamSandbags = new int[2]; playerBlindNil = new boolean[4]; - leadPlayer = 0; for (int i = 0; i < 4; i++) { Deck hand = new Deck<>("Player" + i + "Hand", CoreConstants.VisibilityMode.VISIBLE_TO_OWNER); @@ -109,8 +106,6 @@ protected SpadesGameState _copy(int playerId) { copy.teamScores = Arrays.copyOf(teamScores, teamScores.length); copy.teamSandbags = Arrays.copyOf(teamSandbags, teamSandbags.length); copy.playerBlindNil = Arrays.copyOf(playerBlindNil, playerBlindNil.length); - copy.leadPlayer = leadPlayer; - copy.gamePhase = gamePhase; copy.leadSuit = leadSuit; copy.spadesBroken = spadesBroken; @@ -201,14 +196,6 @@ public void addTeamSandbags(int team, int sandbags) { teamSandbags[team] += sandbags; } - public Phase getSpadesGamePhase() { - return gamePhase; - } - - public void setSpadesGamePhase(Phase phase) { - this.gamePhase = phase; - } - public boolean isSpadesBroken() { return spadesBroken; } @@ -225,14 +212,6 @@ public void setLeadSuit(FrenchCard.Suite suit) { this.leadSuit = suit; } - public int getLeadPlayer() { - return leadPlayer; - } - - public void setLeadPlayer(int playerId) { - this.leadPlayer = playerId; - } - public List> getCurrentTrick() { return currentTrick; } @@ -257,8 +236,6 @@ protected boolean _equals(Object o) { Arrays.equals(teamScores, that.teamScores) && Arrays.equals(teamSandbags, that.teamSandbags) && Arrays.equals(playerBlindNil, that.playerBlindNil) && - leadPlayer == that.leadPlayer && - gamePhase == that.gamePhase && leadSuit == that.leadSuit && spadesBroken == that.spadesBroken; } @@ -266,7 +243,7 @@ protected boolean _equals(Object o) { @Override public int hashCode() { int result = Objects.hash(super.hashCode(), playerHands, currentTrick, tricksWon, - leadPlayer, gamePhase, leadSuit == null ? -1 : leadSuit.ordinal(), spadesBroken); + leadSuit == null ? -1 : leadSuit.ordinal(), spadesBroken); result = 31 * result + Arrays.hashCode(playerBids); result = 31 * result + Arrays.hashCode(tricksTaken); result = 31 * result + Arrays.hashCode(teamScores); diff --git a/src/main/java/games/spades/SpadesHeuristic.java b/src/main/java/games/spades/SpadesHeuristic.java index 4cf563135..afc2ce893 100644 --- a/src/main/java/games/spades/SpadesHeuristic.java +++ b/src/main/java/games/spades/SpadesHeuristic.java @@ -28,7 +28,7 @@ public double evaluateState(AbstractGameState gs, int playerId) { } // Add tactical evaluation based on current game phase - if (state.getSpadesGamePhase() == SpadesGameState.Phase.PLAYING) { + if (state.getGamePhase() == SpadesGameState.Phase.PLAYING) { int ourBid = state.getPlayerBid(playerId) + state.getPlayerBid((playerId + 2) % 4); int ourTricks = state.getTricksTaken(playerId) + state.getTricksTaken((playerId + 2) % 4); @@ -46,7 +46,7 @@ public double evaluateState(AbstractGameState gs, int playerId) { // Add hand strength evaluation double handStrength = evaluateHandStrength(state, playerId); scoreAdvantage += handStrength * 0.2; - } else if (state.getSpadesGamePhase() == SpadesGameState.Phase.BIDDING) { + } else if (state.getGamePhase() == SpadesGameState.Phase.BIDDING) { // During bidding, focus more on hand strength double handStrength = evaluateHandStrength(state, playerId); scoreAdvantage += handStrength * 0.4; diff --git a/src/main/java/games/spades/SpadesParameters.java b/src/main/java/games/spades/SpadesParameters.java index e8ef35b62..59e094dbb 100644 --- a/src/main/java/games/spades/SpadesParameters.java +++ b/src/main/java/games/spades/SpadesParameters.java @@ -14,7 +14,6 @@ public class SpadesParameters extends AbstractParameters { public final int blindNilBonusPoints = 200; public final int blindNilPenaltyPoints = 200; - public final int minBid = 0; public final int maxBid = 13; public boolean allowBlindNil = false; diff --git a/src/main/java/games/spades/actions/Bid.java b/src/main/java/games/spades/actions/Bid.java index d2258ca1e..43c81e6ce 100644 --- a/src/main/java/games/spades/actions/Bid.java +++ b/src/main/java/games/spades/actions/Bid.java @@ -28,7 +28,7 @@ public Bid(int bidAmount, boolean blind) { public boolean execute(AbstractGameState gameState) { SpadesGameState state = (SpadesGameState) gameState; - if (state.getSpadesGamePhase() != SpadesGameState.Phase.BIDDING) { + if (state.getGamePhase() != SpadesGameState.Phase.BIDDING) { throw new AssertionError("Bid action called outside of bidding phase"); } int playerId = state.getCurrentPlayer(); diff --git a/src/main/java/games/spades/actions/PlayCard.java b/src/main/java/games/spades/actions/PlayCard.java index 772c4cfcf..90951ff05 100644 --- a/src/main/java/games/spades/actions/PlayCard.java +++ b/src/main/java/games/spades/actions/PlayCard.java @@ -27,7 +27,7 @@ public boolean execute(AbstractGameState gameState) { SpadesGameState state = (SpadesGameState) gameState; int playerId = state.getCurrentPlayer(); - if (state.getSpadesGamePhase() != SpadesGameState.Phase.PLAYING) { + if (state.getGamePhase() != SpadesGameState.Phase.PLAYING) { throw new AssertionError("PlayCard action called outside of playing phase"); } diff --git a/src/main/java/games/spades/gui/SpadesScoreView.java b/src/main/java/games/spades/gui/SpadesScoreView.java index 279358e65..f6dfa6eef 100644 --- a/src/main/java/games/spades/gui/SpadesScoreView.java +++ b/src/main/java/games/spades/gui/SpadesScoreView.java @@ -116,7 +116,7 @@ protected void paintComponent(Graphics g) { // Game phase g2d.setFont(new Font("Arial", Font.ITALIC, 10)); g2d.setColor(Color.GRAY); - String phase = gameState.getSpadesGamePhase().name(); + String phase = gameState.getGamePhase().toString(); g2d.drawString("Phase: " + phase, 15, y); // Spades broken indicator diff --git a/src/main/java/games/spades/gui/SpadesTrickView.java b/src/main/java/games/spades/gui/SpadesTrickView.java index 8ad9a423a..46a06ccbf 100644 --- a/src/main/java/games/spades/gui/SpadesTrickView.java +++ b/src/main/java/games/spades/gui/SpadesTrickView.java @@ -19,8 +19,7 @@ public class SpadesTrickView extends JPanel { private final String dataPath; private List> currentTrick; private Image backOfCard; - private int leadPlayer = -1; - + public static final int CARD_WIDTH = 60; public static final int CARD_HEIGHT = 80; public static final int TRICK_AREA_WIDTH = 300; @@ -119,12 +118,6 @@ private void drawTrickCards(Graphics2D g) { break; } - // Highlight lead card - if (playerId == leadPlayer) { - g.setColor(new Color(255, 255, 0, 150)); // Yellow highlight - g.fillRoundRect(x - 3, y - 3, CARD_WIDTH + 6, CARD_HEIGHT + 6, 10, 10); - } - // Draw the card Image cardImage = getCardImage(card); CardView.drawCard(g, x, y, CARD_WIDTH, CARD_HEIGHT, card, cardImage, backOfCard, true); @@ -154,22 +147,13 @@ public void updateTrick(SpadesGameState gameState) { if (gameState != null) { try { this.currentTrick = gameState.getCurrentTrick(); - this.leadPlayer = gameState.getLeadPlayer(); } catch (Exception e) { // If there's an error, clear the trick this.currentTrick = null; - this.leadPlayer = -1; } } else { this.currentTrick = null; - this.leadPlayer = -1; } repaint(); } - - public void clearTrick() { - this.currentTrick = null; - this.leadPlayer = -1; - repaint(); - } } \ No newline at end of file diff --git a/src/test/java/games/fmtester/ForwardModelTestsWithMCTS.java b/src/test/java/games/fmtester/ForwardModelTestsWithMCTS.java index 205b2ae93..827abff3c 100644 --- a/src/test/java/games/fmtester/ForwardModelTestsWithMCTS.java +++ b/src/test/java/games/fmtester/ForwardModelTestsWithMCTS.java @@ -154,6 +154,12 @@ public void testHearts() { new ForwardModelTester("game=Hearts", "nGames=2", "nPlayers=4", "agent=json\\players\\gameSpecific\\Hearts\\Hearts.json", "budget=50"); } + @Test + public void testSpades() { + new ForwardModelTester("game=Hearts", "nGames=2", "nPlayers=4", "agent=json\\players\\gameSpecific\\Hearts\\Hearts.json", "budget=50"); + } + + @Test public void testMastermind() { new ForwardModelTester("game=Mastermind", "nGames=2", "nPlayers=1", "agent=json\\players\\gameSpecific\\TicTacToe.json"); diff --git a/src/test/java/games/fmtester/ForwardModelTestsWithRandom.java b/src/test/java/games/fmtester/ForwardModelTestsWithRandom.java index 6b13b5f9a..5427019a0 100644 --- a/src/test/java/games/fmtester/ForwardModelTestsWithRandom.java +++ b/src/test/java/games/fmtester/ForwardModelTestsWithRandom.java @@ -12,6 +12,11 @@ public void testRoot() { new ForwardModelTester("game=Root", "nGames=1", "nPlayers=4"); } + @Test + public void testSpades() { + new ForwardModelTester("game=Spades", "nGames=2", "nPlayers=4"); + } + @Test public void testPickomino() { new ForwardModelTester("game=Pickomino", "nGames=1", "nPlayers=2"); diff --git a/src/test/java/games/spades/TestSpades.java b/src/test/java/games/spades/TestSpades.java index c777963a0..6c45c14d9 100644 --- a/src/test/java/games/spades/TestSpades.java +++ b/src/test/java/games/spades/TestSpades.java @@ -1,7 +1,6 @@ package games.spades; import core.AbstractParameters; -import core.CoreConstants; import core.actions.AbstractAction; import core.components.Deck; import core.components.FrenchCard; @@ -10,9 +9,10 @@ import org.junit.*; import java.util.List; -import java.util.Map; +import java.util.Random; -import static core.CoreConstants.GameResult.*; +import static games.spades.SpadesGameState.Phase.BIDDING; +import static games.spades.SpadesGameState.Phase.PLAYING; import static org.junit.Assert.*; @@ -20,12 +20,12 @@ public class TestSpades { private SpadesForwardModel forwardModel; private SpadesGameState gameState; - private AbstractParameters gameParameters; + Random rnd = new Random(64); @Before public void setUp() { forwardModel = new SpadesForwardModel(); - gameParameters = new SpadesParameters(); + AbstractParameters gameParameters = new SpadesParameters(); gameState = new SpadesGameState(gameParameters, 4); forwardModel.setup(gameState); } @@ -35,9 +35,6 @@ public void setUp() { // ======================================== /** - * 测试游戏初始设置是否正确 - * - * 验证内容: * - 4 plyaers * - 13 cards * - Start as Bidding phase @@ -49,8 +46,8 @@ public void setUp() { @Test public void testGameSetup() { assertEquals(4, gameState.getNPlayers()); - assertEquals(SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); - assertEquals(1, gameState.getCurrentPlayer()); + assertEquals(SpadesGameState.Phase.BIDDING, gameState.getGamePhase()); + assertEquals(0, gameState.getCurrentPlayer()); for (int i = 0; i < 4; i++) { assertEquals(13, gameState.getPlayerHands().get(i).getSize()); @@ -62,14 +59,14 @@ public void testGameSetup() { assertFalse(gameState.isSpadesBroken()); int totalCards = gameState.getPlayerHands().stream() - .mapToInt(hand -> hand.getSize()) + .mapToInt(Deck::getSize) .sum(); assertEquals("总共应该有52张牌", 52, totalCards); } /** * player-team - * + *

* player0&2 - team1, player1&3 - team2 */ @Test @@ -80,34 +77,34 @@ public void testPlayerTeamMapping() { assertEquals(1, gameState.getTeam(3)); } + // ======================================== // Bidding // ======================================== - /** - * 测试叫牌阶段的可用动作 - * - * 在叫牌阶段,当前玩家应该能够叫0-13的任意数字 - * 这测试了action generation的正确性 - */ @Test public void testBiddingPhaseAvailableActions() { - assertEquals("游戏开始时应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); - + assertEquals(SpadesGameState.Phase.BIDDING, gameState.getGamePhase()); List actions = forwardModel._computeAvailableActions(gameState); - assertEquals("叫牌阶段应该有14个可用动作(0-13)", 14, actions.size()); - - // 验证所有动作都是Bid动作且范围正确 - for (int i = 0; i < 14; i++) { - assertTrue("所有动作都应该是Bid动作", actions.get(i) instanceof Bid); - Bid bid = (Bid) actions.get(i); - assertTrue("叫牌值应该在0-13范围内", bid.bidAmount >= 0 && bid.bidAmount <= 13); + assertEquals(14, actions.size()); + + assertTrue(actions.stream().allMatch(a -> a instanceof Bid)); + // check we have one of each number + for (int b = 0; b < 14; b++) { + assertTrue(actions.contains(new Bid(b))); } + + for (int i = 0; i < 4; i++) { + assertEquals(i, gameState.getCurrentPlayer()); + forwardModel.next(gameState, new Bid(i + 1)); + } + assertEquals(PLAYING, gameState.getGamePhase()); + } /** * 测试叫牌动作的执行和阶段转换 - * + *

* 验证: * - 叫牌被正确记录 * - 轮到下一个玩家 @@ -118,41 +115,63 @@ public void testBiddingExecution() { // 玩家1叫5 Bid bid1 = new Bid(5); forwardModel.next(gameState, bid1); - - assertEquals("玩家1的叫牌应该被记录", 5, gameState.getPlayerBid(1)); - assertEquals("应该轮到玩家2", 2, gameState.getCurrentPlayer()); - assertEquals("仍然应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getSpadesGamePhase()); - + + assertEquals(5, gameState.getPlayerBid(0)); + assertEquals(-1, gameState.getPlayerBid(1)); + + assertEquals("应该轮到玩家", 1, gameState.getCurrentPlayer()); + assertEquals("仍然应该处于叫牌阶段", SpadesGameState.Phase.BIDDING, gameState.getGamePhase()); + // 其他玩家完成叫牌 forwardModel.next(gameState, new Bid(3)); - forwardModel.next(gameState, new Bid( 4)); - assertEquals("应该轮到玩家0", 0, gameState.getCurrentPlayer()); - + forwardModel.next(gameState, new Bid(4)); + // 最后一个玩家叫牌 forwardModel.next(gameState, new Bid(2)); - assertEquals("所有玩家叫牌后应该转换到出牌阶段", SpadesGameState.Phase.PLAYING, gameState.getSpadesGamePhase()); - assertEquals("出牌阶段应该从玩家1开始(庄家左边)", 1, gameState.getCurrentPlayer()); + assertEquals(PLAYING, gameState.getGamePhase()); + assertEquals(0, gameState.getCurrentPlayer()); + } + + @Test + public void testStartingBidderRotates() { + // there are a total of 4 x 14 = 56 actions (including bids) in a round + assertEquals(0, gameState.getRoundCounter()); + for (int i = 0; i < 56; i++) { + assertEquals(0, gameState.getRoundCounter()); + List actions = forwardModel.computeAvailableActions(gameState); + forwardModel.next(gameState, actions.get(rnd.nextInt(actions.size()))); + } + assertEquals(1, gameState.getRoundCounter()); + assertEquals(1, gameState.getCurrentPlayer()); + assertEquals(BIDDING, gameState.getGamePhase()); + for (int i = 0; i < 4; i++) { + List actions = forwardModel.computeAvailableActions(gameState); + forwardModel.next(gameState, actions.get(rnd.nextInt(actions.size()))); + } + assertEquals(1, gameState.getRoundCounter()); + assertEquals(1, gameState.getCurrentPlayer()); + assertEquals(PLAYING, gameState.getGamePhase()); } /** * 测试团队叫牌计算 - * + *

* 团队的总叫牌是两个队友叫牌的和,这对最终得分计算很重要 */ @Test public void testTeamBidCalculation() { // 完成所有叫牌 forwardModel.next(gameState, new Bid(5)); // 团队1 - forwardModel.next(gameState, new Bid( 3)); // 团队0 - forwardModel.next(gameState, new Bid( 4)); // 团队1 + forwardModel.next(gameState, new Bid(3)); // 团队0 + forwardModel.next(gameState, new Bid(4)); // 团队1 forwardModel.next(gameState, new Bid(2)); // 团队0 - + // 验证团队叫牌总和 int team0Bid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 2 + 3 = 5 int team1Bid = gameState.getPlayerBid(1) + gameState.getPlayerBid(3); // 5 + 4 = 9 - - assertEquals("团队0的总叫牌应该是5", 5, team0Bid); - assertEquals("团队1的总叫牌应该是9", 9, team1Bid); + + assertEquals("团队0的总叫牌应该是5", 9, team0Bid); + assertEquals("团队1的总叫牌应该是9", 5, team1Bid); } // ======================================== @@ -161,19 +180,19 @@ public void testTeamBidCalculation() { /** * 测试出牌阶段的基本动作生成 - * + *

* 在出牌阶段开始时,首家应该能够出任何牌(除了黑桃,除非只有黑桃) */ @Test public void testPlayingPhaseBasicActions() { // 完成叫牌阶段 completeBiddingPhase(); - - assertEquals("应该处于出牌阶段", SpadesGameState.Phase.PLAYING, gameState.getSpadesGamePhase()); - + + assertEquals("应该处于出牌阶段", PLAYING, gameState.getGamePhase()); + List actions = forwardModel._computeAvailableActions(gameState); - assertTrue("出牌阶段应该有可用动作", actions.size() > 0); - + assertFalse(actions.isEmpty()); + // 验证所有动作都是PlayCard动作 for (AbstractAction action : actions) { assertTrue("所有动作都应该是PlayCard动作", action instanceof PlayCard); @@ -182,85 +201,86 @@ public void testPlayingPhaseBasicActions() { /** * 测试黑桃破门规则 - * + *

* 在黑桃未破门时,不能首攻黑桃,除非手中只有黑桃 * 这是Spades游戏的核心规则之一 */ @Test public void testSpadesBreakingRule() { completeBiddingPhase(); - + // 创建一个有混合花色的手牌来测试黑桃破门规则 Deck playerHand = gameState.getPlayerHands().get(gameState.getCurrentPlayer()); playerHand.clear(); - + // 添加非黑桃牌和黑桃牌 playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts)); playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Spades)); - + List actions = forwardModel._computeAvailableActions(gameState); - + // 验证不能首攻黑桃(黑桃未破门且有其他花色) boolean canLeadSpades = actions.stream() .filter(a -> a instanceof PlayCard) .map(a -> (PlayCard) a) .anyMatch(a -> a.card.suite == FrenchCard.Suite.Spades); - + assertFalse("黑桃未破门时,有其他花色时不能首攻黑桃", canLeadSpades); } /** * 测试只有黑桃时可以首攻黑桃 - * + *

* 当玩家手中只有黑桃时,即使黑桃未破门也必须出黑桃 */ @Test public void testSpadesOnlyCanLeadSpades() { completeBiddingPhase(); - + // 创建只有黑桃的手牌 Deck playerHand = gameState.getPlayerHands().get(gameState.getCurrentPlayer()); playerHand.clear(); playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Spades)); playerHand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Spades)); - + List actions = forwardModel._computeAvailableActions(gameState); - + // 验证可以出黑桃 boolean canLeadSpades = actions.stream() .filter(a -> a instanceof PlayCard) .map(a -> (PlayCard) a) .anyMatch(a -> a.card.suite == FrenchCard.Suite.Spades); - + assertTrue("手中只有黑桃时应该可以首攻黑桃", canLeadSpades); } /** * 测试跟牌规则 - * + *

* 当别人已经出牌时,必须跟牌(如果有的话) * 这是trick-taking游戏的基本规则 */ @Test public void testFollowSuitRule() { completeBiddingPhase(); - + // 玩家1出红心A - Deck player1Hand = gameState.getPlayerHands().get(1); - player1Hand.clear(); + Deck player0Hand = gameState.getPlayerHands().get(0); FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); - player1Hand.add(heartAce); - - forwardModel.next(gameState, new PlayCard(heartAce)); - + player0Hand.add(heartAce); + + forwardModel.next(gameState, new PlayCard(heartAce)); // P0 + forwardModel.next(gameState, forwardModel.computeAvailableActions(gameState).get(0)); // P1 + // 现在轮到玩家2,设置玩家2的手牌(有红心和其他花色) Deck player2Hand = gameState.getPlayerHands().get(2); player2Hand.clear(); player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts)); player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Clubs)); - + List actions = forwardModel._computeAvailableActions(gameState); - + assertEquals(PLAYING, gameState.getGamePhase()); + // 验证只能出红心 for (AbstractAction action : actions) { PlayCard playAction = (PlayCard) action; @@ -270,34 +290,34 @@ public void testFollowSuitRule() { /** * 测试没有跟牌花色时可以出任意牌 - * + *

* 当玩家没有首攻花色时,可以出任意牌(包括将牌黑桃) */ @Test public void testCannotFollowSuitRule() { completeBiddingPhase(); - + // 玩家1出红心A - Deck player1Hand = gameState.getPlayerHands().get(1); - player1Hand.clear(); + Deck player0Hand = gameState.getPlayerHands().get(0); FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); - player1Hand.add(heartAce); - - forwardModel.next(gameState, new PlayCard( heartAce)); - + player0Hand.add(heartAce); + + forwardModel.next(gameState, new PlayCard(heartAce)); + forwardModel.next(gameState, forwardModel.computeAvailableActions(gameState).get(0)); + // 设置玩家2没有红心 Deck player2Hand = gameState.getPlayerHands().get(2); player2Hand.clear(); player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Clubs)); player2Hand.add(new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Spades)); - + List actions = forwardModel._computeAvailableActions(gameState); assertEquals("没有跟牌花色时应该可以出所有手牌", 2, actions.size()); - + // 验证可以出任意花色 boolean hasClubs = actions.stream().anyMatch(a -> ((PlayCard) a).card.suite == FrenchCard.Suite.Clubs); boolean hasSpades = actions.stream().anyMatch(a -> ((PlayCard) a).card.suite == FrenchCard.Suite.Spades); - + assertTrue("应该可以出梅花", hasClubs); assertTrue("应该可以出黑桃", hasSpades); } @@ -308,47 +328,44 @@ public void testCannotFollowSuitRule() { /** * 测试黑桃将牌的优先级 - * + *

* 黑桃是将牌,总是比其他花色大 * 这是Spades游戏的核心机制 */ @Test public void testSpadesAreTrump() { completeBiddingPhase(); - - // 创建一个完整的trick - setupCompleteTrick(); - + // 模拟出牌:红心A, 黑桃2, 红心K, 红心Q FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); FrenchCard spade2 = new FrenchCard(FrenchCard.FrenchCardType.Number, FrenchCard.Suite.Spades, 2); FrenchCard heartKing = new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts); FrenchCard heartQueen = new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Hearts); - + playCompleteRound(heartAce, spade2, heartKing, heartQueen); - + // 验证黑桃2赢了这轮(尽管红心A更大) assertEquals("出黑桃2的玩家应该赢得这轮", 1, gameState.getCurrentPlayer()); + assertEquals(1, gameState.getTricksTaken(1)); } /** * 测试同花色牌的大小比较 - * + *

* 在没有将牌的情况下,同花色中点数大的牌获胜 */ @Test public void testSameSuitComparison() { completeBiddingPhase(); - setupCompleteTrick(); - + // 模拟出牌:红心2, 红心A, 红心K, 红心Q (都是红心,A最大) FrenchCard heart2 = new FrenchCard(FrenchCard.FrenchCardType.Number, FrenchCard.Suite.Hearts, 2); FrenchCard heartAce = new FrenchCard(FrenchCard.FrenchCardType.Ace, FrenchCard.Suite.Hearts); FrenchCard heartKing = new FrenchCard(FrenchCard.FrenchCardType.King, FrenchCard.Suite.Hearts); FrenchCard heartQueen = new FrenchCard(FrenchCard.FrenchCardType.Queen, FrenchCard.Suite.Hearts); - + playCompleteRound(heart2, heartAce, heartKing, heartQueen); - + // 验证红心A赢了这轮 assertEquals("出红心A的玩家应该赢得这轮", 1, gameState.getCurrentPlayer()); } @@ -359,7 +376,7 @@ public void testSameSuitComparison() { /** * 测试成功完成合约的得分 - * + *

* 当团队赢得的墩数 >= 叫牌数时: * - 基础分 = 叫牌数 * 10 * - 沙袋分 = (实际墩数 - 叫牌数) * 1 @@ -369,18 +386,18 @@ public void testSuccessfulContractScoring() { // 设置一个简单的得分场景 gameState.setPlayerBid(0, 3); // 团队0叫3 gameState.setPlayerBid(2, 2); // 团队0叫2,总共5 - + // 模拟团队0赢得6墩 for (int i = 0; i < 6; i++) { gameState.incrementTricksTaken(0); // 玩家0赢得6墩 } - + // 手动调用得分计算逻辑 int teamBid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 5 int teamTricks = gameState.getTricksTaken(0) + gameState.getTricksTaken(2); // 6 - + int expectedScore = teamBid * 10 + (teamTricks - teamBid); // 5*10 + 1 = 51 - + assertEquals("团队叫牌应该是5", 5, teamBid); assertEquals("团队实际墩数应该是6", 6, teamTricks); // 注意:这里我们测试的是计算逻辑,实际的endRound方法会自动计算 @@ -388,7 +405,7 @@ public void testSuccessfulContractScoring() { /** * 测试未完成合约的惩罚 - * + *

* 当团队赢得的墩数 < 叫牌数时: * - 扣分 = 叫牌数 * 10 */ @@ -396,15 +413,15 @@ public void testSuccessfulContractScoring() { public void testFailedContractPenalty() { gameState.setPlayerBid(0, 5); // 团队0叫5 gameState.setPlayerBid(2, 2); // 团队0叫2,总共7 - + // 模拟团队0只赢得5墩(少于叫牌7) for (int i = 0; i < 5; i++) { gameState.incrementTricksTaken(0); } - + int teamBid = gameState.getPlayerBid(0) + gameState.getPlayerBid(2); // 7 int teamTricks = gameState.getTricksTaken(0) + gameState.getTricksTaken(2); // 5 - + assertTrue("团队实际墩数应该少于叫牌", teamTricks < teamBid); int expectedPenalty = teamBid * 10; // 70分惩罚 assertEquals("惩罚应该是70分", 70, expectedPenalty); @@ -412,24 +429,24 @@ public void testFailedContractPenalty() { /** * 测试沙袋累积和惩罚 - * + *

* 每10个沙袋扣100分,这防止玩家故意低叫 */ @Test public void testSandbagPenalty() { SpadesParameters params = (SpadesParameters) gameState.getGameParameters(); - + // 设置团队已有9个沙袋 gameState.addTeamSandbags(0, 9); - + // 再添加2个沙袋(总共11个) gameState.addTeamSandbags(0, 2); - + // 检查是否触发沙袋惩罚 if (gameState.getTeamSandbags(0) >= params.sandbagsPerPenalty) { int penalty = params.sandbagsRandPenalty; // 100分 assertEquals("沙袋惩罚应该是100分", 100, penalty); - + // 验证沙袋数重置 int remainingSandbags = gameState.getTeamSandbags(0) - params.sandbagsPerPenalty; assertEquals("剩余沙袋应该是1个", 1, remainingSandbags); @@ -442,7 +459,7 @@ public void testSandbagPenalty() { /** * 测试达到获胜分数的游戏结束 - * + *

* 当任意团队达到500分时游戏结束,得分高的团队获胜 */ @Test @@ -450,91 +467,17 @@ public void testWinningScoreGameEnd() { // 设置团队0得分为520分 gameState.setTeamScore(0, 520); gameState.setTeamScore(1, 300); - + SpadesParameters params = (SpadesParameters) gameState.getGameParameters(); - boolean gameEnded = gameState.getTeamScore(0) >= params.winningScore || - gameState.getTeamScore(1) >= params.winningScore; - + boolean gameEnded = gameState.getTeamScore(0) >= params.winningScore || + gameState.getTeamScore(1) >= params.winningScore; + assertTrue("游戏应该结束(有团队达到获胜分数)", gameEnded); - + int winningTeam = gameState.getTeamScore(0) > gameState.getTeamScore(1) ? 0 : 1; assertEquals("团队0应该获胜", 0, winningTeam); } - // ======================================== - // 状态管理和信息隐藏测试 - // ======================================== - - /** - * 测试游戏状态复制的正确性 - * - * 状态复制对MCTS算法至关重要,必须确保: - * - 复制的状态独立于原状态 - * - 信息隐藏正确实现 - * - hashCode一致性 - */ - @Test - public void testGameStateCopy() { - // 复制状态 - SpadesGameState copy = (SpadesGameState) gameState.copy(); - - // 验证复制的状态是独立的 - assertNotSame("复制的状态应该是不同的对象", gameState, copy); - assertEquals("复制的状态应该有相同的玩家数", gameState.getNPlayers(), copy.getNPlayers()); - assertEquals("复制的状态应该有相同的游戏阶段", gameState.getSpadesGamePhase(), copy.getSpadesGamePhase()); - - // 验证手牌复制 - for (int i = 0; i < 4; i++) { - assertNotSame("玩家手牌应该是独立复制的", - gameState.getPlayerHands().get(i), - copy.getPlayerHands().get(i)); - assertEquals("玩家手牌大小应该相同", - gameState.getPlayerHands().get(i).getSize(), - copy.getPlayerHands().get(i).getSize()); - } - } - - /** - * 测试特定玩家视角的状态复制(信息隐藏) - * - * 当为特定玩家复制状态时,其他玩家的手牌应该被随机化 - * 这对Information Set MCTS很重要 - */ - @Test - public void testPlayerSpecificStateCopy() { - // 为玩家0复制状态 - SpadesGameState copyForPlayer0 = (SpadesGameState) gameState.copy(0); - - // 玩家0自己的手牌应该保持不变 - assertEquals("玩家0的手牌应该保持原样", - gameState.getPlayerHands().get(0).getSize(), - copyForPlayer0.getPlayerHands().get(0).getSize()); - - // 其他玩家的手牌应该被随机化(但大小相同) - for (int i = 1; i < 4; i++) { - assertEquals("其他玩家的手牌大小应该相同", - gameState.getPlayerHands().get(i).getSize(), - copyForPlayer0.getPlayerHands().get(i).getSize()); - } - } - - /** - * 测试状态hashCode的一致性 - * - * 相同的状态应该有相同的hashCode,这对状态比较很重要 - */ - @Test - public void testStateHashCodeConsistency() { - SpadesGameState copy = (SpadesGameState) gameState.copy(); - - assertEquals("相同状态的hashCode应该相等", gameState.hashCode(), copy.hashCode()); - - // 修改复制的状态 - copy.setPlayerBid(0, 5); - - assertNotEquals("不同状态的hashCode应该不同", gameState.hashCode(), copy.hashCode()); - } - // ======================================== // 辅助方法 // ======================================== @@ -544,43 +487,33 @@ public void testStateHashCodeConsistency() { * 让所有玩家完成叫牌,进入出牌阶段 */ private void completeBiddingPhase() { - forwardModel.next(gameState, new Bid( 3)); + forwardModel.next(gameState, new Bid(3)); forwardModel.next(gameState, new Bid(4)); forwardModel.next(gameState, new Bid(2)); forwardModel.next(gameState, new Bid(4)); } - /** - * 设置完整trick的辅助方法 - * 为测试赢牌判断准备游戏状态 - */ - private void setupCompleteTrick() { - // 清空所有玩家手牌,方便控制出牌 - for (int i = 0; i < 4; i++) { - gameState.getPlayerHands().get(i).clear(); - } - } - /** * 执行完整一轮出牌的辅助方法 - * + * * @param card1 玩家1出的牌 - * @param card2 玩家2出的牌 + * @param card2 玩家2出的牌 * @param card3 玩家3出的牌 * @param card4 玩家0出的牌 */ private void playCompleteRound(FrenchCard card1, FrenchCard card2, FrenchCard card3, FrenchCard card4) { // 添加牌到对应玩家手中并出牌 - gameState.getPlayerHands().get(1).add(card1); + int player = gameState.getCurrentPlayer(); + gameState.getPlayerHands().get(player).add(card1); forwardModel.next(gameState, new PlayCard(card1)); - - gameState.getPlayerHands().get(2).add(card2); + + gameState.getPlayerHands().get(player + 1 % 4).add(card2); forwardModel.next(gameState, new PlayCard(card2)); - - gameState.getPlayerHands().get(3).add(card3); - forwardModel.next(gameState, new PlayCard( card3)); - - gameState.getPlayerHands().get(0).add(card4); - forwardModel.next(gameState, new PlayCard( card4)); + + gameState.getPlayerHands().get(player + 2 % 4).add(card3); + forwardModel.next(gameState, new PlayCard(card3)); + + gameState.getPlayerHands().get(player + 3 % 4).add(card4); + forwardModel.next(gameState, new PlayCard(card4)); } } \ No newline at end of file