diff --git a/domains/games/libs/cards/README.md b/domains/games/libs/cards/README.md index 1fb62bad..6ee864a2 100644 --- a/domains/games/libs/cards/README.md +++ b/domains/games/libs/cards/README.md @@ -9,6 +9,7 @@ This module provides a set of classes and utilities for building card games. - Top level module contains core components for building card games. - [golf](./golf) is a mostly complete implementation of the game Golf. +- [castle](./castle) is the rules engine for Castle (Palace). ## Building diff --git a/domains/games/libs/cards/castle/BUILD.bazel b/domains/games/libs/cards/castle/BUILD.bazel new file mode 100644 index 00000000..88ddeed3 --- /dev/null +++ b/domains/games/libs/cards/castle/BUILD.bazel @@ -0,0 +1,53 @@ +load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + +# The castle rules engine: immutable GameState/Player value types for +# the games hub. + +cc_library( + name = "player", + srcs = ["player.cc"], + hdrs = ["player.h"], + visibility = ["//visibility:public"], + deps = [ + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_library( + name = "game_state", + srcs = ["game_state.cc"], + hdrs = ["game_state.h"], + visibility = ["//visibility:public"], + deps = [ + ":player", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_test( + name = "player_test", + size = "small", + srcs = ["player_test.cc"], + deps = [ + ":player", + "//domains/games/libs/cards", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "game_state_test", + size = "small", + srcs = ["game_state_test.cc"], + deps = [ + ":game_state", + ":player", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "@googletest//:gtest_main", + ], +) diff --git a/domains/games/libs/cards/castle/README.md b/domains/games/libs/cards/castle/README.md new file mode 100644 index 00000000..da7d8234 --- /dev/null +++ b/domains/games/libs/cards/castle/README.md @@ -0,0 +1,17 @@ +# castle + +The rules engine for Castle (the shedding game also played as Palace): +immutable `Player` and `GameState` value types in the style of +[`../golf`](../golf), for the games hub. + +The rules the engine plays are the contract, stated on `GameState` in +`game_state.h` and pinned one by one in `game_state_test.cc`: three +face-down, three face-up, three in hand; a setup phase of hand/face-up +swaps; lowest ordinary card opens; equal-or-higher plays, twos reset, +tens and four of a kind burn; must play if able, otherwise pick up the +pile; face-up then face-down rows once the hand is gone; first out wins, +last holder loses. + +```bash +bazel test //domains/games/libs/cards/castle/... +``` diff --git a/domains/games/libs/cards/castle/game_state.cc b/domains/games/libs/cards/castle/game_state.cc new file mode 100644 index 00000000..c5a45296 --- /dev/null +++ b/domains/games/libs/cards/castle/game_state.cc @@ -0,0 +1,407 @@ +#include "domains/games/libs/cards/castle/game_state.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/castle/player.h" + +namespace castle { +using namespace cards; +using absl::FailedPreconditionError; +using absl::InvalidArgumentError; +using absl::StatusOr; +using std::deque; +using std::string; +using std::vector; + +namespace { + +bool isSpecial(Rank rank) { return rank == Rank::Two || rank == Rank::Ten; } + +int rankValue(Rank rank) { return static_cast(rank); } + +vector take(const vector& row, const vector& indexes) { + vector taken; + taken.reserve(indexes.size()); + for (int index : indexes) { + taken.push_back(row.at(index)); + } + return taken; +} + +// The roster with one seat replaced; Player is not assignable. +vector replaceSeat(const vector& roster, int seat, const Player& replacement) { + vector out; + out.reserve(roster.size()); + for (size_t i = 0; i < roster.size(); i++) { + out.push_back(static_cast(i) == seat ? replacement : roster.at(i)); + } + return out; +} + +vector withoutSeat(const vector& roster, int seat) { + vector out; + out.reserve(roster.size()); + for (size_t i = 0; i < roster.size(); i++) { + if (static_cast(i) != seat) { + out.push_back(roster.at(i)); + } + } + return out; +} + +// The seat holding the lowest ordinary card in hand; specials do not +// count, and a table with none goes to seat 0. +int openingSeat(const vector& roster) { + int seat = 0; + int lowest = rankValue(Rank::Ace) + 1; + for (size_t i = 0; i < roster.size(); i++) { + for (const Card& card : roster.at(i).getHand()) { + if (!isSpecial(card.getRank()) && rankValue(card.getRank()) < lowest) { + lowest = rankValue(card.getRank()); + seat = static_cast(i); + } + } + } + return seat; +} + +int seatsHoldingCards(const vector& roster) { + return static_cast( + std::count_if(roster.begin(), roster.end(), [](const Player& p) { return !p.isOut(); })); +} + +} // namespace + +StatusOr dealCastleGame(const string& game_id, const vector& player_ids, + deque shuffled_deck) { + const int seats = static_cast(player_ids.size()); + if (seats < GameState::kMinPlayers || seats > GameState::kMaxPlayers) { + return InvalidArgumentError("2 to 4 players"); + } + if (shuffled_deck.size() < player_ids.size() * 3 * GameState::kHandSize) { + return InvalidArgumentError("deck too small"); + } + vector players; + players.reserve(player_ids.size()); + for (const string& player_id : player_ids) { + vector rows[3]; + for (auto& row : rows) { + for (int i = 0; i < GameState::kHandSize; i++) { + row.push_back(shuffled_deck.back()); + shuffled_deck.pop_back(); + } + } + players.emplace_back(player_id, std::move(rows[2]), std::move(rows[1]), std::move(rows[0])); + } + return GameState{std::move(shuffled_deck), + {}, + std::move(players), + GameState::kNoTurn, + Phase::Setup, + {}, + game_id, + ""}; +} + +absl::Status GameState::ensureSeat(int player) const { + if (player < 0 || player >= static_cast(players.size())) { + return InvalidArgumentError("no such player"); + } + return absl::OkStatus(); +} + +absl::Status GameState::ensurePlayableTurn(int player) const { + if (auto seat = ensureSeat(player); !seat.ok()) { + return seat; + } + if (isOver()) { + return FailedPreconditionError("game is over"); + } + if (phase == Phase::Setup) { + return FailedPreconditionError("still setting up"); + } + if (whoseTurn != player) { + return FailedPreconditionError("not your turn"); + } + return absl::OkStatus(); +} + +StatusOr GameState::swapForSetup(int player, int handIndex, int faceUpIndex) const { + if (auto seat = ensureSeat(player); !seat.ok()) { + return seat; + } + if (phase != Phase::Setup) { + return FailedPreconditionError("setup is over"); + } + if (players.at(player).isReady()) { + return FailedPreconditionError("already ready"); + } + auto swapped = players.at(player).swapForSetup(handIndex, faceUpIndex); + if (!swapped.ok()) { + return swapped.status(); + } + return GameState{drawPile, pile, replaceSeat(players, player, *swapped), + whoseTurn, phase, finished, + gameId, versionId}; +} + +StatusOr GameState::ready(int player) const { + if (auto seat = ensureSeat(player); !seat.ok()) { + return seat; + } + if (phase != Phase::Setup) { + return FailedPreconditionError("setup is over"); + } + if (players.at(player).isReady()) { + return FailedPreconditionError("already ready"); + } + vector newPlayers = replaceSeat(players, player, players.at(player).withReady()); + const bool everyoneReady = std::all_of(newPlayers.begin(), newPlayers.end(), + [](const Player& p) { return p.isReady(); }); + const int turn = everyoneReady ? openingSeat(newPlayers) : kNoTurn; + const Phase newPhase = everyoneReady ? Phase::Playing : Phase::Setup; + return GameState{drawPile, pile, std::move(newPlayers), turn, newPhase, finished, + gameId, versionId}; +} + +std::optional GameState::pileTop() const { + if (pile.empty()) { + return std::nullopt; + } + return pile.back(); +} + +bool GameState::isPlayable(Rank rank) const { + if (isSpecial(rank) || pile.empty()) { + return true; + } + // A two on top takes anything, which the rank order already says: two + // is the lowest rank. + return rankValue(rank) >= rankValue(pile.back().getRank()); +} + +bool GameState::hasLegalPlay(int player) const { + if (!ensureSeat(player).ok()) { + return false; + } + const Player& seat = players.at(player); + if (seat.source() == Source::FaceDown) { + return false; + } + const vector& row = seat.row(seat.source()); + return std::any_of(row.begin(), row.end(), + [this](const Card& card) { return isPlayable(card.getRank()); }); +} + +StatusOr GameState::playFromHand(int player, const vector& indexes) const { + return play(player, Source::Hand, indexes); +} + +StatusOr GameState::playFaceUp(int player, const vector& indexes) const { + return play(player, Source::FaceUp, indexes); +} + +StatusOr GameState::play(int player, Source source, const vector& indexes) const { + if (auto turn = ensurePlayableTurn(player); !turn.ok()) { + return turn; + } + const Player& seat = players.at(player); + if (seat.source() != source) { + return FailedPreconditionError("not the row in play"); + } + if (indexes.empty()) { + return InvalidArgumentError("no cards"); + } + auto remaining = seat.without(source, indexes); + if (!remaining.ok()) { + return remaining.status(); + } + const vector played = take(seat.row(source), indexes); + const Rank rank = played.front().getRank(); + if (std::any_of(played.begin(), played.end(), + [rank](const Card& card) { return card.getRank() != rank; })) { + return InvalidArgumentError("one rank per play"); + } + if (!isPlayable(rank)) { + return FailedPreconditionError("that rank cannot go on the pile"); + } + + deque newDrawPile = drawPile; + vector drawn; + if (source == Source::Hand) { + while (static_cast(remaining->getHand().size() + drawn.size()) < kHandSize && + !newDrawPile.empty()) { + drawn.push_back(newDrawPile.back()); + newDrawPile.pop_back(); + } + } + vector newPlayers = replaceSeat(players, player, remaining->withHandAdded(drawn)); + + vector newPile = pile; + for (const Card& card : played) { + newPile.push_back(card); + } + int run = 0; + for (auto it = newPile.rbegin(); it != newPile.rend() && it->getRank() == rank; ++it) { + run++; + } + const bool burned = rank == Rank::Ten || run >= 4; + return settle(player, std::move(newDrawPile), std::move(newPile), std::move(newPlayers), burned); +} + +StatusOr GameState::playFaceDown(int player, int index) const { + if (auto turn = ensurePlayableTurn(player); !turn.ok()) { + return turn; + } + const Player& seat = players.at(player); + if (seat.source() != Source::FaceDown) { + return FailedPreconditionError("not the row in play"); + } + auto remaining = seat.without(Source::FaceDown, {index}); + if (!remaining.ok()) { + return remaining.status(); + } + const Card card = seat.getFaceDown().at(index); + if (isPlayable(card.getRank())) { + return play(player, Source::FaceDown, {index}); + } + // Unplayable: the seat takes the pile and the card it turned over. + vector taken = pile; + taken.push_back(card); + vector newPlayers = replaceSeat(players, player, remaining->withHandAdded(taken)); + const int next = nextSeat(player, newPlayers); + return GameState{drawPile, {}, std::move(newPlayers), next, phase, finished, gameId, versionId}; +} + +StatusOr GameState::pickUp(int player) const { + if (auto turn = ensurePlayableTurn(player); !turn.ok()) { + return turn; + } + if (pile.empty()) { + return FailedPreconditionError("nothing to pick up"); + } + if (players.at(player).source() == Source::FaceDown) { + return FailedPreconditionError("face-down cards are played blind"); + } + if (hasLegalPlay(player)) { + return FailedPreconditionError("a playable card must be played"); + } + vector newPlayers = replaceSeat(players, player, players.at(player).withHandAdded(pile)); + const int next = nextSeat(player, newPlayers); + return GameState{drawPile, {}, std::move(newPlayers), next, phase, finished, gameId, versionId}; +} + +GameState GameState::settle(int player, deque newDrawPile, vector newPile, + vector newPlayers, bool burned) const { + if (burned) { + newPile.clear(); + } + vector newFinished = finished; + const bool wentOut = newPlayers.at(player).isOut(); + if (wentOut) { + newFinished.push_back(newPlayers.at(player).getId()); + } + if (seatsHoldingCards(newPlayers) <= 1) { + return GameState{std::move(newDrawPile), + std::move(newPile), + std::move(newPlayers), + kNoTurn, + Phase::Over, + std::move(newFinished), + gameId, + versionId}; + } + const int next = burned && !wentOut ? player : nextSeat(player, newPlayers); + return GameState{std::move(newDrawPile), + std::move(newPile), + std::move(newPlayers), + next, + phase, + std::move(newFinished), + gameId, + versionId}; +} + +// The next seat after `from` that still holds cards. +int GameState::nextSeat(int from, const vector& roster) const { + const int seats = static_cast(roster.size()); + for (int step = 1; step <= seats; step++) { + const int candidate = (from + step) % seats; + if (!roster.at(candidate).isOut()) { + return candidate; + } + } + return kNoTurn; +} + +StatusOr GameState::removePlayer(int player) const { + if (auto seat = ensureSeat(player); !seat.ok()) { + return seat; + } + if (isOver()) { + return FailedPreconditionError("game is over"); + } + vector newPlayers = withoutSeat(players, player); + if (newPlayers.size() < static_cast(kMinPlayers)) { + return GameState{drawPile, pile, std::move(newPlayers), kNoTurn, Phase::Abandoned, finished, + gameId, versionId}; + } + int newTurn = whoseTurn; + if (phase == Phase::Playing) { + if (whoseTurn == player) { + // The turn passes as if the leaver had just played, on the roster + // that still has them so the search starts from their seat. + newTurn = nextSeat(player, players); + } + if (newTurn > player) { + newTurn--; + } + } + Phase newPhase = phase; + if (phase == Phase::Setup && std::all_of(newPlayers.begin(), newPlayers.end(), + [](const Player& p) { return p.isReady(); })) { + newTurn = openingSeat(newPlayers); + newPhase = Phase::Playing; + } + if (phase == Phase::Playing && seatsHoldingCards(newPlayers) <= 1) { + return GameState{drawPile, pile, std::move(newPlayers), kNoTurn, Phase::Abandoned, finished, + gameId, versionId}; + } + return GameState{drawPile, pile, std::move(newPlayers), newTurn, newPhase, finished, + gameId, versionId}; +} + +std::optional GameState::loser() const { + if (phase != Phase::Over) { + return std::nullopt; + } + for (const Player& p : players) { + if (!p.isOut()) { + return p.getId(); + } + } + return std::nullopt; +} + +GameState GameState::withIdAndVersion(const string& game_id, const string& version_id) const { + return GameState{drawPile, pile, players, whoseTurn, phase, finished, game_id, version_id}; +} + +int GameState::playerIndex(const string& id) const { + for (size_t i = 0; i < players.size(); i++) { + if (players.at(i).getId() == id) { + return static_cast(i); + } + } + return -1; +} + +} // namespace castle diff --git a/domains/games/libs/cards/castle/game_state.h b/domains/games/libs/cards/castle/game_state.h new file mode 100644 index 00000000..62c01124 --- /dev/null +++ b/domains/games/libs/cards/castle/game_state.h @@ -0,0 +1,145 @@ +#ifndef CPP_CARDS_CASTLE_GAME_STATE_H +#define CPP_CARDS_CASTLE_GAME_STATE_H + +#include +#include +#include +#include +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/castle/player.h" + +namespace castle { +using namespace cards; +using std::string; + +/// Castle, the shedding game (also played as Palace): be the first to +/// get rid of every card; whoever is left holding cards loses. +/// +/// The rules this engine plays: +/// - 2-4 players, one deck. Each seat is dealt three face-down cards, +/// three face-up cards on top of them, and three in hand. +/// - Setup: each player may swap hand cards with their own face-up +/// cards, then declares ready. Play opens when every seat is ready; +/// the seat holding the lowest ordinary card in hand (three low, ace +/// high; twos and tens are specials and do not count; face-up rows +/// do not count) goes first, the earliest seat on a tie, seat 0 when +/// no hand holds an ordinary card. +/// - A turn plays one or more cards of a single rank from the seat's +/// active row (hand while it has cards, then the face-up row, then +/// the face-down row one card at a time, blind) onto the pile. A card +/// is playable when the pile is empty, when it is a two or a ten, or +/// when its rank is at least the top's (a two on top takes anything). +/// A hand play draws back up to three while the draw pile lasts. +/// - A ten, or four of a kind on top of the pile, burns the pile: it +/// leaves the game and the same seat plays again — unless the burn +/// shed the seat's last card, when the turn passes. A burn is a play +/// like any other: the cards must be playable on the pile as it +/// stands, and a run of four is broken by a card of another rank. +/// - A seat with no playable card in hand or in the face-up row must +/// pick up the pile. A face-down card that turns out unplayable goes +/// into the hand with the pile. +/// - A seat that sheds its last card is out; its finish order is kept, +/// and stays kept if the seat later leaves the table. The game is +/// over when one seat still holds cards — the loser. +class GameState; + +enum class Phase { Setup, Playing, Over, Abandoned }; + +/// Deals a fresh game from an already-shuffled deck (drawn from the +/// back), in the setup phase. Nine cards a seat — face-down row, then +/// face-up row, then hand; the rest is the draw pile. +[[nodiscard]] absl::StatusOr dealCastleGame(const string& game_id, + const std::vector& player_ids, + std::deque shuffled_deck); + +class GameState { + public: + static constexpr int kHandSize = 3; + static constexpr int kMinPlayers = 2; + static constexpr int kMaxPlayers = 4; + /// whoseTurn once no seat has a turn: setup and every ending. + static constexpr int kNoTurn = -1; + + GameState(std::deque _drawPile, std::vector _pile, std::vector _players, + int _whoseTurn, Phase _phase, std::vector _finished, string _gameId, + string _versionId) + : drawPile(std::move(_drawPile)), + pile(std::move(_pile)), + players(std::move(_players)), + whoseTurn(_whoseTurn), + phase(_phase), + finished(std::move(_finished)), + gameId(std::move(_gameId)), + versionId(std::move(_versionId)) {} + + // Setup. + [[nodiscard]] absl::StatusOr swapForSetup(int player, int handIndex, + int faceUpIndex) const; + [[nodiscard]] absl::StatusOr ready(int player) const; + + // Turns. + [[nodiscard]] absl::StatusOr playFromHand(int player, + const std::vector& indexes) const; + [[nodiscard]] absl::StatusOr playFaceUp(int player, + const std::vector& indexes) const; + [[nodiscard]] absl::StatusOr playFaceDown(int player, int index) const; + [[nodiscard]] absl::StatusOr pickUp(int player) const; + + /// A seat abandoned mid-game: it disappears with its cards, indices + /// compact, and a turn it held passes on. Below two seats, or below two + /// seats holding cards, the game is over by abandonment: the finish + /// order stands and nobody loses, since no play ended it. + [[nodiscard]] absl::StatusOr removePlayer(int player) const; + + // Queries. + [[nodiscard]] bool isOver() const { return phase == Phase::Over || phase == Phase::Abandoned; } + [[nodiscard]] Phase getPhase() const { return phase; } + [[nodiscard]] std::optional pileTop() const; + /// Whether a card of this rank may go on the pile as it stands. + [[nodiscard]] bool isPlayable(Rank rank) const; + /// Whether the seat's active row holds a playable card. Always false + /// for a face-down row: those are played blind. + [[nodiscard]] bool hasLegalPlay(int player) const; + /// Seats that went out, first out first. + [[nodiscard]] const std::vector& getFinished() const { return finished; } + /// The seat left holding cards once the game is over by play. + [[nodiscard]] std::optional loser() const; + + [[nodiscard]] GameState withIdAndVersion(const string& game_id, const string& version_id) const; + [[nodiscard]] const std::deque& getDrawPile() const { return drawPile; } + [[nodiscard]] const std::vector& getPile() const { return pile; } + [[nodiscard]] const std::vector& getPlayers() const { return players; } + [[nodiscard]] const Player& getPlayer(int index) const { return players.at(index); } + [[nodiscard]] int playerIndex(const string& id) const; + [[nodiscard]] int getWhoseTurn() const { return whoseTurn; } + [[nodiscard]] const string& getGameId() const { return gameId; } + [[nodiscard]] const string& getVersionId() const { return versionId; } + + private: + [[nodiscard]] absl::Status ensureSeat(int player) const; + [[nodiscard]] absl::Status ensurePlayableTurn(int player) const; + [[nodiscard]] absl::StatusOr play(int player, Source source, + const std::vector& indexes) const; + /// The state after a seat's cards landed on the pile: burns, finishes, + /// the next turn, and the end of the game. + [[nodiscard]] GameState settle(int player, std::deque newDrawPile, + std::vector newPile, std::vector newPlayers, + bool burned) const; + [[nodiscard]] int nextSeat(int from, const std::vector& roster) const; + + const std::deque drawPile; + const std::vector pile; // back is the top + const std::vector players; + const int whoseTurn; + const Phase phase; + const std::vector finished; + const string gameId; + const string versionId; +}; + +} // namespace castle + +#endif diff --git a/domains/games/libs/cards/castle/game_state_test.cc b/domains/games/libs/cards/castle/game_state_test.cc new file mode 100644 index 00000000..15070024 --- /dev/null +++ b/domains/games/libs/cards/castle/game_state_test.cc @@ -0,0 +1,554 @@ +#include "domains/games/libs/cards/castle/game_state.h" + +#include + +#include +#include +#include + +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/castle/player.h" +#include "domains/games/libs/cards/dealer.h" + +using namespace cards; +using namespace castle; +using std::deque; +using std::string; +using std::vector; + +namespace { + +Card c(Rank rank, Suit suit = Suit::Clubs) { return Card{suit, rank}; } + +Player seat(const string& id, vector hand, vector faceUp = {}, + vector faceDown = {}) { + return Player{id, std::move(hand), std::move(faceUp), std::move(faceDown), true}; +} + +/// A game in play: seats as given, the pile's back on top, seat `turn` to move. +GameState playing(vector players, vector pile = {}, deque draw = {}, + int turn = 0) { + return GameState{ + std::move(draw), std::move(pile), std::move(players), turn, Phase::Playing, {}, "game", "v0"}; +} + +} // namespace + +TEST(Deal, NineCardsASeatFromTheBackOfTheDeckIntoSetup) { + NoShuffleDealer dealer; + auto game = dealCastleGame("g1", {"a", "b"}, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(game.ok()); + EXPECT_EQ(game->getPhase(), Phase::Setup); + EXPECT_EQ(game->getWhoseTurn(), GameState::kNoTurn); + EXPECT_EQ(game->getGameId(), "g1"); + EXPECT_EQ(game->getDrawPile().size(), 52u - 18u); + EXPECT_TRUE(game->getPile().empty()); + ASSERT_EQ(game->getPlayers().size(), 2u); + for (const Player& p : game->getPlayers()) { + EXPECT_EQ(p.getHand().size(), 3u); + EXPECT_EQ(p.getFaceUp().size(), 3u); + EXPECT_EQ(p.getFaceDown().size(), 3u); + EXPECT_FALSE(p.isReady()); + } + // The unshuffled deck ends in the four aces, then kings: seat a's + // face-down row is dealt first, from the back, then face-up, then hand. + EXPECT_EQ(game->getPlayer(0).getFaceDown().at(0), c(Rank::Ace, Suit::Spades)); + EXPECT_EQ(game->getPlayer(0).getFaceUp().at(0), c(Rank::Ace, Suit::Clubs)); + EXPECT_EQ(game->getPlayer(0).getHand().at(0), c(Rank::King, Suit::Diamonds)); + EXPECT_EQ(game->getPlayer(1).getFaceDown().at(0), c(Rank::Queen, Suit::Hearts)); + EXPECT_EQ(game->playerIndex("b"), 1); + EXPECT_EQ(game->playerIndex("zed"), -1); +} + +TEST(Deal, RejectsTheWrongTableOrAShortDeck) { + NoShuffleDealer dealer; + EXPECT_FALSE(dealCastleGame("g", {"a"}, dealer.DealNewUnshuffledDeck()).ok()); + EXPECT_FALSE(dealCastleGame("g", {"a", "b", "c", "d", "e"}, dealer.DealNewUnshuffledDeck()).ok()); + EXPECT_TRUE(dealCastleGame("g", {"a", "b", "c", "d"}, dealer.DealNewUnshuffledDeck()).ok()); + + deque seventeen; + for (int i = 0; i < 17; i++) { + seventeen.emplace_back(i); + } + EXPECT_FALSE(dealCastleGame("g", {"a", "b"}, seventeen).ok()); + seventeen.emplace_back(17); + auto exact = dealCastleGame("g", {"a", "b"}, seventeen); + ASSERT_TRUE(exact.ok()); + EXPECT_TRUE(exact->getDrawPile().empty()); +} + +TEST(Setup, SwapsThenReadyOpensPlayWhenEveryoneIsReady) { + NoShuffleDealer dealer; + auto game = dealCastleGame("g", {"a", "b"}, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(game.ok()); + const Card handCard = game->getPlayer(0).getHand().at(0); + const Card tableCard = game->getPlayer(0).getFaceUp().at(1); + + auto swapped = game->swapForSetup(0, 0, 1); + ASSERT_TRUE(swapped.ok()); + EXPECT_EQ(swapped->getPlayer(0).getHand().at(0), tableCard); + EXPECT_EQ(swapped->getPlayer(0).getFaceUp().at(1), handCard); + EXPECT_EQ(swapped->getPlayer(1), game->getPlayer(1)); + EXPECT_FALSE(swapped->swapForSetup(0, 3, 0).ok()); + EXPECT_FALSE(swapped->swapForSetup(2, 0, 0).ok()); + + // No turn moves during setup. + EXPECT_FALSE(swapped->playFromHand(0, {0}).ok()); + EXPECT_FALSE(swapped->pickUp(0).ok()); + + auto aReady = swapped->ready(0); + ASSERT_TRUE(aReady.ok()); + EXPECT_EQ(aReady->getPhase(), Phase::Setup); + EXPECT_FALSE(aReady->swapForSetup(0, 0, 0).ok()); // locked in + EXPECT_FALSE(aReady->ready(0).ok()); + EXPECT_TRUE(aReady->swapForSetup(1, 0, 0).ok()); // b is still arranging + + auto open = aReady->ready(1); + ASSERT_TRUE(open.ok()); + EXPECT_EQ(open->getPhase(), Phase::Playing); + EXPECT_NE(open->getWhoseTurn(), GameState::kNoTurn); + EXPECT_FALSE(open->ready(0).ok()); + EXPECT_FALSE(open->swapForSetup(1, 0, 0).ok()); +} + +TEST(Setup, TheLowestOrdinaryHandCardOpensTiesToTheEarliestSeat) { + const Player a{"a", {c(Rank::Two), c(Rank::Ten), c(Rank::King)}, {}, {}, true}; + const Player b{"b", {c(Rank::Ace), c(Rank::Five), c(Rank::Ace)}, {}, {}, true}; + const Player cc{"c", {c(Rank::Five), c(Rank::Nine), c(Rank::Nine)}, {}, {}, false}; + GameState setup{{}, {}, {a, b, cc}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto open = setup.ready(2); + ASSERT_TRUE(open.ok()); + EXPECT_EQ(open->getWhoseTurn(), 1); // b's five beats a's specials; b before c +} + +TEST(Setup, ATableWithNoOrdinaryHandCardOpensAtSeatZero) { + const Player a{"a", {c(Rank::Ten), c(Rank::Ten)}, {}, {}, true}; + const Player b{"b", {c(Rank::Two), c(Rank::Ten)}, {}, {}, false}; + GameState specials{{}, {}, {a, b}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto opened = specials.ready(1); + ASSERT_TRUE(opened.ok()); + EXPECT_EQ(opened->getWhoseTurn(), 0); +} + +TEST(Setup, OnlyHandCardsDecideTheOpeningSeat) { + const Player a{"a", {c(Rank::Nine)}, {c(Rank::Three)}, {}, true}; + const Player b{"b", {c(Rank::Five)}, {c(Rank::King)}, {}, false}; + GameState setup{{}, {}, {a, b}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto opened = setup.ready(1); + ASSERT_TRUE(opened.ok()); + EXPECT_EQ(opened->getWhoseTurn(), 1); // a's three is on the table, not in hand +} + +TEST(Play, ACardGoesOnAPileTopOfItsRankOrLower) { + const GameState g = playing( + {seat("a", {c(Rank::Five), c(Rank::Seven), c(Rank::Nine)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Seven)}); + EXPECT_TRUE(g.isPlayable(Rank::Seven)); + EXPECT_TRUE(g.isPlayable(Rank::Nine)); + EXPECT_FALSE(g.isPlayable(Rank::Five)); + EXPECT_TRUE(g.hasLegalPlay(0)); + EXPECT_FALSE(g.playFromHand(0, {0}).ok()); + + auto next = g.playFromHand(0, {1}); + ASSERT_TRUE(next.ok()); + EXPECT_EQ(next->getPile(), (vector{c(Rank::Seven), c(Rank::Seven)})); + EXPECT_EQ(next->getPlayer(0).getHand(), (vector{c(Rank::Five), c(Rank::Nine)})); + EXPECT_EQ(next->getWhoseTurn(), 1); + EXPECT_EQ(next->getPhase(), Phase::Playing); +} + +TEST(Play, OnlyTheSeatWhoseTurnItIsMayMove) { + const GameState g = playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Nine)})}, {}, {}, 1); + EXPECT_FALSE(g.playFromHand(0, {0}).ok()); + EXPECT_FALSE(g.pickUp(0).ok()); + EXPECT_TRUE(g.playFromHand(1, {0}).ok()); +} + +TEST(Play, ASeatOutsideTheTableIsRejectedEverywhere) { + const GameState g = playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Nine)})}); + for (int bad : {-1, 2, 5}) { + EXPECT_FALSE(g.playFromHand(bad, {0}).ok()); + EXPECT_FALSE(g.playFaceUp(bad, {0}).ok()); + EXPECT_FALSE(g.playFaceDown(bad, 0).ok()); + EXPECT_FALSE(g.pickUp(bad).ok()); + EXPECT_FALSE(g.ready(bad).ok()); + EXPECT_FALSE(g.swapForSetup(bad, 0, 0).ok()); + EXPECT_FALSE(g.removePlayer(bad).ok()); + EXPECT_FALSE(g.hasLegalPlay(bad)); + } +} + +TEST(Play, ATwoResetsThePileAndATenBurnsIt) { + const GameState g = + playing({seat("a", {c(Rank::Two), c(Rank::Three), c(Rank::Ten)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::King)}); + EXPECT_TRUE(g.isPlayable(Rank::Two)); + EXPECT_TRUE(g.isPlayable(Rank::Ten)); + EXPECT_FALSE(g.isPlayable(Rank::Three)); + + auto reset = g.playFromHand(0, {0}); + ASSERT_TRUE(reset.ok()); + EXPECT_EQ(reset->pileTop(), c(Rank::Two)); + EXPECT_TRUE(reset->isPlayable(Rank::Three)); + EXPECT_EQ(reset->getWhoseTurn(), 1); + + auto burn = g.playFromHand(0, {2}); + ASSERT_TRUE(burn.ok()); + EXPECT_TRUE(burn->getPile().empty()); + EXPECT_EQ(burn->pileTop(), std::nullopt); + EXPECT_EQ(burn->getWhoseTurn(), 0); // the burner goes again + EXPECT_TRUE(burn->isPlayable(Rank::Three)); + EXPECT_FALSE(burn->pickUp(0).ok()); // nothing to pick up +} + +TEST(Play, FourOfAKindOnTopBurnsAcrossPlays) { + const GameState g = playing( + {seat("a", {c(Rank::Eight), c(Rank::Eight), c(Rank::Four)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Eight, Suit::Hearts), c(Rank::Eight, Suit::Spades)}); + auto burn = g.playFromHand(0, {0, 1}); + ASSERT_TRUE(burn.ok()); + EXPECT_TRUE(burn->getPile().empty()); + EXPECT_EQ(burn->getWhoseTurn(), 0); + + const GameState split = playing( + {seat("a", {c(Rank::Eight), c(Rank::Eight), c(Rank::Four)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Eight, Suit::Hearts), c(Rank::Eight, Suit::Spades), c(Rank::Eight, Suit::Diamonds), + c(Rank::Seven)}); + // Three eights buried under a seven and two more on top is not a run of four. + auto noBurn = split.playFromHand(0, {0, 1}); + ASSERT_TRUE(noBurn.ok()); + EXPECT_EQ(noBurn->getPile().size(), 6u); + EXPECT_EQ(noBurn->getWhoseTurn(), 1); +} + +TEST(Play, FourTwosBurnLikeAnyOtherFourOfAKind) { + const GameState g = + playing({seat("a", {c(Rank::Two), c(Rank::Two, Suit::Hearts), c(Rank::Four)}), + seat("b", {c(Rank::Nine)})}, + {c(Rank::King), c(Rank::Two, Suit::Spades), c(Rank::Two, Suit::Diamonds)}); + auto burn = g.playFromHand(0, {0, 1}); + ASSERT_TRUE(burn.ok()); + EXPECT_TRUE(burn->getPile().empty()); + EXPECT_EQ(burn->getWhoseTurn(), 0); +} + +TEST(Play, AFourOfAKindRunBrokenByATwoDoesNotBurn) { + const GameState g = + playing({seat("a", {c(Rank::Eight), c(Rank::Eight, Suit::Hearts), c(Rank::Four)}), + seat("b", {c(Rank::Nine)})}, + {c(Rank::Eight, Suit::Spades), c(Rank::Eight, Suit::Diamonds), c(Rank::Two)}); + auto pair = g.playFromHand(0, {0, 1}); + ASSERT_TRUE(pair.ok()); + EXPECT_EQ(pair->getPile().size(), 5u); + EXPECT_EQ(pair->getWhoseTurn(), 1); +} + +TEST(Play, ABurnMustStillBeAPlayableRank) { + const GameState g = + playing({seat("a", {c(Rank::Five), c(Rank::Five, Suit::Hearts), c(Rank::Five, Suit::Spades), + c(Rank::Five, Suit::Diamonds)}), + seat("b", {c(Rank::Nine)})}, + {c(Rank::King)}); + EXPECT_FALSE(g.playFromHand(0, {0, 1, 2, 3}).ok()); +} + +TEST(Play, AHandNeverEmptiesWhileTheDrawPileLasts) { + const GameState g = + playing({seat("a", {c(Rank::Five), c(Rank::Five, Suit::Hearts), c(Rank::Five, Suit::Spades)}, + {c(Rank::Nine)}), + seat("b", {c(Rank::Nine)})}, + {}, {c(Rank::Jack)}); + auto played = g.playFromHand(0, {0, 1, 2}); + ASSERT_TRUE(played.ok()); + EXPECT_EQ(played->getPlayer(0).getHand(), (vector{c(Rank::Jack)})); + EXPECT_EQ(played->getPlayer(0).source(), Source::Hand); + EXPECT_TRUE(played->getFinished().empty()); +} + +TEST(Play, AHandPlayDrawsBackUpToThreeWhileTheDrawPileLasts) { + const GameState g = playing( + {seat("a", {c(Rank::Five), c(Rank::Five), c(Rank::Five)}), seat("b", {c(Rank::Nine)})}, {}, + {c(Rank::Jack), c(Rank::Queen)}); + auto one = g.playFromHand(0, {1}); + ASSERT_TRUE(one.ok()); + EXPECT_EQ(one->getPlayer(0).getHand(), + (vector{c(Rank::Five), c(Rank::Five), c(Rank::Queen)})); + EXPECT_EQ(one->getDrawPile(), (deque{c(Rank::Jack)})); + + auto three = g.playFromHand(0, {0, 1, 2}); + ASSERT_TRUE(three.ok()); + EXPECT_EQ(three->getPlayer(0).getHand(), (vector{c(Rank::Queen), c(Rank::Jack)})); + EXPECT_TRUE(three->getDrawPile().empty()); + EXPECT_EQ(three->getPile().size(), 3u); +} + +TEST(Play, OneRankPerPlayFromRealCards) { + const GameState g = + playing({seat("a", {c(Rank::Five), c(Rank::Six), c(Rank::Six)}), seat("b", {c(Rank::Nine)})}); + EXPECT_FALSE(g.playFromHand(0, {0, 1}).ok()); + EXPECT_FALSE(g.playFromHand(0, {}).ok()); + EXPECT_FALSE(g.playFromHand(0, {1, 1}).ok()); + EXPECT_FALSE(g.playFromHand(0, {3}).ok()); + EXPECT_FALSE(g.playFromHand(0, {-1}).ok()); + auto pair = g.playFromHand(0, {1, 2}); + ASSERT_TRUE(pair.ok()); + EXPECT_EQ(pair->getPile(), (vector{c(Rank::Six), c(Rank::Six)})); + EXPECT_EQ(g.getPlayer(0).getHand().size(), 3u); // the source state is untouched +} + +TEST(Play, ASeatWithNoPlayablePickUpTakesThePileWithoutDrawing) { + const GameState stuck = + playing({seat("a", {c(Rank::Three), c(Rank::Four)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Five), c(Rank::Seven)}, {c(Rank::Ace)}); + EXPECT_FALSE(stuck.hasLegalPlay(0)); + auto took = stuck.pickUp(0); + ASSERT_TRUE(took.ok()); + EXPECT_EQ(took->getPlayer(0).getHand(), + (vector{c(Rank::Three), c(Rank::Four), c(Rank::Five), c(Rank::Seven)})); + EXPECT_TRUE(took->getPile().empty()); + EXPECT_EQ(took->getDrawPile(), (deque{c(Rank::Ace)})); + EXPECT_EQ(took->getWhoseTurn(), 1); + + const GameState able = playing( + {seat("a", {c(Rank::Three), c(Rank::Nine)}), seat("b", {c(Rank::Nine)})}, {c(Rank::Seven)}); + EXPECT_FALSE(able.pickUp(0).ok()); // the nine must be played + EXPECT_FALSE(able.pickUp(1).ok()); // not b's turn +} + +TEST(Play, TheFaceUpRowIsInPlayOnceTheHandIsEmpty) { + const GameState g = playing( + {seat("a", {}, {c(Rank::Nine), c(Rank::Nine), c(Rank::Two)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Seven)}); + EXPECT_TRUE(g.hasLegalPlay(0)); + EXPECT_FALSE(g.playFromHand(0, {0}).ok()); + auto pair = g.playFaceUp(0, {0, 1}); + ASSERT_TRUE(pair.ok()); + EXPECT_EQ(pair->getPlayer(0).getFaceUp(), (vector{c(Rank::Two)})); + EXPECT_EQ(pair->getPile().size(), 3u); + EXPECT_EQ(pair->getWhoseTurn(), 1); + + const GameState handFirst = + playing({seat("a", {c(Rank::Nine)}, {c(Rank::Nine)}), seat("b", {c(Rank::Nine)})}); + EXPECT_FALSE(handFirst.playFaceUp(0, {0}).ok()); + + // Picking up from the face-up row puts the pile in the hand, and the + // hand is the row in play again. + const GameState stuck = + playing({seat("a", {}, {c(Rank::Three)}), seat("b", {c(Rank::Nine)})}, {c(Rank::King)}); + auto took = stuck.pickUp(0); + ASSERT_TRUE(took.ok()); + EXPECT_EQ(took->getPlayer(0).getHand(), (vector{c(Rank::King)})); + EXPECT_EQ(took->getPlayer(0).getFaceUp(), (vector{c(Rank::Three)})); + EXPECT_EQ(took->getPlayer(0).source(), Source::Hand); +} + +TEST(Play, FaceDownCardsPlayBlindAndAnUnplayableOneIsPickedUpWithThePile) { + const GameState g = + playing({seat("a", {}, {}, {c(Rank::Three), c(Rank::King)}), seat("b", {c(Rank::Nine)})}, + {c(Rank::Seven)}); + EXPECT_FALSE(g.hasLegalPlay(0)); // blind rows never count as a legal play + EXPECT_FALSE(g.pickUp(0).ok()); + EXPECT_FALSE(g.playFaceUp(0, {0}).ok()); + EXPECT_FALSE(g.playFaceDown(0, 2).ok()); + + auto lucky = g.playFaceDown(0, 1); + ASSERT_TRUE(lucky.ok()); + EXPECT_EQ(lucky->pileTop(), c(Rank::King)); + EXPECT_EQ(lucky->getPlayer(0).getFaceDown(), (vector{c(Rank::Three)})); + EXPECT_EQ(lucky->getWhoseTurn(), 1); + + auto unlucky = g.playFaceDown(0, 0); + ASSERT_TRUE(unlucky.ok()); + EXPECT_TRUE(unlucky->getPile().empty()); + EXPECT_EQ(unlucky->getPlayer(0).getHand(), (vector{c(Rank::Seven), c(Rank::Three)})); + EXPECT_EQ(unlucky->getPlayer(0).getFaceDown(), (vector{c(Rank::King)})); + EXPECT_EQ(unlucky->getWhoseTurn(), 1); + EXPECT_EQ(unlucky->getPhase(), Phase::Playing); +} + +TEST(Play, TheFaceDownRowIsClosedWhileAnotherRowHasCards) { + const GameState hand = + playing({seat("a", {c(Rank::Nine)}, {}, {c(Rank::Ace)}), seat("b", {c(Rank::Nine)})}); + EXPECT_FALSE(hand.playFaceDown(0, 0).ok()); + const GameState table = + playing({seat("a", {}, {c(Rank::Nine)}, {c(Rank::Ace)}), seat("b", {c(Rank::Nine)})}); + EXPECT_FALSE(table.playFaceDown(0, 0).ok()); +} + +TEST(Play, AFaceDownCardOnAnEmptyPileAlwaysPlays) { + const GameState g = playing({seat("a", {}, {}, {c(Rank::Three)}), seat("b", {c(Rank::Nine)})}); + auto flipped = g.playFaceDown(0, 0); + ASSERT_TRUE(flipped.ok()); + EXPECT_EQ(flipped->pileTop(), c(Rank::Three)); + EXPECT_TRUE(flipped->getPlayer(0).getHand().empty()); +} + +TEST(Play, ATenAsTheLastFaceDownCardBurnsAndGoesOut) { + const GameState g = playing( + {seat("a", {}, {}, {c(Rank::Ten)}), seat("b", {c(Rank::Nine)}), seat("c", {c(Rank::Nine)})}, + {c(Rank::Seven)}); + auto out = g.playFaceDown(0, 0); + ASSERT_TRUE(out.ok()); + EXPECT_TRUE(out->getPile().empty()); + EXPECT_EQ(out->getFinished(), (vector{"a"})); + EXPECT_EQ(out->getWhoseTurn(), 1); // out on a burn: the turn passes + EXPECT_EQ(out->getPhase(), Phase::Playing); +} + +TEST(Ending, SeatsGoOutInOrderAndTheLastHolderLoses) { + const GameState g = playing({seat("a", {c(Rank::Ace)}), seat("b", {c(Rank::Two)}), + seat("c", {c(Rank::Four), c(Rank::Four)})}); + auto aOut = g.playFromHand(0, {0}); + ASSERT_TRUE(aOut.ok()); + EXPECT_EQ(aOut->getFinished(), (vector{"a"})); + EXPECT_EQ(aOut->getWhoseTurn(), 1); + EXPECT_FALSE(aOut->isOver()); + EXPECT_EQ(aOut->loser(), std::nullopt); + + auto bOut = aOut->playFromHand(1, {0}); + ASSERT_TRUE(bOut.ok()); + EXPECT_EQ(bOut->getFinished(), (vector{"a", "b"})); + EXPECT_EQ(bOut->getPhase(), Phase::Over); + EXPECT_TRUE(bOut->isOver()); + EXPECT_EQ(bOut->loser(), "c"); + EXPECT_EQ(bOut->getWhoseTurn(), GameState::kNoTurn); + EXPECT_FALSE(bOut->playFromHand(2, {0}).ok()); + EXPECT_FALSE(bOut->pickUp(2).ok()); + EXPECT_FALSE(bOut->removePlayer(2).ok()); +} + +TEST(Ending, TwoPlayersEndWithTheFirstOut) { + const GameState g = playing({seat("a", {c(Rank::Ace)}), seat("b", {c(Rank::Two)})}); + auto over = g.playFromHand(0, {0}); + ASSERT_TRUE(over.ok()); + EXPECT_EQ(over->getPhase(), Phase::Over); + EXPECT_EQ(over->loser(), "b"); + EXPECT_EQ(over->getFinished(), (vector{"a"})); +} + +TEST(Ending, TheTurnSkipsSeatsThatAreOut) { + const GameState g = playing( + {seat("a", {c(Rank::Five), c(Rank::Five)}), seat("b", {}), seat("c", {c(Rank::Nine)})}, {}, + {}, 0); + auto next = g.playFromHand(0, {0}); + ASSERT_TRUE(next.ok()); + EXPECT_EQ(next->getWhoseTurn(), 2); + auto around = next->playFromHand(2, {0}); + ASSERT_TRUE(around.ok()); + EXPECT_EQ(around->getPhase(), Phase::Over); // only a holds cards + EXPECT_EQ(around->loser(), "a"); +} + +TEST(Abandonment, ALeavingSeatCompactsIndicesAndPassesItsTurn) { + const GameState g = + playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Six)}), seat("c", {c(Rank::Seven)})}, + {}, {}, 1); + auto bLeft = g.removePlayer(1); + ASSERT_TRUE(bLeft.ok()); + ASSERT_EQ(bLeft->getPlayers().size(), 2u); + EXPECT_EQ(bLeft->getPlayer(1).getId(), "c"); + EXPECT_EQ(bLeft->getWhoseTurn(), 1); // c, at its new index + EXPECT_EQ(bLeft->getPhase(), Phase::Playing); + + const GameState late = + playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Six)}), seat("c", {c(Rank::Seven)})}, + {}, {}, 2); + auto aLeft = late.removePlayer(0); + ASSERT_TRUE(aLeft.ok()); + EXPECT_EQ(aLeft->getWhoseTurn(), 1); // still c + auto wrapped = late.removePlayer(2); + ASSERT_TRUE(wrapped.ok()); + EXPECT_EQ(wrapped->getWhoseTurn(), 0); // c's turn wraps to a + + EXPECT_FALSE(g.removePlayer(3).ok()); +} + +TEST(Abandonment, BelowTwoSeatsTheGameIsAbandonedWithNoLoser) { + const GameState g = playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Six)})}, {}, {}, 1); + auto gone = g.removePlayer(1); + ASSERT_TRUE(gone.ok()); + EXPECT_EQ(gone->getPhase(), Phase::Abandoned); + EXPECT_TRUE(gone->isOver()); + EXPECT_EQ(gone->loser(), std::nullopt); + EXPECT_EQ(gone->getWhoseTurn(), GameState::kNoTurn); + EXPECT_FALSE(gone->playFromHand(0, {0}).ok()); +} + +TEST(Abandonment, ALeaversTurnSkipsAnOutSeatThenCompacts) { + const GameState g = playing({seat("a", {c(Rank::Five)}), seat("b", {}), seat("c", {c(Rank::Six)}), + seat("d", {c(Rank::Seven)})}, + {}, {}, 0); + auto aLeft = g.removePlayer(0); + ASSERT_TRUE(aLeft.ok()); + EXPECT_EQ(aLeft->getPlayer(aLeft->getWhoseTurn()).getId(), "c"); + EXPECT_EQ(aLeft->getWhoseTurn(), 1); +} + +TEST(Abandonment, AFinishedSeatThatLeavesStaysInTheFinishOrder) { + const GameState g = + playing({seat("a", {}), seat("b", {c(Rank::Six)}), seat("c", {c(Rank::Seven)})}, {}, {}, 1); + const GameState withA{ + g.getDrawPile(), g.getPile(), g.getPlayers(), 1, Phase::Playing, {"a"}, "g", "v"}; + auto aLeft = withA.removePlayer(0); + ASSERT_TRUE(aLeft.ok()); + EXPECT_EQ(aLeft->getFinished(), (vector{"a"})); + EXPECT_EQ(aLeft->playerIndex("a"), -1); + EXPECT_EQ(aLeft->getPhase(), Phase::Playing); + EXPECT_EQ(aLeft->getWhoseTurn(), 0); +} + +TEST(Abandonment, ASeatLeavingATwoSeatSetupAbandonsTheGame) { + const Player a{"a", {c(Rank::Five)}, {}, {}, false}; + const Player b{"b", {c(Rank::Three)}, {}, {}, true}; + GameState setup{{}, {}, {a, b}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto gone = setup.removePlayer(0); + ASSERT_TRUE(gone.ok()); + EXPECT_EQ(gone->getPhase(), Phase::Abandoned); + EXPECT_FALSE(gone->ready(0).ok()); +} + +TEST(Abandonment, ALeaverWhoseExitLeavesOneHolderAbandonsTheGameKeepingTheFinishOrder) { + const GameState g{{}, + {}, + {seat("a", {}), seat("b", {c(Rank::Six)}), seat("c", {c(Rank::Seven)})}, + 1, + Phase::Playing, + {"a"}, + "g", + "v"}; + auto gone = g.removePlayer(2); + ASSERT_TRUE(gone.ok()); + EXPECT_EQ(gone->getPhase(), Phase::Abandoned); + EXPECT_TRUE(gone->isOver()); + EXPECT_EQ(gone->loser(), std::nullopt); // b never lost by play + EXPECT_EQ(gone->getFinished(), (vector{"a"})); + EXPECT_EQ(gone->getWhoseTurn(), GameState::kNoTurn); +} + +TEST(Abandonment, TheLastUnreadySeatLeavingOpensPlay) { + const Player a{"a", {c(Rank::Five)}, {}, {}, true}; + const Player b{"b", {c(Rank::Three)}, {}, {}, true}; + const Player slow{"c", {c(Rank::Four)}, {}, {}, false}; + GameState setup{{}, {}, {a, b, slow}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto open = setup.removePlayer(2); + ASSERT_TRUE(open.ok()); + EXPECT_EQ(open->getPhase(), Phase::Playing); + EXPECT_EQ(open->getWhoseTurn(), 1); // b's three opens + + GameState waiting{{}, {}, {a, slow, b}, GameState::kNoTurn, Phase::Setup, {}, "g", "v"}; + auto stillSetup = waiting.removePlayer(2); + ASSERT_TRUE(stillSetup.ok()); + EXPECT_EQ(stillSetup->getPhase(), Phase::Setup); + EXPECT_EQ(stillSetup->getWhoseTurn(), GameState::kNoTurn); +} + +TEST(Identity, IdAndVersionRideAlongUnchangedByMoves) { + const GameState g = playing({seat("a", {c(Rank::Five)}), seat("b", {c(Rank::Six)})}); + const GameState stamped = g.withIdAndVersion("g7", "v3"); + EXPECT_EQ(stamped.getGameId(), "g7"); + EXPECT_EQ(stamped.getVersionId(), "v3"); + auto moved = stamped.playFromHand(0, {0}); + ASSERT_TRUE(moved.ok()); + EXPECT_EQ(moved->getGameId(), "g7"); + EXPECT_EQ(moved->getVersionId(), "v3"); +} diff --git a/domains/games/libs/cards/castle/player.cc b/domains/games/libs/cards/castle/player.cc new file mode 100644 index 00000000..32c2e43a --- /dev/null +++ b/domains/games/libs/cards/castle/player.cc @@ -0,0 +1,102 @@ +#include "domains/games/libs/cards/castle/player.h" + +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" + +namespace castle { +using namespace cards; +using absl::InvalidArgumentError; +using std::vector; + +const vector& Player::row(Source source) const { + switch (source) { + case Source::Hand: + return hand; + case Source::FaceUp: + return faceUp; + case Source::FaceDown: + return faceDown; + } + return faceDown; +} + +Source Player::source() const { + if (!hand.empty()) { + return Source::Hand; + } + if (!faceUp.empty()) { + return Source::FaceUp; + } + return Source::FaceDown; +} + +bool Player::isOut() const { return cardsLeft() == 0; } + +int Player::cardsLeft() const { + return static_cast(hand.size() + faceUp.size() + faceDown.size()); +} + +absl::StatusOr Player::swapForSetup(int handIndex, int faceUpIndex) const { + if (handIndex < 0 || handIndex >= static_cast(hand.size())) { + return InvalidArgumentError("no such hand card"); + } + if (faceUpIndex < 0 || faceUpIndex >= static_cast(faceUp.size())) { + return InvalidArgumentError("no such face-up card"); + } + vector newHand; + vector newFaceUp; + for (size_t i = 0; i < hand.size(); i++) { + newHand.push_back(static_cast(i) == handIndex ? faceUp.at(faceUpIndex) : hand.at(i)); + } + for (size_t i = 0; i < faceUp.size(); i++) { + newFaceUp.push_back(static_cast(i) == faceUpIndex ? hand.at(handIndex) : faceUp.at(i)); + } + return Player{id, std::move(newHand), std::move(newFaceUp), faceDown, ready}; +} + +Player Player::withReady() const { return Player{id, hand, faceUp, faceDown, true}; } + +absl::StatusOr Player::without(Source source, const vector& indexes) const { + const vector& from = row(source); + vector taken(from.size(), false); + for (int index : indexes) { + if (index < 0 || index >= static_cast(from.size())) { + return InvalidArgumentError("no such card"); + } + if (taken.at(index)) { + return InvalidArgumentError("the same card twice"); + } + taken.at(index) = true; + } + vector kept; + for (size_t i = 0; i < from.size(); i++) { + if (!taken.at(i)) { + kept.push_back(from.at(i)); + } + } + switch (source) { + case Source::Hand: + return Player{id, std::move(kept), faceUp, faceDown, ready}; + case Source::FaceUp: + return Player{id, hand, std::move(kept), faceDown, ready}; + case Source::FaceDown: + return Player{id, hand, faceUp, std::move(kept), ready}; + } + return InvalidArgumentError("no such row"); +} + +Player Player::withHandAdded(const vector& cards) const { + vector newHand = hand; + for (const Card& card : cards) { + newHand.push_back(card); + } + return Player{id, std::move(newHand), faceUp, faceDown, ready}; +} + +} // namespace castle diff --git a/domains/games/libs/cards/castle/player.h b/domains/games/libs/cards/castle/player.h new file mode 100644 index 00000000..a348d2a5 --- /dev/null +++ b/domains/games/libs/cards/castle/player.h @@ -0,0 +1,68 @@ +#ifndef CPP_CARDS_CASTLE_PLAYER_H +#define CPP_CARDS_CASTLE_PLAYER_H + +#include +#include +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" + +namespace castle { +using namespace cards; + +/// Where a seat's next card comes from: the hand while it holds cards, +/// then the face-up row, then the face-down row played blind. +enum class Source { Hand, FaceUp, FaceDown }; + +/// One seat: a hand, a face-up row on the table, and the face-down row +/// beneath it (the castle). Immutable; every change is a new Player. +class Player { + public: + Player(std::string _id, std::vector _hand, std::vector _faceUp, + std::vector _faceDown) + : Player(std::move(_id), std::move(_hand), std::move(_faceUp), std::move(_faceDown), false) {} + Player(std::string _id, std::vector _hand, std::vector _faceUp, + std::vector _faceDown, bool _ready) + : id(std::move(_id)), + hand(std::move(_hand)), + faceUp(std::move(_faceUp)), + faceDown(std::move(_faceDown)), + ready(_ready) {} + + [[nodiscard]] const std::string& getId() const { return id; } + [[nodiscard]] const std::vector& getHand() const { return hand; } + [[nodiscard]] const std::vector& getFaceUp() const { return faceUp; } + [[nodiscard]] const std::vector& getFaceDown() const { return faceDown; } + [[nodiscard]] bool isReady() const { return ready; } + [[nodiscard]] const std::vector& row(Source source) const; + + [[nodiscard]] Source source() const; + [[nodiscard]] bool isOut() const; + [[nodiscard]] int cardsLeft() const; + + /// Setup: exchange a hand card with a face-up card. + [[nodiscard]] absl::StatusOr swapForSetup(int handIndex, int faceUpIndex) const; + [[nodiscard]] Player withReady() const; + /// The cards at these indexes leave the row. Indexes must be distinct + /// and in range; the row's remaining order is kept. + [[nodiscard]] absl::StatusOr without(Source source, + const std::vector& indexes) const; + [[nodiscard]] Player withHandAdded(const std::vector& cards) const; + + bool operator==(const Player& o) const { + return id == o.id && hand == o.hand && faceUp == o.faceUp && faceDown == o.faceDown && + ready == o.ready; + } + + private: + const std::string id; + const std::vector hand; + const std::vector faceUp; + const std::vector faceDown; + const bool ready; +}; + +} // namespace castle + +#endif diff --git a/domains/games/libs/cards/castle/player_test.cc b/domains/games/libs/cards/castle/player_test.cc new file mode 100644 index 00000000..1edc99fc --- /dev/null +++ b/domains/games/libs/cards/castle/player_test.cc @@ -0,0 +1,83 @@ +#include "domains/games/libs/cards/castle/player.h" + +#include + +#include + +#include "domains/games/libs/cards/card.h" + +using namespace cards; +using namespace castle; + +namespace { + +Card c(Rank rank, Suit suit = Suit::Clubs) { return Card{suit, rank}; } + +} // namespace + +TEST(Player, TheActiveRowIsHandThenFaceUpThenFaceDown) { + const Player full{"a", {c(Rank::Three)}, {c(Rank::Four)}, {c(Rank::Five)}}; + EXPECT_EQ(full.source(), Source::Hand); + const Player noHand{"a", {}, {c(Rank::Four)}, {c(Rank::Five)}}; + EXPECT_EQ(noHand.source(), Source::FaceUp); + const Player castleOnly{"a", {}, {}, {c(Rank::Five)}}; + EXPECT_EQ(castleOnly.source(), Source::FaceDown); +} + +TEST(Player, RowNamesEachOfTheThreeRows) { + const Player p{"a", {c(Rank::Three)}, {c(Rank::Four)}, {c(Rank::Five)}}; + EXPECT_EQ(p.row(Source::Hand), (std::vector{c(Rank::Three)})); + EXPECT_EQ(p.row(Source::FaceUp), (std::vector{c(Rank::Four)})); + EXPECT_EQ(p.row(Source::FaceDown), (std::vector{c(Rank::Five)})); +} + +TEST(Player, ASeatIsOutWhenEveryRowIsEmpty) { + const Player full{"a", {c(Rank::Three)}, {c(Rank::Four)}, {c(Rank::Five)}}; + EXPECT_EQ(full.cardsLeft(), 3); + EXPECT_FALSE(full.isOut()); + const Player out{"a", {}, {}, {}}; + EXPECT_TRUE(out.isOut()); + EXPECT_EQ(out.cardsLeft(), 0); +} + +TEST(Player, SetupSwapExchangesAHandCardWithAFaceUpCard) { + const Player p{"a", {c(Rank::Three), c(Rank::Four)}, {c(Rank::King), c(Rank::Ace)}, {}}; + auto swapped = p.swapForSetup(1, 0); + ASSERT_TRUE(swapped.ok()); + EXPECT_EQ(swapped->getHand(), (std::vector{c(Rank::Three), c(Rank::King)})); + EXPECT_EQ(swapped->getFaceUp(), (std::vector{c(Rank::Four), c(Rank::Ace)})); + EXPECT_FALSE(swapped->isReady()); + + EXPECT_FALSE(p.swapForSetup(2, 0).ok()); + EXPECT_FALSE(p.swapForSetup(-1, 0).ok()); + EXPECT_FALSE(p.swapForSetup(0, 2).ok()); + EXPECT_TRUE(p.withReady().isReady()); +} + +TEST(Player, WithoutRemovesTheNamedCardsAndKeepsTheOrder) { + const Player p{ + "a", {c(Rank::Three), c(Rank::Four), c(Rank::Five)}, {c(Rank::King)}, {c(Rank::Ace)}}; + auto fewer = p.without(Source::Hand, {2, 0}); + ASSERT_TRUE(fewer.ok()); + EXPECT_EQ(fewer->getHand(), (std::vector{c(Rank::Four)})); + EXPECT_EQ(fewer->getFaceUp(), p.getFaceUp()); + + auto noFaceUp = p.without(Source::FaceUp, {0}); + ASSERT_TRUE(noFaceUp.ok()); + EXPECT_TRUE(noFaceUp->getFaceUp().empty()); + auto noFaceDown = p.without(Source::FaceDown, {0}); + ASSERT_TRUE(noFaceDown.ok()); + EXPECT_TRUE(noFaceDown->getFaceDown().empty()); + + EXPECT_FALSE(p.without(Source::Hand, {0, 0}).ok()); + EXPECT_FALSE(p.without(Source::Hand, {3}).ok()); + EXPECT_FALSE(p.without(Source::FaceUp, {1}).ok()); + EXPECT_FALSE(p.without(Source::FaceDown, {-1}).ok()); +} + +TEST(Player, WithHandAddedAppends) { + const Player p{"a", {c(Rank::Three)}, {}, {}}; + const Player more = p.withHandAdded({c(Rank::Four), c(Rank::Five)}); + EXPECT_EQ(more.getHand(), (std::vector{c(Rank::Three), c(Rank::Four), c(Rank::Five)})); + EXPECT_EQ(p.withHandAdded({}), p); +}