From 17175f538fb30f564330d585924c74b5739784eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:57:49 +0000 Subject: [PATCH 1/7] rummy: best-meld search with lay-offs and a parity corpus --- domains/games/libs/cards/rummy/BUILD.bazel | 28 + domains/games/libs/cards/rummy/arrange.cc | 197 +++ domains/games/libs/cards/rummy/arrange.h | 37 + .../games/libs/cards/rummy/arrange_test.cc | 182 +++ .../cards/rummy/testdata/arrange_corpus.json | 1094 +++++++++++++++++ .../rummy/testdata/make_arrange_corpus.py | 88 ++ 6 files changed, 1626 insertions(+) create mode 100644 domains/games/libs/cards/rummy/arrange.cc create mode 100644 domains/games/libs/cards/rummy/arrange.h create mode 100644 domains/games/libs/cards/rummy/arrange_test.cc create mode 100644 domains/games/libs/cards/rummy/testdata/arrange_corpus.json create mode 100644 domains/games/libs/cards/rummy/testdata/make_arrange_corpus.py diff --git a/domains/games/libs/cards/rummy/BUILD.bazel b/domains/games/libs/cards/rummy/BUILD.bazel index c3b9191b..9bd98e7e 100644 --- a/domains/games/libs/cards/rummy/BUILD.bazel +++ b/domains/games/libs/cards/rummy/BUILD.bazel @@ -11,6 +11,34 @@ cc_library( deps = ["//domains/games/libs/cards"], ) +cc_library( + name = "arrange", + srcs = ["arrange.cc"], + hdrs = ["arrange.h"], + visibility = ["//visibility:public"], + deps = [ + ":meld", + "//domains/games/libs/cards", + ], +) + +# The corpus is shared with the web client's search (muchq.github.io +# src/apps/rummy), which replays a copy; testdata/make_arrange_corpus.py +# makes it from a brute force of its own. +cc_test( + name = "arrange_test", + size = "small", + srcs = ["arrange_test.cc"], + data = ["testdata/arrange_corpus.json"], + deps = [ + ":arrange", + ":meld", + "//domains/games/libs/cards", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + ], +) + cc_library( name = "game_state", srcs = ["game_state.cc"], diff --git a/domains/games/libs/cards/rummy/arrange.cc b/domains/games/libs/cards/rummy/arrange.cc new file mode 100644 index 00000000..ea367094 --- /dev/null +++ b/domains/games/libs/cards/rummy/arrange.cc @@ -0,0 +1,197 @@ +#include "domains/games/libs/cards/rummy/arrange.h" + +#include +#include +#include + +#include "domains/games/libs/cards/rummy/meld.h" + +namespace rummy { +namespace { + +using Mask = std::uint32_t; + +int rankValue(const Card& card, bool aceLow) { + const int rank = static_cast(card.getRank()); + return aceLow && card.getRank() == Rank::Ace ? -1 : rank; +} + +// Every meld the hand could make, as masks over its cards: each set and +// each three-card part of a four-card set, and every stretch of three or +// more in a suit, the ace low or high. +std::vector candidateMelds(const std::vector& hand) { + const std::size_t n = hand.size(); + std::vector melds; + for (int rank = 0; rank <= static_cast(Rank::Ace); rank++) { + std::vector same; + for (std::size_t i = 0; i < n; i++) { + if (static_cast(hand[i].getRank()) == rank) same.push_back(i); + } + if (same.size() < 3) continue; + Mask all = 0; + for (std::size_t i : same) all |= Mask{1} << i; + melds.push_back(all); + if (same.size() == 4) { + for (std::size_t skip : same) melds.push_back(all & ~(Mask{1} << skip)); + } + } + for (int suit = 0; suit <= static_cast(Suit::Spades); suit++) { + for (const bool aceLow : {true, false}) { + std::vector cards; + for (std::size_t i = 0; i < n; i++) { + if (static_cast(hand[i].getSuit()) == suit) cards.push_back(i); + } + std::sort(cards.begin(), cards.end(), [&](std::size_t a, std::size_t b) { + return rankValue(hand[a], aceLow) < rankValue(hand[b], aceLow); + }); + for (std::size_t from = 0; from < cards.size(); from++) { + Mask run = Mask{1} << cards[from]; + for (std::size_t to = from + 1; to < cards.size(); to++) { + if (rankValue(hand[cards[to]], aceLow) != rankValue(hand[cards[to - 1]], aceLow) + 1) { + break; + } + run |= Mask{1} << cards[to]; + if (to - from >= 2) melds.push_back(run); + } + } + } + } + std::sort(melds.begin(), melds.end()); + melds.erase(std::unique(melds.begin(), melds.end()), melds.end()); + return melds; +} + +struct Search { + const std::vector& hand; + // Candidates by the lowest card in them: the search decides cards in + // order, so a meld is taken at its first card or not at all. + std::vector> byFirst; + std::vector chosen; + std::vector best; + int bestPoints; + + int pointsOf(Mask mask) const { + int sum = 0; + for (std::size_t i = 0; i < hand.size(); i++) { + if (mask & (Mask{1} << i)) sum += cardPoints(hand[i]); + } + return sum; + } + + void from(std::size_t i, Mask used, int dead) { + if (dead >= bestPoints) return; + while (i < hand.size() && (used & (Mask{1} << i))) i++; + if (i == hand.size()) { + bestPoints = dead; + best = chosen; + return; + } + for (const Mask meld : byFirst[i]) { + if (meld & used) continue; + chosen.push_back(meld); + from(i + 1, used | meld, dead); + chosen.pop_back(); + } + from(i + 1, used | (Mask{1} << i), dead + cardPoints(hand[i])); + } +}; + +std::vector cardsOf(const std::vector& hand, Mask mask) { + std::vector cards; + for (std::size_t i = 0; i < hand.size(); i++) { + if (mask & (Mask{1} << i)) cards.push_back(hand[i]); + } + return cards; +} + +bool isSet(const std::vector& meld) { + return std::all_of(meld.begin(), meld.end(), + [&](const Card& card) { return card.getRank() == meld.front().getRank(); }); +} + +// Whether `cards` can all go onto `onto`, each onto one meld, leaving +// every meld a meld. A card is only ever tried where it could belong: a +// set of its rank or a run of its suit. +bool laysOff(const std::vector& cards, const std::vector>& onto) { + std::vector> fits(cards.size()); + for (std::size_t c = 0; c < cards.size(); c++) { + for (std::size_t m = 0; m < onto.size(); m++) { + const bool set = isSet(onto[m]); + if (set ? cards[c].getRank() == onto[m].front().getRank() + : cards[c].getSuit() == onto[m].front().getSuit()) { + fits[c].push_back(m); + } + } + if (fits[c].empty()) return false; + } + std::vector pick(cards.size(), 0); + for (;;) { + std::vector> grown = onto; + for (std::size_t c = 0; c < cards.size(); c++) grown[fits[c][pick[c]]].push_back(cards[c]); + if (std::all_of(grown.begin(), grown.end(), + [](const auto& meld) { return arrangedMeld(meld).has_value(); })) { + return true; + } + std::size_t c = 0; + while (c < pick.size() && ++pick[c] == fits[c].size()) pick[c++] = 0; + if (c == pick.size()) return false; + } +} + +} // namespace + +Arrangement bestArrangement(const std::vector& hand) { + Search search{hand, std::vector>(hand.size()), {}, {}, 0}; + for (const Mask meld : candidateMelds(hand)) { + std::size_t first = 0; + while (!(meld & (Mask{1} << first))) first++; + search.byFirst[first].push_back(meld); + } + search.bestPoints = search.pointsOf(hand.empty() ? 0 : (Mask{1} << hand.size()) - 1) + 1; + search.from(0, 0, 0); + + Arrangement out; + Mask melded = 0; + for (const Mask meld : search.best) { + out.melds.push_back(*arrangedMeld(cardsOf(hand, meld))); + melded |= meld; + } + for (std::size_t i = 0; i < hand.size(); i++) { + if (!(melded & (Mask{1} << i))) out.deadwood.push_back(hand[i]); + } + out.deadwoodPoints = search.bestPoints; + return out; +} + +LaidOffArrangement bestWithLayOffs(const std::vector& hand, + const std::vector>& onto) { + // Only a card of a set's rank or a run's suit could go on. + std::vector candidates; + for (std::size_t i = 0; i < hand.size(); i++) { + for (const auto& meld : onto) { + if (isSet(meld) ? hand[i].getRank() == meld.front().getRank() + : hand[i].getSuit() == meld.front().getSuit()) { + candidates.push_back(i); + break; + } + } + } + LaidOffArrangement best{bestArrangement(hand), {}}; + for (Mask pick = 1; pick < (Mask{1} << candidates.size()); pick++) { + std::vector laid; + std::vector kept; + Mask off = 0; + for (std::size_t k = 0; k < candidates.size(); k++) { + if (pick & (Mask{1} << k)) off |= Mask{1} << candidates[k]; + } + for (std::size_t i = 0; i < hand.size(); i++) { + (off & (Mask{1} << i) ? laid : kept).push_back(hand[i]); + } + if (!laysOff(laid, onto)) continue; + Arrangement own = bestArrangement(kept); + if (own.deadwoodPoints < best.own.deadwoodPoints) best = {std::move(own), std::move(laid)}; + } + return best; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/arrange.h b/domains/games/libs/cards/rummy/arrange.h new file mode 100644 index 00000000..4700f309 --- /dev/null +++ b/domains/games/libs/cards/rummy/arrange.h @@ -0,0 +1,37 @@ +#ifndef CPP_CARDS_RUMMY_ARRANGE_H +#define CPP_CARDS_RUMMY_ARRANGE_H + +#include + +#include "domains/games/libs/cards/card.h" + +namespace rummy { +using namespace cards; + +/// A hand laid out as melds and what is left: the deadwood, and what it +/// costs (cardPoints). +struct Arrangement { + std::vector> melds; + std::vector deadwood; + int deadwoodPoints = 0; +}; + +/// The arrangement of `hand` that leaves the least deadwood, each meld +/// laid as arrangedMeld lays it. A card is in one meld at most. Ties go +/// to the first found, which is the same for the same hand. +[[nodiscard]] Arrangement bestArrangement(const std::vector& hand); + +/// A defender's hand against a knock (gin, #1610): its own melds, and +/// the cards it lays off onto the knocker's `onto`, chosen together for +/// the least deadwood. A card laid off is neither meld nor deadwood. +struct LaidOffArrangement { + Arrangement own; + std::vector laidOff; +}; + +[[nodiscard]] LaidOffArrangement bestWithLayOffs(const std::vector& hand, + const std::vector>& onto); + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/arrange_test.cc b/domains/games/libs/cards/rummy/arrange_test.cc new file mode 100644 index 00000000..939d8a74 --- /dev/null +++ b/domains/games/libs/cards/rummy/arrange_test.cc @@ -0,0 +1,182 @@ +#include "domains/games/libs/cards/rummy/arrange.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/meld.h" + +using namespace cards; +using namespace rummy; +using nlohmann::json; + +namespace { + +const std::vector kRanks = {"2", "3", "4", "5", "6", "7", "8", + "9", "10", "J", "Q", "K", "A"}; +const std::vector kSuits = {"♣", "♦", "♥", "♠"}; + +// "10♥" as the wire spells it. +Card c(const std::string& face) { + for (std::size_t s = 0; s < kSuits.size(); s++) { + const std::string& suit = kSuits[s]; + if (face.size() > suit.size() && + face.compare(face.size() - suit.size(), suit.size(), suit) == 0) { + const std::string rank = face.substr(0, face.size() - suit.size()); + const auto at = std::find(kRanks.begin(), kRanks.end(), rank); + if (at == kRanks.end()) break; + return Card{static_cast(s), static_cast(at - kRanks.begin())}; + } + } + ADD_FAILURE() << "no such card: " << face; + return Card{Suit::Clubs, Rank::Two}; +} + +std::vector hand(const json& faces) { + std::vector cards; + for (const json& face : faces) cards.push_back(c(face.get())); + return cards; +} + +int points(const std::vector& cards) { + int sum = 0; + for (const Card& card : cards) sum += cardPoints(card); + return sum; +} + +// Melds valid and laid as arrangedMeld lays them; melds, deadwood and any +// lay-offs together exactly the hand; the deadwood's cost as stated. +void expectWellFormed(const std::vector& in, const Arrangement& out, + const std::vector& laidOff = {}) { + std::vector seen = out.deadwood; + seen.insert(seen.end(), laidOff.begin(), laidOff.end()); + for (const auto& meld : out.melds) { + const auto arranged = arrangedMeld(meld); + ASSERT_TRUE(arranged.has_value()); + EXPECT_EQ(*arranged, meld); + seen.insert(seen.end(), meld.begin(), meld.end()); + } + auto key = [](const Card& a, const Card& b) { + return std::pair(a.getSuit(), a.getRank()) < std::pair(b.getSuit(), b.getRank()); + }; + std::vector want = in; + std::sort(want.begin(), want.end(), key); + std::sort(seen.begin(), seen.end(), key); + EXPECT_EQ(seen, want); + EXPECT_EQ(out.deadwoodPoints, points(out.deadwood)); +} + +json corpus() { + std::ifstream file("domains/games/libs/cards/rummy/testdata/arrange_corpus.json"); + EXPECT_TRUE(file.good()); + return json::parse(file); +} + +// The oracle: every subset of the hand as melded cards, kept if it splits +// into melds. +bool partitionable(std::vector cards) { + if (cards.empty()) return true; + const Card first = cards.front(); + cards.erase(cards.begin()); + const std::size_t n = cards.size(); + for (unsigned mask = 1; mask < (1u << n); mask++) { + if (__builtin_popcount(mask) < 2) continue; + std::vector group{first}; + std::vector rest; + for (std::size_t i = 0; i < n; i++) (mask & (1u << i) ? group : rest).push_back(cards[i]); + if (arrangedMeld(group).has_value() && partitionable(rest)) return true; + } + return false; +} + +bool laysOff(const std::vector& cards, const std::vector>& onto) { + if (cards.empty()) return true; + if (onto.empty()) return false; + std::vector assign(cards.size(), 0); + for (;;) { + std::vector> grown = onto; + for (std::size_t i = 0; i < cards.size(); i++) grown[assign[i]].push_back(cards[i]); + if (std::all_of(grown.begin(), grown.end(), + [](const auto& meld) { return arrangedMeld(meld).has_value(); })) { + return true; + } + std::size_t i = 0; + while (i < assign.size() && ++assign[i] == onto.size()) assign[i++] = 0; + if (i == assign.size()) return false; + } +} + +int oracle(const std::vector& cards) { + int best = points(cards); + const std::size_t n = cards.size(); + for (unsigned mask = 0; mask < (1u << n); mask++) { + std::vector melded; + std::vector left; + for (std::size_t i = 0; i < n; i++) (mask & (1u << i) ? melded : left).push_back(cards[i]); + if (points(left) < best && partitionable(melded)) best = points(left); + } + return best; +} + +} // namespace + +TEST(Arrange, TheCorpusDeadwoodIsTheLeastThereIs) { + const json cases = corpus()["arrange"]; + ASSERT_GE(cases.size(), 30u); + for (const json& entry : cases) { + const std::vector in = hand(entry["hand"]); + const Arrangement out = bestArrangement(in); + EXPECT_EQ(out.deadwoodPoints, entry["deadwood"].get()) << entry["hand"].dump(); + expectWellFormed(in, out); + } +} + +TEST(Arrange, LayOffsInTheCorpusLeaveTheLeastDeadwood) { + const json cases = corpus()["layOff"]; + ASSERT_GE(cases.size(), 15u); + for (const json& entry : cases) { + const std::vector in = hand(entry["hand"]); + std::vector> onto; + for (const json& meld : entry["onto"]) onto.push_back(hand(meld)); + const LaidOffArrangement out = bestWithLayOffs(in, onto); + EXPECT_EQ(out.own.deadwoodPoints, entry["deadwood"].get()) << entry["hand"].dump(); + expectWellFormed(in, out.own, out.laidOff); + // The laid-off cards go onto the knocker's melds, each onto one, and + // every meld so grown is a meld. + EXPECT_TRUE(laysOff(out.laidOff, onto)) << entry["hand"].dump(); + } +} + +TEST(Arrange, AgreesWithTheOracleOnSeededHands) { + std::mt19937 rng(1610); + std::vector deck; + for (int i = 0; i < 52; i++) deck.emplace_back(i); + for (int round = 0; round < 60; round++) { + std::shuffle(deck.begin(), deck.end(), rng); + const std::vector in(deck.begin(), deck.begin() + (round % 2 == 0 ? 10 : 11)); + const Arrangement out = bestArrangement(in); + EXPECT_EQ(out.deadwoodPoints, oracle(in)) << round; + expectWellFormed(in, out); + } +} + +TEST(Arrange, AnEmptyHandIsNothing) { + const Arrangement out = bestArrangement({}); + EXPECT_TRUE(out.melds.empty()); + EXPECT_TRUE(out.deadwood.empty()); + EXPECT_EQ(out.deadwoodPoints, 0); +} + +TEST(Arrange, WithNothingToLayOffOntoTheDefenderKeepsItsOwnBest) { + const std::vector in = {c("7♥"), c("7♣"), c("7♦"), c("K♠")}; + const LaidOffArrangement out = bestWithLayOffs(in, {}); + EXPECT_TRUE(out.laidOff.empty()); + EXPECT_EQ(out.own.deadwoodPoints, 10); +} diff --git a/domains/games/libs/cards/rummy/testdata/arrange_corpus.json b/domains/games/libs/cards/rummy/testdata/arrange_corpus.json new file mode 100644 index 00000000..73911986 --- /dev/null +++ b/domains/games/libs/cards/rummy/testdata/arrange_corpus.json @@ -0,0 +1,1094 @@ +{ + "arrange": [ + { + "hand": [ + "A♥", + "K♥", + "Q♥", + "J♥", + "10♥", + "A♣", + "K♣", + "Q♣", + "J♣", + "10♣" + ], + "deadwood": 0 + }, + { + "hand": [ + "K♠", + "A♠", + "2♠", + "3♦", + "4♦", + "5♦", + "9♣", + "9♦", + "9♥", + "J♠" + ], + "deadwood": 23 + }, + { + "hand": [ + "A♠", + "2♠", + "3♠", + "4♠", + "5♠", + "7♣", + "7♦", + "7♥", + "7♠", + "K♦" + ], + "deadwood": 10 + }, + { + "hand": [ + "7♥", + "7♣", + "7♦", + "5♥", + "6♥", + "8♥", + "9♥", + "2♠", + "3♠", + "K♣" + ], + "deadwood": 29 + }, + { + "hand": [ + "4♣", + "5♣", + "6♣", + "7♣", + "4♦", + "4♥", + "6♦", + "6♥", + "Q♠", + "K♠" + ], + "deadwood": 32 + }, + { + "hand": [ + "2♣", + "2♦", + "2♥", + "2♠", + "3♣", + "3♦", + "3♥", + "4♣", + "4♦", + "4♥" + ], + "deadwood": 0 + }, + { + "hand": [ + "A♣", + "2♣", + "3♣", + "Q♦", + "K♦", + "A♦", + "5♥", + "6♥", + "8♥", + "9♥" + ], + "deadwood": 28 + }, + { + "hand": [ + "J♠", + "Q♠", + "K♠", + "10♦", + "J♦", + "Q♦", + "10♥", + "J♥", + "Q♥", + "10♣" + ], + "deadwood": 10 + }, + { + "hand": [ + "10♠", + "J♠", + "Q♠", + "K♠", + "A♠", + "9♠", + "8♠", + "7♠", + "6♠", + "5♠", + "4♠" + ], + "deadwood": 0 + }, + { + "hand": [ + "K♣", + "Q♦", + "J♥", + "10♠", + "9♣", + "8♦", + "7♥", + "6♠", + "5♣", + "4♦" + ], + "deadwood": 79 + }, + { + "hand": [ + "7♣", + "7♦", + "7♥", + "7♠", + "8♠", + "9♠", + "2♦", + "3♣", + "4♥", + "K♦" + ], + "deadwood": 19 + }, + { + "hand": [ + "6♦", + "2♠", + "K♦", + "9♣", + "9♦", + "9♥", + "3♠", + "4♣", + "4♥", + "2♣" + ], + "deadwood": 31 + }, + { + "hand": [ + "Q♣", + "8♦", + "J♥", + "J♠", + "5♦", + "5♥", + "6♦", + "7♠", + "8♠", + "2♦" + ], + "deadwood": 71 + }, + { + "hand": [ + "4♣", + "5♠", + "J♦", + "6♠", + "A♥", + "10♠", + "6♦", + "5♦", + "K♦", + "K♠" + ], + "deadwood": 67 + }, + { + "hand": [ + "Q♥", + "7♠", + "J♠", + "7♣", + "3♥", + "10♥", + "9♥", + "K♦", + "A♦", + "8♣" + ], + "deadwood": 75 + }, + { + "hand": [ + "J♣", + "6♥", + "A♥", + "Q♥", + "9♣", + "5♦", + "2♣", + "A♠", + "4♣", + "Q♣" + ], + "deadwood": 58 + }, + { + "hand": [ + "8♥", + "10♦", + "5♦", + "4♥", + "J♣", + "2♣", + "4♣", + "8♣", + "7♣", + "A♦" + ], + "deadwood": 59 + }, + { + "hand": [ + "A♠", + "Q♦", + "6♥", + "3♠", + "A♦", + "4♠", + "8♠", + "J♠", + "K♦", + "J♥" + ], + "deadwood": 42 + }, + { + "hand": [ + "3♥", + "8♥", + "2♦", + "J♠", + "3♠", + "K♦", + "4♥", + "7♣", + "10♦", + "8♣" + ], + "deadwood": 65 + }, + { + "hand": [ + "5♣", + "Q♣", + "9♥", + "K♣", + "7♥", + "9♦", + "8♥", + "2♣", + "6♥", + "2♠" + ], + "deadwood": 38 + }, + { + "hand": [ + "4♦", + "5♠", + "Q♠", + "3♣", + "4♠", + "K♣", + "10♣", + "J♥", + "2♣", + "5♥" + ], + "deadwood": 63 + }, + { + "hand": [ + "10♦", + "A♦", + "2♥", + "J♥", + "K♣", + "2♦", + "7♣", + "9♠", + "8♥", + "8♦" + ], + "deadwood": 67 + }, + { + "hand": [ + "6♣", + "Q♣", + "J♠", + "2♦", + "2♣", + "10♥", + "Q♥", + "2♠", + "Q♦", + "9♠" + ], + "deadwood": 35 + }, + { + "hand": [ + "K♥", + "3♣", + "4♠", + "J♥", + "8♥", + "3♠", + "4♦", + "9♦", + "4♥", + "Q♦" + ], + "deadwood": 53 + }, + { + "hand": [ + "K♥", + "9♥", + "Q♠", + "4♥", + "7♠", + "9♠", + "Q♣", + "3♦", + "J♥", + "10♥" + ], + "deadwood": 53 + }, + { + "hand": [ + "10♦", + "2♠", + "4♥", + "A♣", + "Q♥", + "K♠", + "K♣", + "10♥", + "10♣", + "2♦" + ], + "deadwood": 39 + }, + { + "hand": [ + "A♠", + "3♦", + "3♣", + "8♠", + "J♦", + "J♠", + "2♥", + "Q♣", + "6♠", + "7♦" + ], + "deadwood": 60 + }, + { + "hand": [ + "8♥", + "A♠", + "6♦", + "9♣", + "A♥", + "3♠", + "4♥", + "6♥", + "3♥", + "A♣" + ], + "deadwood": 39 + }, + { + "hand": [ + "3♥", + "2♦", + "6♦", + "8♣", + "K♠", + "7♦", + "K♥", + "2♣", + "10♦", + "9♦" + ], + "deadwood": 67 + }, + { + "hand": [ + "9♦", + "10♠", + "Q♣", + "3♣", + "3♠", + "K♦", + "4♥", + "10♦", + "10♣", + "K♥", + "A♥" + ], + "deadwood": 50 + }, + { + "hand": [ + "2♥", + "K♥", + "5♠", + "K♦", + "8♥", + "K♠", + "2♠", + "7♣", + "Q♥", + "7♥", + "3♥" + ], + "deadwood": 44 + }, + { + "hand": [ + "2♦", + "4♠", + "K♥", + "9♣", + "A♣", + "Q♠", + "5♠", + "J♦", + "A♠", + "9♥", + "6♦" + ], + "deadwood": 67 + }, + { + "hand": [ + "5♠", + "3♥", + "5♦", + "3♣", + "Q♠", + "6♦", + "Q♦", + "4♣", + "8♥", + "6♣", + "6♥" + ], + "deadwood": 48 + }, + { + "hand": [ + "7♥", + "5♠", + "7♠", + "7♣", + "10♠", + "2♣", + "Q♥", + "9♠", + "8♦", + "K♠", + "5♥" + ], + "deadwood": 59 + }, + { + "hand": [ + "J♣", + "2♠", + "5♣", + "7♦", + "A♥", + "9♦", + "K♥", + "J♠", + "7♣", + "8♥", + "J♦" + ], + "deadwood": 49 + }, + { + "hand": [ + "A♣", + "2♣", + "J♠", + "5♦", + "4♥", + "9♠", + "6♥", + "3♦", + "7♠", + "Q♦", + "2♠" + ], + "deadwood": 59 + }, + { + "hand": [ + "5♥", + "5♦", + "J♠", + "7♥", + "9♥", + "2♥", + "A♦", + "J♥", + "3♠", + "6♦", + "J♦" + ], + "deadwood": 38 + }, + { + "hand": [ + "K♠", + "Q♥", + "2♦", + "7♥", + "4♣", + "5♦", + "10♦", + "6♥", + "5♥", + "9♣", + "8♦" + ], + "deadwood": 58 + }, + { + "hand": [ + "K♥", + "10♦", + "2♣", + "8♠", + "3♠", + "9♦", + "J♥", + "8♦", + "9♣", + "3♣", + "6♥" + ], + "deadwood": 51 + }, + { + "hand": [ + "Q♠", + "Q♦", + "A♣", + "10♦", + "3♣", + "6♦", + "K♣", + "5♣", + "8♦", + "4♠", + "J♠" + ], + "deadwood": 77 + }, + { + "hand": [ + "2♥", + "J♥", + "8♠", + "5♦", + "7♦", + "8♦", + "Q♣", + "6♠", + "J♠", + "5♠", + "4♦" + ], + "deadwood": 75 + } + ], + "layOff": [ + { + "hand": [ + "4♥", + "3♥", + "8♥", + "K♣", + "K♦", + "2♣" + ], + "onto": [ + [ + "5♥", + "6♥", + "7♥" + ] + ], + "deadwood": 22 + }, + { + "hand": [ + "9♣", + "9♦", + "9♥", + "2♠", + "3♠", + "Q♦" + ], + "onto": [ + [ + "9♠", + "10♠", + "J♠" + ] + ], + "deadwood": 15 + }, + { + "hand": [ + "5♥", + "5♣", + "5♦", + "4♥", + "K♠", + "2♦" + ], + "onto": [ + [ + "6♥", + "7♥", + "8♥" + ] + ], + "deadwood": 16 + }, + { + "hand": [ + "A♠", + "2♠", + "K♥", + "Q♣" + ], + "onto": [ + [ + "3♠", + "4♠", + "5♠" + ] + ], + "deadwood": 20 + }, + { + "hand": [ + "J♦", + "Q♦", + "K♦", + "A♦", + "3♣" + ], + "onto": [ + [ + "8♦", + "9♦", + "10♦" + ] + ], + "deadwood": 3 + }, + { + "hand": [ + "7♣", + "3♠", + "4♠", + "Q♥" + ], + "onto": [ + [ + "7♥", + "7♠", + "7♦" + ] + ], + "deadwood": 17 + }, + { + "hand": [ + "8♦", + "10♥", + "2♣", + "3♦", + "10♦", + "6♣", + "10♠", + "9♠", + "8♥" + ], + "onto": [ + [ + "9♣", + "10♣", + "J♣" + ], + [ + "5♣", + "5♦", + "5♥" + ] + ], + "deadwood": 36 + }, + { + "hand": [ + "4♦", + "4♣", + "K♦", + "9♠", + "7♦", + "A♦", + "2♦", + "Q♠", + "7♠", + "J♦" + ], + "onto": [ + [ + "4♠", + "5♠", + "6♠" + ], + [ + "3♣", + "3♦", + "3♥" + ] + ], + "deadwood": 57 + }, + { + "hand": [ + "5♠", + "J♥", + "3♦", + "A♣", + "2♦", + "A♥", + "4♥", + "2♠", + "6♠" + ], + "onto": [ + [ + "10♦", + "J♦", + "Q♦" + ], + [ + "8♣", + "8♦", + "8♥" + ] + ], + "deadwood": 34 + }, + { + "hand": [ + "Q♣", + "10♦", + "9♥", + "9♦", + "A♥", + "J♦", + "6♠", + "K♥", + "7♠" + ], + "onto": [ + [ + "3♦", + "4♦", + "5♦" + ], + [ + "7♣", + "7♦", + "7♥" + ] + ], + "deadwood": 36 + }, + { + "hand": [ + "Q♠", + "6♦", + "10♥", + "K♣", + "3♥", + "A♦", + "A♠" + ], + "onto": [ + [ + "6♣", + "7♣", + "8♣" + ], + [ + "2♣", + "2♦", + "2♥" + ] + ], + "deadwood": 41 + }, + { + "hand": [ + "3♣", + "2♦", + "8♥", + "K♠", + "10♦", + "K♦", + "3♦", + "5♦", + "K♣" + ], + "onto": [ + [ + "3♠", + "4♠", + "5♠" + ], + [ + "7♣", + "7♦", + "7♥" + ] + ], + "deadwood": 31 + }, + { + "hand": [ + "4♥", + "5♠", + "K♣", + "9♥", + "J♦", + "8♥", + "A♥" + ], + "onto": [ + [ + "9♣", + "10♣", + "J♣" + ], + [ + "7♣", + "7♦", + "7♥" + ] + ], + "deadwood": 47 + }, + { + "hand": [ + "2♠", + "6♠", + "3♦", + "2♦", + "7♥", + "4♥", + "6♣", + "A♣", + "J♥" + ], + "onto": [ + [ + "9♣", + "10♣", + "J♣" + ], + [ + "8♣", + "8♦", + "8♥" + ] + ], + "deadwood": 41 + }, + { + "hand": [ + "6♦", + "Q♠", + "9♣", + "6♥", + "J♦", + "K♣", + "2♠", + "A♥", + "J♥" + ], + "onto": [ + [ + "8♠", + "9♠", + "10♠" + ], + [ + "5♣", + "5♦", + "5♥" + ] + ], + "deadwood": 64 + }, + { + "hand": [ + "3♠", + "Q♣", + "9♥", + "J♠", + "A♥", + "5♦", + "A♠", + "J♥", + "4♥" + ], + "onto": [ + [ + "8♠", + "9♠", + "10♠" + ], + [ + "2♣", + "2♦", + "2♥" + ] + ], + "deadwood": 43 + }, + { + "hand": [ + "J♣", + "Q♣", + "J♦", + "2♣", + "J♥", + "K♥", + "5♠" + ], + "onto": [ + [ + "9♠", + "10♠", + "J♠" + ], + [ + "3♣", + "3♦", + "3♥" + ] + ], + "deadwood": 27 + }, + { + "hand": [ + "A♠", + "2♥", + "7♥", + "Q♥", + "10♥", + "9♦", + "3♦", + "5♦", + "9♥" + ], + "onto": [ + [ + "7♣", + "8♣", + "9♣" + ], + [ + "4♣", + "4♦", + "4♥" + ] + ], + "deadwood": 56 + }, + { + "hand": [ + "A♣", + "2♦", + "3♥", + "6♦", + "5♣", + "6♥", + "K♣" + ], + "onto": [ + [ + "9♥", + "10♥", + "J♥" + ], + [ + "4♣", + "4♦", + "4♥" + ] + ], + "deadwood": 33 + }, + { + "hand": [ + "2♥", + "9♦", + "K♠", + "9♣", + "Q♥", + "6♠", + "J♠", + "6♣", + "7♥" + ], + "onto": [ + [ + "9♥", + "10♥", + "J♥" + ], + [ + "3♣", + "3♦", + "3♥" + ] + ], + "deadwood": 59 + } + ] +} diff --git a/domains/games/libs/cards/rummy/testdata/make_arrange_corpus.py b/domains/games/libs/cards/rummy/testdata/make_arrange_corpus.py new file mode 100644 index 00000000..1bde69da --- /dev/null +++ b/domains/games/libs/cards/rummy/testdata/make_arrange_corpus.py @@ -0,0 +1,88 @@ +# An independent brute force: the corpus both searches (C++ and TS) replay. +import itertools, json, random +RANKS=['A','2','3','4','5','6','7','8','9','10','J','Q','K'] +SUITS=['♣','♦','♥','♠'] +DECK=[r+s for s in SUITS for r in RANKS] +def rank(c): return c[:-1] +def suit(c): return c[-1] +def pts(c): + i=RANKS.index(rank(c)); return 10 if i>=10 else i+1 +def is_meld(cs): + if len(cs)<3 or len(set(cs))!=len(cs): return False + if all(rank(c)==rank(cs[0]) for c in cs): return len(cs)<=4 + if not all(suit(c)==suit(cs[0]) for c in cs): return False + for hi in (False,True): + v=sorted((13 if hi and rank(c)=='A' else RANKS.index(rank(c))) for c in cs) + if all(v[i]==v[i-1]+1 for i in range(1,len(v))): return True + return False +def partitionable(cs): + if not cs: return True + first,rest=cs[0],cs[1:] + for k in range(2,len(rest)+1): + for combo in itertools.combinations(rest,k): + g=[first,*combo] + if is_meld(g) and partitionable([c for c in rest if c not in combo]): return True + return False +def best(hand): + b=sum(map(pts,hand)) + for k in range(3,len(hand)+1): + for m in itertools.combinations(hand,k): + d=sum(pts(c) for c in hand if c not in m) + if d Date: Tue, 29 Sep 2026 03:07:26 +0000 Subject: [PATCH 2/7] rummy: gin engine, ten-card deals, table over deal variants --- domains/games/libs/cards/rummy/BUILD.bazel | 60 ++++ domains/games/libs/cards/rummy/game_state.cc | 7 +- domains/games/libs/cards/rummy/game_state.h | 5 +- .../games/libs/cards/rummy/game_state_test.cc | 10 + domains/games/libs/cards/rummy/gin.cc | 256 +++++++++++++++ domains/games/libs/cards/rummy/gin.h | 154 +++++++++ domains/games/libs/cards/rummy/gin_serde.cc | 294 ++++++++++++++++++ domains/games/libs/cards/rummy/gin_serde.h | 23 ++ .../games/libs/cards/rummy/gin_serde_test.cc | 147 +++++++++ domains/games/libs/cards/rummy/gin_test.cc | 276 ++++++++++++++++ domains/games/libs/cards/rummy/table.cc | 98 ++++-- domains/games/libs/cards/rummy/table.h | 58 +++- domains/games/libs/cards/rummy/table_serde.cc | 28 +- .../libs/cards/rummy/table_serde_test.cc | 34 +- domains/games/libs/cards/rummy/table_test.cc | 154 +++++++-- 15 files changed, 1524 insertions(+), 80 deletions(-) create mode 100644 domains/games/libs/cards/rummy/gin.cc create mode 100644 domains/games/libs/cards/rummy/gin.h create mode 100644 domains/games/libs/cards/rummy/gin_serde.cc create mode 100644 domains/games/libs/cards/rummy/gin_serde.h create mode 100644 domains/games/libs/cards/rummy/gin_serde_test.cc create mode 100644 domains/games/libs/cards/rummy/gin_test.cc diff --git a/domains/games/libs/cards/rummy/BUILD.bazel b/domains/games/libs/cards/rummy/BUILD.bazel index 9bd98e7e..9b8bfbf9 100644 --- a/domains/games/libs/cards/rummy/BUILD.bazel +++ b/domains/games/libs/cards/rummy/BUILD.bazel @@ -39,6 +39,61 @@ cc_test( ], ) +cc_library( + name = "gin", + srcs = ["gin.cc"], + hdrs = ["gin.h"], + visibility = ["//visibility:public"], + deps = [ + ":arrange", + ":game_state", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_test( + name = "gin_test", + size = "small", + srcs = ["gin_test.cc"], + deps = [ + ":gin", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "gin_serde", + srcs = ["gin_serde.cc"], + hdrs = ["gin_serde.h"], + visibility = ["//visibility:public"], + deps = [ + ":gin", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@nlohmann_json//:json", + ], +) + +cc_test( + name = "gin_serde_test", + size = "small", + srcs = ["gin_serde_test.cc"], + deps = [ + ":gin", + ":gin_serde", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + ], +) + cc_library( name = "game_state", srcs = ["game_state.cc"], @@ -115,9 +170,11 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":game_state", + ":gin", "//domains/games/libs/cards", "@com_google_absl//absl/status", "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", ], ) @@ -127,6 +184,7 @@ cc_test( srcs = ["table_test.cc"], deps = [ ":game_state", + ":gin", ":table", "//domains/games/libs/cards", "//domains/games/libs/cards:dealer", @@ -144,6 +202,7 @@ cc_library( deps = [ ":game_state", ":game_state_serde", + ":gin_serde", ":table", "@com_google_absl//absl/status", "@com_google_absl//absl/status:statusor", @@ -159,6 +218,7 @@ cc_test( deps = [ ":game_state", ":game_state_serde", + ":gin_serde", ":table", ":table_serde", "//domains/games/libs/cards", diff --git a/domains/games/libs/cards/rummy/game_state.cc b/domains/games/libs/cards/rummy/game_state.cc index b6cf1746..f1922f06 100644 --- a/domains/games/libs/cards/rummy/game_state.cc +++ b/domains/games/libs/cards/rummy/game_state.cc @@ -41,19 +41,20 @@ vector withHand(const vector& roster, int seat, vector han } // namespace StatusOr dealRummyGame(const string& game_id, const vector& player_ids, - deque shuffled_deck, int opener) { + deque shuffled_deck, int opener, int hand_size) { const int seats = static_cast(player_ids.size()); if (seats < GameState::kMinPlayers || seats > GameState::kMaxPlayers) { return InvalidArgumentError("2 to 4 players"); } if (opener < 0 || opener >= seats) return InvalidArgumentError("no such opener"); - if (static_cast(shuffled_deck.size()) < seats * GameState::kHandSize + 1) { + if (hand_size < 1) return InvalidArgumentError("no cards to deal"); + if (static_cast(shuffled_deck.size()) < seats * hand_size + 1) { return InvalidArgumentError("deck too small"); } vector players; players.reserve(player_ids.size()); for (const string& id : player_ids) players.push_back(Player{id, {}}); - for (int round = 0; round < GameState::kHandSize; round++) { + for (int round = 0; round < hand_size; round++) { for (Player& player : players) { player.hand.push_back(shuffled_deck.back()); shuffled_deck.pop_back(); diff --git a/domains/games/libs/cards/rummy/game_state.h b/domains/games/libs/cards/rummy/game_state.h index 9b85d1cc..42c326e9 100644 --- a/domains/games/libs/cards/rummy/game_state.h +++ b/domains/games/libs/cards/rummy/game_state.h @@ -85,10 +85,11 @@ struct Player { /// Deals a fresh game from an already-shuffled deck (drawn from the /// back): one card a seat around the table until each has its hand, then /// one card face up to the discard pile. Play opens at `opener`'s draw. +/// `hand_size` is kHandSize but for ten-card rummy (#1609). [[nodiscard]] absl::StatusOr dealRummyGame(const string& game_id, const std::vector& player_ids, std::deque shuffled_deck, - int opener = 0); + int opener = 0, int hand_size = 7); class GameState { public: @@ -97,7 +98,7 @@ class GameState { /// whoseTurn once the game is over. static constexpr int kNoTurn = -1; - /// The hand each seat is dealt. + /// The hand each seat is dealt, but for ten-card rummy. static constexpr int kHandSize = 7; GameState(std::deque _stock, std::vector _discard, std::vector _players, diff --git a/domains/games/libs/cards/rummy/game_state_test.cc b/domains/games/libs/cards/rummy/game_state_test.cc index b5f46b0f..93ea7588 100644 --- a/domains/games/libs/cards/rummy/game_state_test.cc +++ b/domains/games/libs/cards/rummy/game_state_test.cc @@ -71,6 +71,16 @@ TEST(Deal, TwoSeatsGetSevenOneCardIsTurnedUpAndTheRestIsStock) { EXPECT_FALSE(game->getTakenDiscard().has_value()); } +// Ten-card rummy (#1609) is the same game dealt ten a seat. +TEST(Deal, AHandSizeCanBeAskedFor) { + NoShuffleDealer dealer; + auto game = dealRummyGame("g1", {"a", "b", "c"}, dealer.DealNewUnshuffledDeck(), 0, 10); + ASSERT_TRUE(game.ok()) << game.status(); + for (const Player& p : game->getPlayers()) EXPECT_EQ(p.hand.size(), 10u); + EXPECT_EQ(game->getStock().size(), 52u - 30u - 1u); + EXPECT_FALSE(dealRummyGame("g1", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 0, 0).ok()); +} + TEST(Deal, DealsOneCardASeatAroundTheTableFromTheBack) { // The unshuffled deck's back is A♠ A♥ A♦ A♣ K♠ ...: seat 0 takes every // other card from it, and the card after the hands is turned up. diff --git a/domains/games/libs/cards/rummy/gin.cc b/domains/games/libs/cards/rummy/gin.cc new file mode 100644 index 00000000..004f4b86 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin.cc @@ -0,0 +1,256 @@ +#include "domains/games/libs/cards/rummy/gin.h" + +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/rummy/arrange.h" + +namespace rummy { +using absl::FailedPreconditionError; +using absl::InvalidArgumentError; +using absl::NotFoundError; +using absl::StatusOr; + +StatusOr dealGin(const std::string& game_id, const std::vector& player_ids, + std::deque shuffled_deck, int opener) { + if (static_cast(player_ids.size()) != GinState::kSeats) { + return InvalidArgumentError("gin is for 2 players"); + } + if (opener < 0 || opener >= GinState::kSeats) return InvalidArgumentError("no such opener"); + if (static_cast(shuffled_deck.size()) < GinState::kSeats * GinState::kHandSize + 1) { + return InvalidArgumentError("deck too small"); + } + std::vector players; + for (const std::string& id : player_ids) players.push_back(Player{id, {}}); + for (int round = 0; round < GinState::kHandSize; round++) { + for (Player& player : players) { + player.hand.push_back(shuffled_deck.back()); + shuffled_deck.pop_back(); + } + } + std::vector discard{shuffled_deck.back()}; + shuffled_deck.pop_back(); + return GinState{std::move(shuffled_deck), + std::move(discard), + std::move(players), + opener, + GinStage::Upcard, + Phase::Playing, + 0, + std::nullopt, + std::nullopt, + std::nullopt, + game_id, + ""}; +} + +GinState::GinState(std::deque _stock, std::vector _discard, + std::vector _players, int _whoseTurn, GinStage _stage, Phase _phase, + int _upcardPasses, std::optional _takenDiscard, + std::optional _lastMove, std::optional _result, + std::string _gameId, std::string _versionId) + : stock(std::move(_stock)), + discardPile(std::move(_discard)), + players(std::move(_players)), + whoseTurn(_whoseTurn), + stage(_stage), + phase(_phase), + upcardPasses(_upcardPasses), + takenDiscard(std::move(_takenDiscard)), + lastMove(std::move(_lastMove)), + result(std::move(_result)), + gameId(std::move(_gameId)), + versionId(std::move(_versionId)) {} + +absl::Status GinState::ensureTurn(int player) const { + if (phase != Phase::Playing) return FailedPreconditionError("game is over"); + if (player < 0 || player >= static_cast(players.size())) { + return InvalidArgumentError("no such player"); + } + if (player != whoseTurn) return FailedPreconditionError("not your turn"); + return absl::OkStatus(); +} + +StatusOr GinState::pass(int player) const { + if (auto status = ensureTurn(player); !status.ok()) return status; + if (stage != GinStage::Upcard) return FailedPreconditionError("nothing to pass on"); + const int other = 1 - player; + // The first pass offers the upcard to the dealer; the second leaves the + // opener — the seat that passed first — to draw from the stock. + const bool second = upcardPasses > 0; + GinState next = *this; + next.whoseTurn = other; + next.stage = second ? GinStage::StockOnly : GinStage::Upcard; + next.upcardPasses = second ? 0 : 1; + next.lastMove = GinLastMove{players[player].id, GinMoveKind::Pass, {}}; + return next; +} + +StatusOr GinState::drawStock(int player) const { + if (auto status = ensureTurn(player); !status.ok()) return status; + if (stage == GinStage::Upcard) return FailedPreconditionError("take the upcard or pass"); + if (stage == GinStage::Play) return FailedPreconditionError("you have already drawn"); + if (stock.empty()) return FailedPreconditionError("the stock is empty"); + GinState next = *this; + next.players[player].hand.push_back(next.stock.back()); + next.stock.pop_back(); + next.stage = GinStage::Play; + next.takenDiscard = std::nullopt; + next.lastMove = GinLastMove{players[player].id, GinMoveKind::DrawStock, {}}; + return next; +} + +StatusOr GinState::drawDiscard(int player) const { + if (auto status = ensureTurn(player); !status.ok()) return status; + if (stage == GinStage::Play) return FailedPreconditionError("you have already drawn"); + if (stage == GinStage::StockOnly) { + return FailedPreconditionError("the upcard was passed: draw from the stock"); + } + if (discardPile.empty()) return FailedPreconditionError("the discard pile is empty"); + GinState next = *this; + const Card taken = next.discardPile.back(); + next.discardPile.pop_back(); + next.players[player].hand.push_back(taken); + next.stage = GinStage::Play; + next.upcardPasses = 0; + next.takenDiscard = taken; + next.lastMove = GinLastMove{players[player].id, GinMoveKind::DrawDiscard, {taken}}; + return next; +} + +StatusOr> GinState::throwing(int player, const Card& card) const { + if (auto status = ensureTurn(player); !status.ok()) return status; + if (stage != GinStage::Play) return FailedPreconditionError("draw a card first"); + std::vector hand = players[player].hand; + const auto at = std::find(hand.begin(), hand.end(), card); + if (at == hand.end()) return NotFoundError("that card is not in your hand"); + if (takenDiscard == card) { + return FailedPreconditionError("you took that card from the discard pile this turn"); + } + hand.erase(at); + return hand; +} + +StatusOr GinState::discard(int player, const Card& card) const { + auto hand = throwing(player, card); + if (!hand.ok()) return hand.status(); + GinState next = *this; + next.players[player].hand = *std::move(hand); + next.discardPile.push_back(card); + next.takenDiscard = std::nullopt; + next.lastMove = GinLastMove{players[player].id, GinMoveKind::Discard, {card}}; + if (static_cast(next.stock.size()) <= kStockFloor) { + // Down to the last two: the deal is drawn, and nobody scores. + GinResult drawn; + drawn.ending = GinEnding::Draw; + for (const Player& seat : next.players) drawn.hands.push_back(bestArrangement(seat.hand)); + next.result = std::move(drawn); + next.phase = Phase::Over; + next.whoseTurn = kNoTurn; + return next; + } + next.whoseTurn = 1 - player; + next.stage = GinStage::Draw; + return next; +} + +StatusOr GinState::knock(int player, const Card& card) const { + auto hand = throwing(player, card); + if (!hand.ok()) return hand.status(); + const Arrangement knocker = bestArrangement(*hand); + if (knocker.deadwoodPoints > kKnockLimit) { + return FailedPreconditionError("more than 10 deadwood: no knock"); + } + const int defender = 1 - player; + GinResult ended; + ended.knocker = player; + ended.hands.resize(kSeats); + ended.hands[player] = knocker; + if (knocker.deadwoodPoints == 0) { + // Gin: nothing to lay off onto. + ended.ending = GinEnding::Gin; + ended.hands[defender] = bestArrangement(players[defender].hand); + ended.winner = player; + ended.points = kGinBonus + ended.hands[defender].deadwoodPoints; + } else { + LaidOffArrangement defended = bestWithLayOffs(players[defender].hand, knocker.melds); + ended.hands[defender] = std::move(defended.own); + ended.laidOff = std::move(defended.laidOff); + const int kept = ended.hands[defender].deadwoodPoints; + if (kept <= knocker.deadwoodPoints) { + ended.ending = GinEnding::Undercut; + ended.winner = defender; + ended.points = kUndercutBonus + knocker.deadwoodPoints - kept; + } else { + ended.ending = GinEnding::Knock; + ended.winner = player; + ended.points = kept - knocker.deadwoodPoints; + } + } + GinState next = *this; + next.players[player].hand = *std::move(hand); + next.discardPile.push_back(card); + next.takenDiscard = std::nullopt; + next.lastMove = GinLastMove{players[player].id, GinMoveKind::Knock, {card}}; + next.result = std::move(ended); + next.phase = Phase::Over; + next.whoseTurn = kNoTurn; + return next; +} + +StatusOr GinState::removePlayer(int player) const { + if (player < 0 || player >= static_cast(players.size())) { + return InvalidArgumentError("no such player"); + } + if (phase != Phase::Playing) return FailedPreconditionError("game is over"); + GinState next = *this; + next.players.erase(next.players.begin() + player); + next.phase = Phase::Abandoned; + next.whoseTurn = kNoTurn; + next.takenDiscard = std::nullopt; + return next; +} + +std::optional GinState::winner() const { + if (phase != Phase::Over || !result.has_value() || result->winner < 0) return std::nullopt; + return players.at(result->winner).id; +} + +int GinState::deadwood(int player) const { + if (result.has_value() && player < static_cast(result->hands.size())) { + return result->hands[player].deadwoodPoints; + } + return bestArrangement(players.at(player).hand).deadwoodPoints; +} + +bool GinState::canDrawStock() const { + return phase == Phase::Playing && (stage == GinStage::StockOnly || stage == GinStage::Draw) && + !stock.empty(); +} + +bool GinState::canDrawDiscard() const { + return phase == Phase::Playing && (stage == GinStage::Upcard || stage == GinStage::Draw) && + !discardPile.empty(); +} + +GinState GinState::withIdAndVersion(const std::string& game_id, + const std::string& version_id) const { + GinState next = *this; + next.gameId = game_id; + next.versionId = version_id; + return next; +} + +int GinState::playerIndex(const std::string& id) const { + for (std::size_t i = 0; i < players.size(); i++) { + if (players[i].id == id) return static_cast(i); + } + return -1; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/gin.h b/domains/games/libs/cards/rummy/gin.h new file mode 100644 index 00000000..bb1d5494 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin.h @@ -0,0 +1,154 @@ +#ifndef CPP_CARDS_RUMMY_GIN_H +#define CPP_CARDS_RUMMY_GIN_H + +#include +#include +#include +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/arrange.h" +#include "domains/games/libs/cards/rummy/game_state.h" + +namespace rummy { + +/// Gin rummy (#1610), one deal of it at a dealer's-choice table. +/// +/// The rules this engine plays: +/// - Two seats, ten cards each, one card turned up to start the +/// discard pile; the rest is the stock. +/// - The upcard: the opener (the seat after the dealer) may take it or +/// pass; then the dealer may. If both pass, the opener draws from the +/// stock and play goes on from there. +/// - A turn is a draw (the stock or the discard pile's top) then a +/// discard. The card taken from the discard pile may not go straight +/// back. Nothing is melded during play. +/// - Knocking ends the deal: the knocker discards a card and must be +/// left with 10 or less deadwood in their best arrangement +/// (bestArrangement). The defender's hand is arranged too, laying cards +/// off onto the knocker's melds (bestWithLayOffs) — not after gin. +/// - Scoring: a knock scores the knocker the deadwood difference; gin +/// (no deadwood) scores 25 plus the defender's deadwood; a defender +/// with deadwood no more than the knocker's undercuts, scoring 25 plus +/// the difference. +/// - A discard that leaves two cards in the stock ends the deal drawn: +/// nobody scores. +/// +/// Refusals are absl statuses as GameState's: FailedPrecondition for the +/// wrong turn or the wrong part of it, NotFound for a card the hand does +/// not hold. +enum class GinStage { Upcard, StockOnly, Draw, Play }; + +enum class GinMoveKind { Pass, DrawStock, DrawDiscard, Discard, Knock }; + +/// The deal's most recent move: the card taken for a discard draw, the +/// card thrown for a discard or a knock, nothing for a pass or a stock +/// draw. +struct GinLastMove { + std::string playerId; + GinMoveKind kind = GinMoveKind::Pass; + std::vector cards; + bool operator==(const GinLastMove& o) const { + return playerId == o.playerId && kind == o.kind && cards == o.cards; + } +}; + +enum class GinEnding { Knock, Gin, Undercut, Draw }; + +/// How a deal ended by play: the knocker (none for a draw), each seat's +/// hand arranged (the defender's without what it laid off), the +/// defender's cards laid off onto the knocker's melds, and who scored +/// what. +struct GinResult { + GinEnding ending = GinEnding::Draw; + int knocker = -1; + std::vector hands; + std::vector laidOff; + int winner = -1; + int points = 0; +}; + +class GinState; + +/// Deals gin from an already-shuffled deck (drawn from the back): ten a +/// seat, one turned up. `opener` decides the upcard first. +[[nodiscard]] absl::StatusOr dealGin(const std::string& game_id, + const std::vector& player_ids, + std::deque shuffled_deck, int opener = 0); + +class GinState { + public: + static constexpr int kSeats = 2; + static constexpr int kHandSize = 10; + static constexpr int kKnockLimit = 10; + static constexpr int kGinBonus = 25; + static constexpr int kUndercutBonus = 25; + /// A discard leaving this few in the stock draws the deal. + static constexpr int kStockFloor = 2; + static constexpr int kNoTurn = -1; + + GinState(std::deque _stock, std::vector _discard, std::vector _players, + int _whoseTurn, GinStage _stage, Phase _phase, int _upcardPasses, + std::optional _takenDiscard, std::optional _lastMove, + std::optional _result, std::string _gameId, std::string _versionId); + + [[nodiscard]] absl::StatusOr pass(int player) const; + [[nodiscard]] absl::StatusOr drawStock(int player) const; + [[nodiscard]] absl::StatusOr drawDiscard(int player) const; + [[nodiscard]] absl::StatusOr discard(int player, const Card& card) const; + [[nodiscard]] absl::StatusOr knock(int player, const Card& card) const; + /// A seat leaving: the deal is abandoned and nobody scores. + [[nodiscard]] absl::StatusOr removePlayer(int player) const; + + [[nodiscard]] bool isOver() const { return phase != Phase::Playing; } + [[nodiscard]] Phase getPhase() const { return phase; } + [[nodiscard]] GinStage getStage() const { return stage; } + /// The seat that scored, once the deal ended by play; none for a draw. + [[nodiscard]] std::optional winner() const; + [[nodiscard]] int winnerPoints() const { return result.has_value() ? result->points : 0; } + /// A seat's deadwood: as the deal's end arranged it, else its hand's + /// best arrangement. + [[nodiscard]] int deadwood(int player) const; + [[nodiscard]] bool canDrawStock() const; + [[nodiscard]] bool canDrawDiscard() const; + [[nodiscard]] const std::optional& getResult() const { return result; } + + [[nodiscard]] GinState withIdAndVersion(const std::string& game_id, + const std::string& version_id) const; + [[nodiscard]] const std::deque& getStock() const { return stock; } + [[nodiscard]] const std::vector& getDiscard() const { return discardPile; } + [[nodiscard]] const std::vector& getPlayers() const { return players; } + [[nodiscard]] const Player& getPlayer(int index) const { return players.at(index); } + [[nodiscard]] int playerIndex(const std::string& id) const; + [[nodiscard]] int getWhoseTurn() const { return whoseTurn; } + /// Passes on the upcard so far: 0 or 1 while it is on offer. + [[nodiscard]] int getUpcardPasses() const { return upcardPasses; } + [[nodiscard]] const std::optional& getTakenDiscard() const { return takenDiscard; } + [[nodiscard]] const std::optional& getLastMove() const { return lastMove; } + [[nodiscard]] const std::string& getGameId() const { return gameId; } + [[nodiscard]] const std::string& getVersionId() const { return versionId; } + + private: + [[nodiscard]] absl::Status ensureTurn(int player) const; + /// The hand after throwing `card`, refused if the hand lacks it or it + /// is the card just taken. + [[nodiscard]] absl::StatusOr> throwing(int player, const Card& card) const; + + std::deque stock; // back is the top + std::vector discardPile; // back is the top + std::vector players; + int whoseTurn; + GinStage stage; + Phase phase; + int upcardPasses; + std::optional takenDiscard; + std::optional lastMove; + std::optional result; + std::string gameId; + std::string versionId; +}; + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/gin_serde.cc b/domains/games/libs/cards/rummy/gin_serde.cc new file mode 100644 index 00000000..010c3598 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin_serde.cc @@ -0,0 +1,294 @@ +#include "domains/games/libs/cards/rummy/gin_serde.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" + +namespace rummy { +namespace { + +using nlohmann::json; + +constexpr int kSchemaVersion = 1; + +template +json cardsToJson(const Cards& cards) { + json codes = json::array(); + for (const Card& card : cards) codes.push_back(card.intValue()); + return codes; +} + +// postgres jsonb rejects a NUL byte: U+FFFD, as the other rows do. +std::string sanitized(const std::string& text) { + std::string safe; + for (const char c : text) { + if (c == '\0') { + safe += "\xEF\xBF\xBD"; + } else { + safe += c; + } + } + return safe; +} + +template +struct Names { + Enum value; + const char* name; +}; + +constexpr Names kStages[] = {{GinStage::Upcard, "upcard"}, + {GinStage::StockOnly, "stock"}, + {GinStage::Draw, "draw"}, + {GinStage::Play, "play"}}; +constexpr Names kPhases[] = { + {Phase::Playing, "playing"}, {Phase::Over, "over"}, {Phase::Abandoned, "abandoned"}}; +constexpr Names kKinds[] = {{GinMoveKind::Pass, "pass"}, + {GinMoveKind::DrawStock, "drawStock"}, + {GinMoveKind::DrawDiscard, "drawDiscard"}, + {GinMoveKind::Discard, "discard"}, + {GinMoveKind::Knock, "knock"}}; +constexpr Names kEndings[] = {{GinEnding::Knock, "knock"}, + {GinEnding::Gin, "gin"}, + {GinEnding::Undercut, "undercut"}, + {GinEnding::Draw, "draw"}}; + +template +const char* nameOf(const Names (&names)[N], Enum value) { + for (const auto& entry : names) { + if (entry.value == value) return entry.name; + } + return names[0].name; +} + +absl::StatusOr readIntInRange(const json& object, const char* key, int64_t lo, int64_t hi) { + if (!object.contains(key) || !object[key].is_number_integer()) { + return absl::InvalidArgumentError(absl::StrCat("expected integer field '", key, "'")); + } + const int64_t value = object[key].get(); + if (value < lo || value > hi) { + return absl::InvalidArgumentError(absl::StrCat("field '", key, "' out of range")); + } + return static_cast(value); +} + +absl::StatusOr readString(const json& object, const char* key) { + if (!object.contains(key) || !object[key].is_string()) { + return absl::InvalidArgumentError(absl::StrCat("expected string field '", key, "'")); + } + return object[key].get(); +} + +template +absl::StatusOr readName(const json& object, const char* key, const Names (&names)[N]) { + auto name = readString(object, key); + if (!name.ok()) return name.status(); + for (const auto& entry : names) { + if (*name == entry.name) return entry.value; + } + return absl::InvalidArgumentError(absl::StrCat("unknown ", key)); +} + +absl::StatusOr> readCards(const json& value, const char* what) { + if (!value.is_array() || value.size() > 52) { + return absl::InvalidArgumentError(absl::StrCat("expected cards in '", what, "'")); + } + std::vector cards; + for (const json& code : value) { + if (!code.is_number_integer() || code.get() < 0 || code.get() > 51) { + return absl::InvalidArgumentError(absl::StrCat("card code out of range in '", what, "'")); + } + cards.emplace_back(static_cast(code.get())); + } + return cards; +} + +absl::StatusOr> readCardField(const json& object, const char* key) { + if (!object.contains(key)) { + return absl::InvalidArgumentError(absl::StrCat("expected array field '", key, "'")); + } + return readCards(object[key], key); +} + +json arrangementToJson(const Arrangement& hand) { + json melds = json::array(); + for (const auto& meld : hand.melds) melds.push_back(cardsToJson(meld)); + return json{{"melds", std::move(melds)}, + {"deadwood", cardsToJson(hand.deadwood)}, + {"deadwoodPoints", hand.deadwoodPoints}}; +} + +absl::StatusOr readArrangement(const json& object) { + if (!object.is_object() || !object.contains("melds") || !object["melds"].is_array()) { + return absl::InvalidArgumentError("expected an arranged hand"); + } + Arrangement hand; + for (const json& meld : object["melds"]) { + auto cards = readCards(meld, "melds"); + if (!cards.ok()) return cards.status(); + hand.melds.push_back(*std::move(cards)); + } + auto deadwood = readCardField(object, "deadwood"); + if (!deadwood.ok()) return deadwood.status(); + hand.deadwood = *std::move(deadwood); + auto points = readIntInRange(object, "deadwoodPoints", 0, 520); + if (!points.ok()) return points.status(); + hand.deadwoodPoints = *points; + return hand; +} + +} // namespace + +std::string serializeGinState(const GinState& state) { + json players = json::array(); + for (const Player& seat : state.getPlayers()) { + players.push_back(json{{"id", sanitized(seat.id)}, {"hand", cardsToJson(seat.hand)}}); + } + json serialized{ + {"v", kSchemaVersion}, + {"stock", cardsToJson(state.getStock())}, + {"discard", cardsToJson(state.getDiscard())}, + {"players", std::move(players)}, + {"whoseTurn", state.getWhoseTurn()}, + {"stage", nameOf(kStages, state.getStage())}, + {"phase", nameOf(kPhases, state.getPhase())}, + {"upcardPasses", state.getUpcardPasses()}, + }; + if (state.getTakenDiscard().has_value()) { + serialized["takenDiscard"] = state.getTakenDiscard()->intValue(); + } + if (const auto& move = state.getLastMove(); move.has_value()) { + serialized["lastMove"] = json{{"player", sanitized(move->playerId)}, + {"kind", nameOf(kKinds, move->kind)}, + {"cards", cardsToJson(move->cards)}}; + } + if (const auto& result = state.getResult(); result.has_value()) { + json hands = json::array(); + for (const Arrangement& hand : result->hands) hands.push_back(arrangementToJson(hand)); + serialized["result"] = json{{"ending", nameOf(kEndings, result->ending)}, + {"knocker", result->knocker}, + {"winner", result->winner}, + {"points", result->points}, + {"laidOff", cardsToJson(result->laidOff)}, + {"hands", std::move(hands)}}; + } + return serialized.dump(/*indent=*/-1, /*indent_char=*/' ', /*ensure_ascii=*/false, + json::error_handler_t::replace); +} + +absl::StatusOr deserializeGinState(const std::string& serialized) { + const json parsed = json::parse(serialized, /*cb=*/nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) { + return absl::InvalidArgumentError("not a JSON object"); + } + auto version = readIntInRange(parsed, "v", kSchemaVersion, kSchemaVersion); + if (!version.ok()) return version.status(); + + auto stock = readCardField(parsed, "stock"); + if (!stock.ok()) return stock.status(); + auto discard = readCardField(parsed, "discard"); + if (!discard.ok()) return discard.status(); + if (!parsed.contains("players") || !parsed["players"].is_array()) { + return absl::InvalidArgumentError("expected array field 'players'"); + } + std::vector players; + for (const json& seat : parsed["players"]) { + if (!seat.is_object()) return absl::InvalidArgumentError("a seat is an object"); + auto id = readString(seat, "id"); + if (!id.ok()) return id.status(); + auto hand = readCardField(seat, "hand"); + if (!hand.ok()) return hand.status(); + players.push_back(Player{*std::move(id), *std::move(hand)}); + } + auto stage = readName(parsed, "stage", kStages); + if (!stage.ok()) return stage.status(); + auto phase = readName(parsed, "phase", kPhases); + if (!phase.ok()) return phase.status(); + auto passes = readIntInRange(parsed, "upcardPasses", 0, 1); + if (!passes.ok()) return passes.status(); + + const int seats = static_cast(players.size()); + // Only an abandoned deal has fewer than two seats: a leave is what + // abandons it. + if (*phase == Phase::Abandoned ? seats > GinState::kSeats : seats != GinState::kSeats) { + return absl::InvalidArgumentError("gin seats two"); + } + const bool playing = *phase == Phase::Playing; + auto turn = readIntInRange(parsed, "whoseTurn", playing ? 0 : GinState::kNoTurn, + playing ? seats - 1 : GinState::kNoTurn); + if (!turn.ok()) return turn.status(); + + std::optional taken; + if (parsed.contains("takenDiscard")) { + auto card = readIntInRange(parsed, "takenDiscard", 0, 51); + if (!card.ok()) return card.status(); + taken.emplace(*card); + } + std::optional lastMove; + if (parsed.contains("lastMove")) { + const json& move = parsed["lastMove"]; + if (!move.is_object()) return absl::InvalidArgumentError("expected object 'lastMove'"); + auto player = readString(move, "player"); + if (!player.ok()) return player.status(); + auto kind = readName(move, "kind", kKinds); + if (!kind.ok()) return kind.status(); + auto cards = readCardField(move, "cards"); + if (!cards.ok()) return cards.status(); + lastMove = GinLastMove{*std::move(player), *kind, *std::move(cards)}; + } + std::optional result; + if (parsed.contains("result")) { + const json& ended = parsed["result"]; + if (!ended.is_object()) return absl::InvalidArgumentError("expected object 'result'"); + GinResult read; + auto ending = readName(ended, "ending", kEndings); + if (!ending.ok()) return ending.status(); + read.ending = *ending; + auto knocker = readIntInRange(ended, "knocker", -1, seats - 1); + if (!knocker.ok()) return knocker.status(); + read.knocker = *knocker; + auto winner = readIntInRange(ended, "winner", -1, seats - 1); + if (!winner.ok()) return winner.status(); + read.winner = *winner; + auto points = readIntInRange(ended, "points", 0, 1000); + if (!points.ok()) return points.status(); + read.points = *points; + auto laidOff = readCardField(ended, "laidOff"); + if (!laidOff.ok()) return laidOff.status(); + read.laidOff = *std::move(laidOff); + if (!ended.contains("hands") || !ended["hands"].is_array() || + static_cast(ended["hands"].size()) != seats) { + return absl::InvalidArgumentError("expected one arranged hand per seat"); + } + for (const json& hand : ended["hands"]) { + auto arranged = readArrangement(hand); + if (!arranged.ok()) return arranged.status(); + read.hands.push_back(*std::move(arranged)); + } + result = std::move(read); + } + // Over by play is over with a result: its winner, or a draw. + if ((*phase == Phase::Over) != result.has_value()) { + return absl::InvalidArgumentError("a deal over by play has its result"); + } + return GinState{std::deque(stock->begin(), stock->end()), + *std::move(discard), + std::move(players), + *turn, + *stage, + *phase, + *passes, + taken, + std::move(lastMove), + std::move(result), + "", + ""}; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/gin_serde.h b/domains/games/libs/cards/rummy/gin_serde.h new file mode 100644 index 00000000..82b88906 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin_serde.h @@ -0,0 +1,23 @@ +#ifndef CPP_CARDS_RUMMY_GIN_SERDE_H +#define CPP_CARDS_RUMMY_GIN_SERDE_H + +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/rummy/gin.h" + +namespace rummy { + +/// A gin deal as a JSON object, the `deal` of a table row whose variant is +/// gin (table_serde). Versioned on its own; cards are Card::intValue(). +[[nodiscard]] std::string serializeGinState(const GinState& state); + +/// The deal back, refused (InvalidArgument) for any shape the engine could +/// not play: an unknown stage or phase, seats not two while playing, a +/// turn out of range, a card code out of range, a result that names seats +/// the deal does not have. +[[nodiscard]] absl::StatusOr deserializeGinState(const std::string& serialized); + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/gin_serde_test.cc b/domains/games/libs/cards/rummy/gin_serde_test.cc new file mode 100644 index 00000000..a3758946 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin_serde_test.cc @@ -0,0 +1,147 @@ +#include "domains/games/libs/cards/rummy/gin_serde.h" + +#include + +#include +#include +#include +#include + +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/dealer.h" +#include "domains/games/libs/cards/rummy/gin.h" + +using namespace cards; +using namespace rummy; +using nlohmann::json; + +namespace { + +GinState dealt() { + auto deal = dealGin("g", {"a", "b"}, NoShuffleDealer().DealNewUnshuffledDeck(), 1); + EXPECT_TRUE(deal.ok()) << deal.status(); + return *deal; +} + +// Seat 1 passes, seat 0 takes the upcard. +GinState taken() { + auto passed = dealt().pass(1); + EXPECT_TRUE(passed.ok()); + auto took = passed->drawDiscard(0); + EXPECT_TRUE(took.ok()) << took.status(); + return *took; +} + +// Seat 1 draws the stock after both pass, and knocks: with the pristine +// deck seat 1 holds A♥ A♣ K♥ K♣ Q♥ Q♣ J♥ J♣ 10♥ 10♣, two runs; 9♥ from +// the stock extends hearts, and throwing it is gin. +GinState ginned() { + auto one = dealt().pass(1); + auto two = one->pass(0); + auto drew = two->drawStock(1); + EXPECT_TRUE(drew.ok()) << drew.status(); + auto knocked = drew->knock(1, drew->getPlayer(1).hand.back()); + EXPECT_TRUE(knocked.ok()) << knocked.status(); + return *knocked; +} + +void expectRoundTrips(const GinState& state) { + const std::string serialized = serializeGinState(state); + const auto restored = deserializeGinState(serialized); + ASSERT_TRUE(restored.ok()) << restored.status() << "\n" << serialized; + EXPECT_EQ(serializeGinState(*restored), serialized); + EXPECT_EQ(restored->getStage(), state.getStage()); + EXPECT_EQ(restored->getPhase(), state.getPhase()); + EXPECT_EQ(restored->getWhoseTurn(), state.getWhoseTurn()); + EXPECT_EQ(restored->getUpcardPasses(), state.getUpcardPasses()); + EXPECT_EQ(restored->getTakenDiscard(), state.getTakenDiscard()); + EXPECT_EQ(restored->getLastMove(), state.getLastMove()); + EXPECT_EQ(restored->getPlayers(), state.getPlayers()); + EXPECT_EQ(restored->winner(), state.winner()); + EXPECT_EQ(restored->winnerPoints(), state.winnerPoints()); +} + +void expectRejected(const json& payload) { + const auto restored = deserializeGinState(payload.dump()); + ASSERT_FALSE(restored.ok()) << "accepted: " << payload.dump(); + EXPECT_EQ(restored.status().code(), absl::StatusCode::kInvalidArgument); +} + +} // namespace + +TEST(GinSerde, EveryStageAndEndingRoundTrips) { + expectRoundTrips(dealt()); + expectRoundTrips(*dealt().pass(1)); + expectRoundTrips(*dealt().pass(1)->pass(0)); + expectRoundTrips(taken()); + expectRoundTrips(*taken().discard(0, taken().getPlayer(0).hand.front())); + const GinState gin = ginned(); + ASSERT_EQ(gin.getResult()->ending, GinEnding::Gin); + expectRoundTrips(gin); + const auto restored = deserializeGinState(serializeGinState(gin)); + ASSERT_TRUE(restored.ok()); + EXPECT_EQ(restored->getResult()->hands.at(1).melds, gin.getResult()->hands.at(1).melds); + EXPECT_EQ(restored->getResult()->hands.at(0).deadwood, gin.getResult()->hands.at(0).deadwood); + expectRoundTrips(*taken().removePlayer(1)); +} + +// The bytes of a fresh deal. A change to the shape is a schema change: a +// version bump, not an edit here. +TEST(GinSerde, FrozenPayload) { + constexpr const char* kRow = + R"({"discard":[31],"phase":"playing","players":[{"hand":[51,49,47,45,43,41,39,37,35,33],)" + R"("id":"a"},{"hand":[50,48,46,44,42,40,38,36,34,32],"id":"b"}],"stage":"upcard",)" + R"("stock":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,)" + R"(29,30],"upcardPasses":0,"v":1,"whoseTurn":1})"; + EXPECT_EQ(serializeGinState(dealt()), kRow); + ASSERT_TRUE(deserializeGinState(kRow).ok()); +} + +TEST(GinSerde, RejectsWhatTheEngineCouldNotPlay) { + const json base = json::parse(serializeGinState(taken())); + json payload = base; + payload["v"] = 2; + expectRejected(payload); + for (const char* key : + {"stock", "discard", "players", "whoseTurn", "stage", "phase", "upcardPasses"}) { + payload = base; + payload.erase(key); + expectRejected(payload); + } + payload = base; + payload["stage"] = "melding"; + expectRejected(payload); + payload = base; + payload["whoseTurn"] = 2; + expectRejected(payload); + payload = base; + payload["players"].erase(1); + expectRejected(payload); // playing with one seat + payload = base; + payload["upcardPasses"] = 2; + expectRejected(payload); + payload = base; + payload["stock"][0] = 52; + expectRejected(payload); + payload = base; + payload["lastMove"]["kind"] = "meld"; + expectRejected(payload); + + const json over = json::parse(serializeGinState(ginned())); + payload = over; + payload["result"]["winner"] = 5; + expectRejected(payload); + payload = over; + payload["result"]["ending"] = "schneider"; + expectRejected(payload); + payload = over; + payload["result"]["hands"].erase(0); + expectRejected(payload); + payload = over; + payload.erase("result"); + expectRejected(payload); // over by play, and no result + + for (const char* input : {"", "[]", "not json", R"({"v":1})"}) { + EXPECT_FALSE(deserializeGinState(input).ok()) << input; + } +} diff --git a/domains/games/libs/cards/rummy/gin_test.cc b/domains/games/libs/cards/rummy/gin_test.cc new file mode 100644 index 00000000..c3c82ce2 --- /dev/null +++ b/domains/games/libs/cards/rummy/gin_test.cc @@ -0,0 +1,276 @@ +#include "domains/games/libs/cards/rummy/gin.h" + +#include + +#include +#include +#include + +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/dealer.h" + +using namespace cards; +using namespace rummy; +using std::string; +using std::vector; + +namespace { + +Card c(Rank rank, Suit suit) { return Card{suit, rank}; } + +// A deal in play at seat `turn`'s `stage`, alice in seat 0 and bob in 1. +GinState playing(vector alice, vector bob, std::deque stock, vector discard, + int turn, GinStage stage, std::optional taken = std::nullopt) { + return GinState{std::move(stock), + std::move(discard), + {{"alice", std::move(alice)}, {"bob", std::move(bob)}}, + turn, + stage, + Phase::Playing, + 0, + taken, + std::nullopt, + std::nullopt, + "g", + ""}; +} + +std::deque someStock(int n) { + std::deque stock; + for (int i = 0; i < n; i++) stock.emplace_back(i); + return stock; +} + +// Alice's knocking hand after her draw: runs and a set with 2♦ and 9♦ over. +vector knocker() { + return { + c(Rank::Three, Suit::Hearts), c(Rank::Four, Suit::Hearts), c(Rank::Five, Suit::Hearts), + c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Diamonds), c(Rank::Seven, Suit::Spades), + c(Rank::Jack, Suit::Spades), c(Rank::Queen, Suit::Spades), c(Rank::King, Suit::Spades), + c(Rank::Two, Suit::Diamonds), c(Rank::Nine, Suit::Diamonds)}; +} + +} // namespace + +TEST(GinDeal, TenEachTheUpcardOnOfferToTheOpener) { + NoShuffleDealer dealer; + auto deal = dealGin("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 1); + ASSERT_TRUE(deal.ok()) << deal.status(); + EXPECT_EQ(deal->getPlayer(0).hand.size(), 10u); + EXPECT_EQ(deal->getPlayer(1).hand.size(), 10u); + EXPECT_EQ(deal->getDiscard().size(), 1u); + EXPECT_EQ(deal->getStock().size(), 31u); + EXPECT_EQ(deal->getWhoseTurn(), 1); + EXPECT_EQ(deal->getStage(), GinStage::Upcard); + EXPECT_FALSE(deal->canDrawStock()); + EXPECT_TRUE(deal->canDrawDiscard()); +} + +TEST(GinDeal, TwoSeatsOnly) { + NoShuffleDealer dealer; + EXPECT_FALSE(dealGin("g", {"a", "b", "c"}, dealer.DealNewUnshuffledDeck()).ok()); + EXPECT_FALSE(dealGin("g", {"a"}, dealer.DealNewUnshuffledDeck()).ok()); + EXPECT_FALSE(dealGin("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 2).ok()); +} + +TEST(GinUpcard, TheOpenerMayTakeIt) { + NoShuffleDealer dealer; + auto deal = dealGin("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 1); + const Card up = deal->getDiscard().back(); + EXPECT_EQ(deal->drawStock(1).status().code(), absl::StatusCode::kFailedPrecondition); + auto took = deal->drawDiscard(1); + ASSERT_TRUE(took.ok()) << took.status(); + EXPECT_EQ(took->getStage(), GinStage::Play); + EXPECT_EQ(took->getPlayer(1).hand.back(), up); + EXPECT_EQ(took->getTakenDiscard(), up); + EXPECT_TRUE(took->getDiscard().empty()); +} + +TEST(GinUpcard, PassedByBothTheOpenerDrawsFromTheStock) { + NoShuffleDealer dealer; + auto deal = dealGin("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 1); + EXPECT_EQ(deal->pass(0).status().code(), absl::StatusCode::kFailedPrecondition); + auto one = deal->pass(1); + ASSERT_TRUE(one.ok()); + EXPECT_EQ(one->getWhoseTurn(), 0); + EXPECT_EQ(one->getStage(), GinStage::Upcard); + auto two = one->pass(0); + ASSERT_TRUE(two.ok()); + EXPECT_EQ(two->getWhoseTurn(), 1); + EXPECT_EQ(two->getStage(), GinStage::StockOnly); + EXPECT_FALSE(two->canDrawDiscard()); + EXPECT_EQ(two->drawDiscard(1).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(two->pass(1).status().code(), absl::StatusCode::kFailedPrecondition); + auto drew = two->drawStock(1); + ASSERT_TRUE(drew.ok()) << drew.status(); + EXPECT_EQ(drew->getStage(), GinStage::Play); + EXPECT_EQ(drew->getLastMove()->kind, GinMoveKind::DrawStock); +} + +TEST(GinUpcard, TheDealerMayTakeItOnceTheOpenerPasses) { + NoShuffleDealer dealer; + auto one = dealGin("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 1)->pass(1); + auto took = one->drawDiscard(0); + ASSERT_TRUE(took.ok()) << took.status(); + EXPECT_EQ(took->getWhoseTurn(), 0); + EXPECT_EQ(took->getStage(), GinStage::Play); +} + +TEST(GinTurn, ADrawThenADiscardHandsItOn) { + auto state = playing(knocker(), {c(Rank::Ace, Suit::Clubs)}, someStock(10), + {c(Rank::Six, Suit::Clubs)}, 0, GinStage::Play); + auto threw = state.discard(0, c(Rank::Nine, Suit::Diamonds)); + ASSERT_TRUE(threw.ok()) << threw.status(); + EXPECT_EQ(threw->getWhoseTurn(), 1); + EXPECT_EQ(threw->getStage(), GinStage::Draw); + EXPECT_EQ(threw->getDiscard().back(), c(Rank::Nine, Suit::Diamonds)); + EXPECT_TRUE(threw->canDrawStock()); + EXPECT_TRUE(threw->canDrawDiscard()); + EXPECT_FALSE(threw->getTakenDiscard().has_value()); + EXPECT_EQ(threw->getLastMove()->kind, GinMoveKind::Discard); +} + +TEST(GinTurn, RefusesOutOfTurnOutOfOrderAndCardsNotHeld) { + auto state = playing(knocker(), {c(Rank::Ace, Suit::Clubs)}, someStock(10), + {c(Rank::Six, Suit::Clubs)}, 0, GinStage::Draw); + EXPECT_EQ(state.drawStock(1).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(state.discard(0, c(Rank::Two, Suit::Diamonds)).status().code(), + absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(state.pass(0).status().code(), absl::StatusCode::kFailedPrecondition); + auto drew = state.drawStock(0); + ASSERT_TRUE(drew.ok()); + EXPECT_EQ(drew->discard(0, c(Rank::Ace, Suit::Clubs)).status().code(), + absl::StatusCode::kNotFound); + EXPECT_EQ(drew->knock(0, c(Rank::Ace, Suit::Clubs)).status().code(), absl::StatusCode::kNotFound); +} + +TEST(GinTurn, TheCardTakenCannotGoStraightBack) { + const Card six = c(Rank::Six, Suit::Clubs); + auto state = + playing(knocker(), {c(Rank::Ace, Suit::Clubs)}, someStock(10), {six}, 0, GinStage::Draw); + auto took = state.drawDiscard(0); + ASSERT_TRUE(took.ok()); + EXPECT_EQ(took->discard(0, six).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(took->knock(0, six).status().code(), absl::StatusCode::kFailedPrecondition); +} + +// Alice keeps 2♦ after throwing 9♦: two of deadwood. Bob lays 6♥ and 7♥ +// off onto her hearts and A♠ onto her spades, and melds his clubs; +// K♦ K♣ Q♦ 5♠ are left, 35. +TEST(GinKnock, TheKnockerScoresTheDifferenceAfterLayOffs) { + const vector bob = {c(Rank::Six, Suit::Hearts), c(Rank::Seven, Suit::Hearts), + c(Rank::Two, Suit::Clubs), c(Rank::Three, Suit::Clubs), + c(Rank::Four, Suit::Clubs), c(Rank::King, Suit::Diamonds), + c(Rank::King, Suit::Clubs), c(Rank::Queen, Suit::Diamonds), + c(Rank::Ace, Suit::Spades), c(Rank::Five, Suit::Spades)}; + auto state = + playing(knocker(), bob, someStock(10), {c(Rank::Six, Suit::Clubs)}, 0, GinStage::Play); + auto knocked = state.knock(0, c(Rank::Nine, Suit::Diamonds)); + ASSERT_TRUE(knocked.ok()) << knocked.status(); + EXPECT_EQ(knocked->getPhase(), Phase::Over); + EXPECT_EQ(knocked->getWhoseTurn(), GinState::kNoTurn); + const GinResult& result = *knocked->getResult(); + EXPECT_EQ(result.ending, GinEnding::Knock); + EXPECT_EQ(result.knocker, 0); + EXPECT_EQ(result.winner, 0); + EXPECT_EQ(result.hands.at(0).deadwoodPoints, 2); + EXPECT_EQ(result.hands.at(1).deadwoodPoints, 35); + EXPECT_EQ(result.laidOff.size(), 3u); + EXPECT_EQ(result.points, 33); + EXPECT_EQ(knocked->winner(), "alice"); + EXPECT_EQ(knocked->winnerPoints(), 33); + EXPECT_EQ(knocked->deadwood(1), 35); + EXPECT_EQ(knocked->getDiscard().back(), c(Rank::Nine, Suit::Diamonds)); + EXPECT_EQ(knocked->getLastMove()->kind, GinMoveKind::Knock); +} + +TEST(GinKnock, MoreThanTenDeadwoodCannotKnock) { + vector hand = knocker(); + hand[9] = c(Rank::King, Suit::Diamonds); // K♦ for 2♦: 10 plus the 9♦ + auto state = playing(hand, {c(Rank::Ace, Suit::Clubs)}, someStock(10), {}, 0, GinStage::Play); + // Throwing K♦ leaves the 9♦: nine, and a knock. + EXPECT_TRUE(state.knock(0, c(Rank::King, Suit::Diamonds)).ok()); + // Throwing a seven breaks the set: far over ten. + const absl::Status over = state.knock(0, c(Rank::Seven, Suit::Clubs)).status(); + EXPECT_EQ(over.code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(over.message(), "more than 10 deadwood: no knock"); +} + +// No deadwood: gin. Bob may not lay off, and alice scores 25 and all he +// holds. +TEST(GinKnock, GinScoresTheBonusAndBlocksLayOffs) { + vector alice = knocker(); + alice[9] = c(Rank::Six, Suit::Hearts); // 3♥-6♥ for 2♦ + const vector bob = {c(Rank::Seven, Suit::Hearts), c(Rank::Two, Suit::Clubs), + c(Rank::Three, Suit::Clubs), c(Rank::Four, Suit::Clubs), + c(Rank::King, Suit::Diamonds), c(Rank::King, Suit::Clubs), + c(Rank::Queen, Suit::Diamonds), c(Rank::Ace, Suit::Spades), + c(Rank::Five, Suit::Spades), c(Rank::Eight, Suit::Hearts)}; + auto knocked = playing(alice, bob, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::Nine, Suit::Diamonds)); + ASSERT_TRUE(knocked.ok()) << knocked.status(); + const GinResult& result = *knocked->getResult(); + EXPECT_EQ(result.ending, GinEnding::Gin); + EXPECT_TRUE(result.laidOff.empty()); + EXPECT_EQ(result.hands.at(1).deadwoodPoints, 51); + EXPECT_EQ(result.points, 76); + EXPECT_EQ(result.winner, 0); +} + +// Bob's clubs, hearts and fives meld, all but A♥: one point. Against a +// knock on as much or more, he undercuts: 25 and the difference. +TEST(GinKnock, ADefenderWithNoMoreDeadwoodUndercuts) { + const vector alice = { + c(Rank::Three, Suit::Hearts), c(Rank::Four, Suit::Hearts), c(Rank::Five, Suit::Hearts), + c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Diamonds), c(Rank::Seven, Suit::Spades), + c(Rank::Jack, Suit::Spades), c(Rank::Queen, Suit::Spades), c(Rank::King, Suit::Spades), + c(Rank::Ace, Suit::Diamonds), c(Rank::Eight, Suit::Diamonds)}; + const vector bob = {c(Rank::Two, Suit::Clubs), c(Rank::Three, Suit::Clubs), + c(Rank::Four, Suit::Clubs), c(Rank::Nine, Suit::Hearts), + c(Rank::Ten, Suit::Hearts), c(Rank::Jack, Suit::Hearts), + c(Rank::Five, Suit::Diamonds), c(Rank::Five, Suit::Spades), + c(Rank::Five, Suit::Clubs), c(Rank::Ace, Suit::Hearts)}; + // Alice throws 8♦ and knocks on A♦, one point: bob's A♥ ties it. + auto tied = playing(alice, bob, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::Eight, Suit::Diamonds)); + ASSERT_TRUE(tied.ok()) << tied.status(); + EXPECT_EQ(tied->getResult()->ending, GinEnding::Undercut); + EXPECT_EQ(tied->getResult()->winner, 1); + EXPECT_EQ(tied->getResult()->points, 25); + EXPECT_EQ(tied->winner(), "bob"); + // Throwing A♦ instead leaves 8♦: eight against one. + auto under = playing(alice, bob, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::Ace, Suit::Diamonds)); + ASSERT_TRUE(under.ok()) << under.status(); + EXPECT_EQ(under->getResult()->ending, GinEnding::Undercut); + EXPECT_EQ(under->getResult()->points, 25 + 7); +} + +TEST(GinStock, ADiscardLeavingTwoInTheStockDrawsTheDeal) { + auto state = playing(knocker(), {c(Rank::King, Suit::Clubs)}, someStock(3), + {c(Rank::Six, Suit::Clubs)}, 0, GinStage::Draw); + auto drew = state.drawStock(0); + ASSERT_TRUE(drew.ok()); + ASSERT_EQ(drew->getStock().size(), 2u); + // A knock still ends it by knocking. + EXPECT_EQ(drew->knock(0, c(Rank::Nine, Suit::Diamonds))->getResult()->ending, GinEnding::Knock); + auto threw = drew->discard(0, c(Rank::Nine, Suit::Diamonds)); + ASSERT_TRUE(threw.ok()); + EXPECT_EQ(threw->getPhase(), Phase::Over); + EXPECT_EQ(threw->getResult()->ending, GinEnding::Draw); + EXPECT_FALSE(threw->winner().has_value()); + EXPECT_EQ(threw->winnerPoints(), 0); +} + +TEST(GinLeave, ALeaveAbandonsTheDeal) { + auto state = + playing(knocker(), {c(Rank::Ace, Suit::Clubs)}, someStock(10), {}, 1, GinStage::Draw); + auto left = state.removePlayer(0); + ASSERT_TRUE(left.ok()); + EXPECT_EQ(left->getPhase(), Phase::Abandoned); + EXPECT_EQ(left->getPlayers().size(), 1u); + EXPECT_EQ(left->getPlayer(0).id, "bob"); + EXPECT_FALSE(left->winner().has_value()); + EXPECT_EQ(left->getWhoseTurn(), GinState::kNoTurn); + EXPECT_FALSE(left->removePlayer(0).ok()); +} diff --git a/domains/games/libs/cards/rummy/table.cc b/domains/games/libs/cards/rummy/table.cc index 8dd23746..2b555bf6 100644 --- a/domains/games/libs/cards/rummy/table.cc +++ b/domains/games/libs/cards/rummy/table.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "absl/status/status.h" @@ -22,6 +23,10 @@ std::string_view variantName(Variant variant) { switch (variant) { case Variant::Basic: return "basic"; + case Variant::TenCard: + return "ten-card"; + case Variant::Gin: + return "gin"; } return "basic"; } @@ -29,24 +34,61 @@ std::string_view variantName(Variant variant) { std::string_view recordedName(Variant variant) { switch (variant) { case Variant::Basic: + case Variant::TenCard: return "rummy"; + case Variant::Gin: + return "gin"; } return "rummy"; } std::optional parseVariant(std::string_view name) { - if (name == "basic") return Variant::Basic; + for (const Variant variant : {Variant::Basic, Variant::TenCard, Variant::Gin}) { + if (name == variantName(variant)) return variant; + } return std::nullopt; } +const std::vector& dealPlayers(const Deal& deal) { + return std::visit([](const auto& d) -> const std::vector& { return d.getPlayers(); }, + deal); +} + +Phase dealPhase(const Deal& deal) { + return std::visit([](const auto& d) { return d.getPhase(); }, deal); +} + +std::optional dealWinner(const Deal& deal) { + return std::visit([](const auto& d) { return d.winner(); }, deal); +} + +int dealWinnerPoints(const Deal& deal) { + return std::visit([](const auto& d) { return d.winnerPoints(); }, deal); +} + +int dealDeadwood(const Deal& deal, int seat) { + return std::visit([seat](const auto& d) { return d.deadwood(seat); }, deal); +} + +int dealWhoseTurn(const Deal& deal) { + return std::visit([](const auto& d) { return d.getWhoseTurn(); }, deal); +} + std::vector variantsFor(int seats) { - if (seats < GameState::kMinPlayers || seats > GameState::kMaxPlayers) return {}; - return {Variant::Basic}; + switch (seats) { + case 2: + return {Variant::Basic, Variant::TenCard, Variant::Gin}; + case 3: + return {Variant::Basic, Variant::TenCard}; + case 4: + return {Variant::Basic}; + } + return {}; } TableState::TableState(std::vector _seats, std::vector _wins, int _dealer, int _dealNumber, TablePhase _phase, Variant _variant, - std::optional _deal, std::string _gameId, std::string _versionId) + std::optional _deal, std::string _gameId, std::string _versionId) : seats(std::move(_seats)), wins(std::move(_wins)), dealer(_dealer), @@ -87,27 +129,30 @@ StatusOr TableState::chooseVariant(int seat, Variant chosen, return InvalidArgumentError("that game does not fit this table"); } const int opener = (dealer + 1) % static_cast(seats.size()); - auto dealt = dealRummyGame(gameId, seats, std::move(shuffled_deck), opener); - if (!dealt.ok()) return dealt.status(); - return TableState{seats, - wins, - dealer, - dealNumber + 1, - TablePhase::Playing, - chosen, - dealt->withIdAndVersion(gameId, versionId), - gameId, - versionId}; + std::optional dealt; + if (chosen == Variant::Gin) { + auto gin = dealGin(gameId, seats, std::move(shuffled_deck), opener); + if (!gin.ok()) return gin.status(); + dealt.emplace(gin->withIdAndVersion(gameId, versionId)); + } else { + auto basic = dealRummyGame(gameId, seats, std::move(shuffled_deck), opener, + chosen == Variant::TenCard ? 10 : GameState::kHandSize); + if (!basic.ok()) return basic.status(); + dealt.emplace(basic->withIdAndVersion(gameId, versionId)); + } + return TableState{ + seats, wins, dealer, dealNumber + 1, TablePhase::Playing, chosen, std::move(dealt), + gameId, versionId}; } -TableState TableState::afterDeal(GameState next) const { - if (!next.isOver()) { +TableState TableState::afterDeal(Deal next) const { + if (dealPhase(next) == Phase::Playing) { return TableState{seats, wins, dealer, dealNumber, phase, variant, std::move(next), gameId, versionId}; } // Over by play: the hand goes to its winner and the deal passes on. std::vector newWins = wins; - if (const auto winner = next.winner(); winner.has_value()) { + if (const auto winner = dealWinner(next); winner.has_value()) { const int at = playerIndex(*winner); if (at >= 0) newWins.at(at)++; } @@ -121,9 +166,15 @@ StatusOr TableState::removePlayer(int seat) const { return InvalidArgumentError("no such player"); } if (isOver()) return FailedPreconditionError("table is closed"); - std::optional newDeal = deal; + std::optional newDeal = deal; if (phase == TablePhase::Playing && deal.has_value()) { - auto left = deal->removePlayer(seat); + absl::StatusOr left = std::visit( + [seat](const auto& d) -> absl::StatusOr { + auto next = d.removePlayer(seat); + if (!next.ok()) return next.status(); + return Deal(*std::move(next)); + }, + *deal); if (!left.ok()) return left.status(); newDeal.emplace(*std::move(left)); } @@ -174,8 +225,11 @@ int TableState::playerIndex(const std::string& id) const { TableState TableState::withIdAndVersion(const std::string& game_id, const std::string& version_id) const { - std::optional stamped; - if (deal.has_value()) stamped.emplace(deal->withIdAndVersion(game_id, version_id)); + std::optional stamped; + if (deal.has_value()) { + stamped.emplace(std::visit( + [&](const auto& d) { return Deal(d.withIdAndVersion(game_id, version_id)); }, *deal)); + } return TableState{seats, wins, dealer, dealNumber, phase, variant, stamped, game_id, version_id}; } diff --git a/domains/games/libs/cards/rummy/table.h b/domains/games/libs/cards/rummy/table.h index 855641b2..1b7a20e4 100644 --- a/domains/games/libs/cards/rummy/table.h +++ b/domains/games/libs/cards/rummy/table.h @@ -5,27 +5,45 @@ #include #include #include +#include #include #include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" #include "domains/games/libs/cards/card.h" #include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/gin.h" namespace rummy { -/// The games a rummy deal can be (#1609). Basic is GameState's rules; -/// gin (#1610) and rummy 500 (#1611) join here. -enum class Variant { Basic }; +/// The games a rummy deal can be (#1609). Basic is GameState's rules, +/// seven cards a seat; ten-card is the same game dealt ten; gin is +/// GinState's (#1610). Rummy 500 (#1611) joins here. +enum class Variant { Basic, TenCard, Gin }; /// The wire's word for a variant. [[nodiscard]] std::string_view variantName(Variant variant); /// The word a deal of this variant is recorded under as a game (#1571): -/// basic keeps "rummy", the word the game had before it had variants. +/// basic, at either hand size, keeps "rummy", the word the game had +/// before it had variants. [[nodiscard]] std::string_view recordedName(Variant variant); [[nodiscard]] std::optional parseVariant(std::string_view name); /// The variants a table of this many seats may deal. [[nodiscard]] std::vector variantsFor(int seats); +/// A deal of any variant: basic and ten-card play GameState, gin +/// GinState. +using Deal = std::variant; + +/// What any deal says of itself, whatever its game. +[[nodiscard]] const std::vector& dealPlayers(const Deal& deal); +[[nodiscard]] Phase dealPhase(const Deal& deal); +[[nodiscard]] std::optional dealWinner(const Deal& deal); +[[nodiscard]] int dealWinnerPoints(const Deal& deal); +[[nodiscard]] int dealDeadwood(const Deal& deal, int seat); +/// The seat on turn; kNoTurn once the deal is over. +[[nodiscard]] int dealWhoseTurn(const Deal& deal); + /// A rummy table (#1609): dealer's choice, one deal after another until /// the table breaks up. /// @@ -49,8 +67,8 @@ class TableState { const std::vector& seats); TableState(std::vector _seats, std::vector _wins, int _dealer, int _dealNumber, - TablePhase _phase, Variant _variant, std::optional _deal, - std::string _gameId, std::string _versionId); + TablePhase _phase, Variant _variant, std::optional _deal, std::string _gameId, + std::string _versionId); /// The next deal, from an already-shuffled deck. `seat` must be the /// dealer's, or any seat's when the dealer is away. @@ -58,16 +76,21 @@ class TableState { std::deque shuffled_deck, bool dealerAway = false) const; - /// A move in the deal in play; the table follows it, dealing on to - /// choosing when the deal ends. - template + /// A move in the deal in play, of `Engine`'s game; the table follows + /// it, dealing on to choosing when the deal ends. A move of another + /// game than the deal's is refused. + template [[nodiscard]] absl::StatusOr inDeal(const Move& move) const { if (phase != TablePhase::Playing || !deal.has_value()) { return absl::FailedPreconditionError("no deal in play"); } - absl::StatusOr next = move(*deal); + const Engine* engine = std::get_if(&*deal); + if (engine == nullptr) { + return absl::FailedPreconditionError(absl::StrCat("not a move in ", variantName(variant))); + } + absl::StatusOr next = move(*engine); if (!next.ok()) return next.status(); - return afterDeal(*std::move(next)); + return afterDeal(Deal(*std::move(next))); } /// A seat leaving, from the table and any deal in play. @@ -84,7 +107,14 @@ class TableState { /// The variant of the deal in play, or of the last one. [[nodiscard]] Variant getVariant() const { return variant; } /// The deal in play, or the one that just ended; absent before the first. - [[nodiscard]] const std::optional& getDeal() const { return deal; } + [[nodiscard]] const std::optional& getDeal() const { return deal; } + /// The deal as basic's or gin's engine, if it is that game's. + [[nodiscard]] const GameState* basicDeal() const { + return deal.has_value() ? std::get_if(&*deal) : nullptr; + } + [[nodiscard]] const GinState* ginDeal() const { + return deal.has_value() ? std::get_if(&*deal) : nullptr; + } [[nodiscard]] int playerIndex(const std::string& id) const; [[nodiscard]] const std::string& getGameId() const { return gameId; } [[nodiscard]] const std::string& getVersionId() const { return versionId; } @@ -92,7 +122,7 @@ class TableState { const std::string& version_id) const; private: - [[nodiscard]] TableState afterDeal(GameState next) const; + [[nodiscard]] TableState afterDeal(Deal next) const; std::vector seats; std::vector wins; @@ -100,7 +130,7 @@ class TableState { int dealNumber; TablePhase phase; Variant variant; - std::optional deal; + std::optional deal; std::string gameId; std::string versionId; }; diff --git a/domains/games/libs/cards/rummy/table_serde.cc b/domains/games/libs/cards/rummy/table_serde.cc index 61b5f4ad..994ea612 100644 --- a/domains/games/libs/cards/rummy/table_serde.cc +++ b/domains/games/libs/cards/rummy/table_serde.cc @@ -10,6 +10,7 @@ #include "absl/strings/str_cat.h" #include "domains/games/libs/cards/rummy/game_state.h" #include "domains/games/libs/cards/rummy/game_state_serde.h" +#include "domains/games/libs/cards/rummy/gin_serde.h" namespace rummy { namespace { @@ -97,8 +98,11 @@ std::string serializeTableState(const TableState& table) { {"dealNumber", table.getDealNumber()}, {"variant", std::string(variantName(table.getVariant()))}, }; - if (const auto& deal = table.getDeal(); deal.has_value()) { - serialized["deal"] = json::parse(serializeGameState(*deal)); + // The deal in its own engine's form; the variant says which. + if (const GameState* basic = table.basicDeal(); basic != nullptr) { + serialized["deal"] = json::parse(serializeGameState(*basic)); + } else if (const GinState* gin = table.ginDeal(); gin != nullptr) { + serialized["deal"] = json::parse(serializeGinState(*gin)); } return serialized.dump(/*indent=*/-1, /*indent_char=*/' ', /*ensure_ascii=*/false, json::error_handler_t::replace); @@ -160,26 +164,32 @@ absl::StatusOr deserializeTableState(const std::string& serialized) auto deal_number = readIntInRange(parsed, "dealNumber", 0, 1'000'000); if (!deal_number.ok()) return deal_number.status(); - std::optional deal; + std::optional deal; if (parsed.contains("deal")) { if (!parsed["deal"].is_object()) return absl::InvalidArgumentError("expected object 'deal'"); - auto read = deserializeGameState(parsed["deal"].dump()); - if (!read.ok()) return read.status(); - deal.emplace(*std::move(read)); + if (*variant == Variant::Gin) { + auto read = deserializeGinState(parsed["deal"].dump()); + if (!read.ok()) return read.status(); + deal.emplace(*std::move(read)); + } else { + auto read = deserializeGameState(parsed["deal"].dump()); + if (!read.ok()) return read.status(); + deal.emplace(*std::move(read)); + } } // A deal is there exactly when one has been dealt. if (deal.has_value() != (*deal_number > 0)) { return absl::InvalidArgumentError("the deal count disagrees with the deal"); } if (*phase == TablePhase::Playing) { - if (!deal.has_value() || deal->isOver()) { + if (!deal.has_value() || dealPhase(*deal) != Phase::Playing) { return absl::InvalidArgumentError("a playing table has a deal in play"); } std::vector dealt; - for (const Player& player : deal->getPlayers()) dealt.push_back(player.id); + for (const Player& player : dealPlayers(*deal)) dealt.push_back(player.id); if (dealt != seats) return absl::InvalidArgumentError("the deal seats the table"); } - if (*phase == TablePhase::Choosing && deal.has_value() && !deal->isOver()) { + if (*phase == TablePhase::Choosing && deal.has_value() && dealPhase(*deal) == Phase::Playing) { return absl::InvalidArgumentError("between deals, the last deal is over"); } diff --git a/domains/games/libs/cards/rummy/table_serde_test.cc b/domains/games/libs/cards/rummy/table_serde_test.cc index 3af71dc9..d22bee5b 100644 --- a/domains/games/libs/cards/rummy/table_serde_test.cc +++ b/domains/games/libs/cards/rummy/table_serde_test.cc @@ -10,6 +10,7 @@ #include "domains/games/libs/cards/card.h" #include "domains/games/libs/cards/rummy/game_state.h" #include "domains/games/libs/cards/rummy/game_state_serde.h" +#include "domains/games/libs/cards/rummy/gin_serde.h" #include "domains/games/libs/cards/rummy/table.h" using namespace cards; @@ -70,14 +71,41 @@ void expectRoundTrips(const TableState& table) { EXPECT_EQ(restored->getPhase(), table.getPhase()); EXPECT_EQ(restored->getVariant(), table.getVariant()); ASSERT_EQ(restored->getDeal().has_value(), table.getDeal().has_value()); - if (table.getDeal().has_value()) { - EXPECT_EQ(serializeGameState(*restored->getDeal()), serializeGameState(*table.getDeal())); + if (table.basicDeal() != nullptr) { + ASSERT_NE(restored->basicDeal(), nullptr); + EXPECT_EQ(serializeGameState(*restored->basicDeal()), serializeGameState(*table.basicDeal())); + } + if (table.ginDeal() != nullptr) { + ASSERT_NE(restored->ginDeal(), nullptr); + EXPECT_EQ(serializeGinState(*restored->ginDeal()), serializeGinState(*table.ginDeal())); } EXPECT_EQ(serializeTableState(*restored), serialized); } } // namespace +// Each variant's deal is stored in its own engine's form: ten-card as +// basic's, gin as gin's. +TEST(TableSerde, EveryVariantsDealRoundTrips) { + auto ten = opened().chooseVariant(0, Variant::TenCard, pristineDeck()); + ASSERT_TRUE(ten.ok()); + expectRoundTrips(*ten); + auto gin = opened().chooseVariant(0, Variant::Gin, pristineDeck()); + ASSERT_TRUE(gin.ok()) << gin.status(); + expectRoundTrips(*gin); + const json payload = payloadOf(*gin); + EXPECT_EQ(payload["variant"], "gin"); + EXPECT_EQ(payload["deal"]["stage"], "upcard"); + + // A deal in another game's form than the table's variant is no table. + json basicAsGin = payloadOf(playing()); + basicAsGin["variant"] = "gin"; + expectRejected(basicAsGin); + json ginAsBasic = payload; + ginAsBasic["variant"] = "basic"; + expectRejected(ginAsBasic); +} + TEST(TableSerde, EveryPhaseRoundTrips) { expectRoundTrips(opened()); expectRoundTrips(playing()); @@ -121,7 +149,7 @@ TEST(TableSerde, AVersionOneRowIsOneDeal) { EXPECT_EQ(table->getDealNumber(), 1); EXPECT_EQ(table->getDealer(), 2); EXPECT_EQ(table->getVariant(), Variant::Basic); - EXPECT_EQ(serializeGameState(*table->getDeal()), serializeGameState(*drew)); + EXPECT_EQ(serializeGameState(*table->basicDeal()), serializeGameState(*drew)); auto gone = drew->removePlayer(1); ASSERT_TRUE(gone.ok()); diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 58dcb5bc..0edc21f6 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -10,6 +10,7 @@ #include "domains/games/libs/cards/card.h" #include "domains/games/libs/cards/dealer.h" #include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/gin.h" using namespace cards; using namespace rummy; @@ -36,15 +37,112 @@ TableState withDeal(const TableState& table, GameState deal) { } // namespace TEST(Variants, NamesRoundTripAndFitTheSeats) { + for (const Variant variant : {Variant::Basic, Variant::TenCard, Variant::Gin}) { + EXPECT_EQ(parseVariant(variantName(variant)), variant); + } EXPECT_EQ(variantName(Variant::Basic), "basic"); - EXPECT_EQ(parseVariant("basic"), Variant::Basic); - EXPECT_FALSE(parseVariant("gin").has_value()); + EXPECT_EQ(variantName(Variant::TenCard), "ten-card"); + EXPECT_EQ(variantName(Variant::Gin), "gin"); + EXPECT_FALSE(parseVariant("rummy500").has_value()); EXPECT_FALSE(parseVariant("").has_value()); - for (int seats : {2, 3, 4}) EXPECT_EQ(variantsFor(seats), vector{Variant::Basic}); - // Stats record basic rummy as the game it always was. - EXPECT_EQ(recordedName(Variant::Basic), "rummy"); + // Ten cards a seat leaves too thin a stock at four; gin is for two. + EXPECT_EQ(variantsFor(2), (vector{Variant::Basic, Variant::TenCard, Variant::Gin})); + EXPECT_EQ(variantsFor(3), (vector{Variant::Basic, Variant::TenCard})); + EXPECT_EQ(variantsFor(4), vector{Variant::Basic}); EXPECT_TRUE(variantsFor(1).empty()); EXPECT_TRUE(variantsFor(5).empty()); + // Stats record basic rummy, at either hand size, as the game it always + // was; gin is a game of its own. + EXPECT_EQ(recordedName(Variant::Basic), "rummy"); + EXPECT_EQ(recordedName(Variant::TenCard), "rummy"); + EXPECT_EQ(recordedName(Variant::Gin), "gin"); +} + +TEST(Variants, EachDealsItsOwnGame) { + auto ten = opened().chooseVariant(0, Variant::TenCard, deck()); + ASSERT_TRUE(ten.ok()) << ten.status(); + EXPECT_EQ(ten->getVariant(), Variant::TenCard); + ASSERT_NE(ten->basicDeal(), nullptr); + for (const Player& seat : ten->basicDeal()->getPlayers()) EXPECT_EQ(seat.hand.size(), 10u); + + auto gin = opened({"a", "b"}).chooseVariant(0, Variant::Gin, deck()); + ASSERT_TRUE(gin.ok()) << gin.status(); + EXPECT_EQ(gin->basicDeal(), nullptr); + ASSERT_NE(gin->ginDeal(), nullptr); + EXPECT_EQ(gin->ginDeal()->getStage(), GinStage::Upcard); + // The seat after the dealer has the upcard first. + EXPECT_EQ(gin->ginDeal()->getWhoseTurn(), 1); + EXPECT_EQ(dealWhoseTurn(*gin->getDeal()), 1); + EXPECT_EQ(dealPlayers(*gin->getDeal()).at(0).hand.size(), 10u); + + const absl::Status unfit = opened().chooseVariant(0, Variant::Gin, deck()).status(); + EXPECT_EQ(unfit.code(), absl::StatusCode::kInvalidArgument); + EXPECT_EQ(unfit.message(), "that game does not fit this table"); +} + +TEST(Variants, AMoveOfTheWrongGameIsRefused) { + auto gin = opened({"a", "b"}).chooseVariant(0, Variant::Gin, deck()); + ASSERT_TRUE(gin.ok()); + const absl::Status refused = + gin->inDeal([](const GameState& deal) { return deal.drawStock(1); }).status(); + EXPECT_EQ(refused.code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(refused.message(), "not a move in gin"); + auto passed = gin->inDeal([](const GinState& deal) { return deal.pass(1); }); + ASSERT_TRUE(passed.ok()) << passed.status(); + EXPECT_EQ(passed->ginDeal()->getWhoseTurn(), 0); +} + +// A gin deal ends like any other: its winner takes the hand, the deal +// passes on. A drawn one credits nobody and still passes on. +TEST(Variants, AGinDealsEndPassesTheDealOn) { + const Card nine{Suit::Diamonds, Rank::Nine}; + const vector knocker = {Card{Suit::Hearts, Rank::Three}, + Card{Suit::Hearts, Rank::Four}, + Card{Suit::Hearts, Rank::Five}, + Card{Suit::Clubs, Rank::Seven}, + Card{Suit::Diamonds, Rank::Seven}, + Card{Suit::Spades, Rank::Seven}, + Card{Suit::Spades, Rank::Jack}, + Card{Suit::Spades, Rank::Queen}, + Card{Suit::Spades, Rank::King}, + Card{Suit::Diamonds, Rank::Two}, + nine}; + std::deque stock; + // Two left, as after her draw: her discard draws the deal. + for (int i = 0; i < 2; i++) stock.emplace_back(i); + const GinState deal{stock, + {}, + {{"a", knocker}, {"b", {Card{Suit::Clubs, Rank::King}}}}, + 0, + GinStage::Play, + Phase::Playing, + 0, + std::nullopt, + std::nullopt, + std::nullopt, + "T1", + ""}; + const TableState table{{"a", "b"}, {0, 0}, 1, 4, TablePhase::Playing, + Variant::Gin, deal, "T1", ""}; + + auto knocked = table.inDeal([&](const GinState& d) { return d.knock(0, nine); }); + ASSERT_TRUE(knocked.ok()) << knocked.status(); + EXPECT_EQ(knocked->getPhase(), TablePhase::Choosing); + EXPECT_EQ(knocked->getWins(), (vector{1, 0})); + EXPECT_EQ(knocked->getDealer(), 0); + EXPECT_EQ(dealWinner(*knocked->getDeal()), "a"); + + auto drawn = table.inDeal([&](const GinState& d) { return d.discard(0, nine); }); + ASSERT_TRUE(drawn.ok()) << drawn.status(); + EXPECT_EQ(drawn->getPhase(), TablePhase::Choosing); + EXPECT_EQ(drawn->getWins(), (vector{0, 0})); + EXPECT_EQ(drawn->getDealer(), 0); + EXPECT_FALSE(dealWinner(*drawn->getDeal()).has_value()); + + auto left = table.removePlayer(1); + ASSERT_TRUE(left.ok()) << left.status(); + EXPECT_TRUE(left->isOver()); + EXPECT_EQ(dealPhase(*left->getDeal()), Phase::Abandoned); } TEST(Table, OpensChoosingWithTheFirstSeatDealingAndNoDeal) { @@ -70,10 +168,10 @@ TEST(Table, TheDealerChoosesAndTheSeatAfterOpens) { EXPECT_EQ(dealt->getDealNumber(), 1); EXPECT_EQ(dealt->getVariant(), Variant::Basic); ASSERT_TRUE(dealt->getDeal().has_value()); - EXPECT_EQ(dealt->getDeal()->getWhoseTurn(), 1); - EXPECT_EQ(dealt->getDeal()->getStage(), Stage::Draw); - EXPECT_EQ(dealt->getDeal()->getPlayer(0).hand.size(), 7u); - EXPECT_EQ(dealt->getDeal()->getGameId(), "T1"); + EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 1); + EXPECT_EQ(dealt->basicDeal()->getStage(), Stage::Draw); + EXPECT_EQ(dealt->basicDeal()->getPlayer(0).hand.size(), 7u); + EXPECT_EQ(dealt->basicDeal()->getGameId(), "T1"); } TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { @@ -84,7 +182,7 @@ TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { ASSERT_TRUE(away.ok()) << away.status(); // The dealer stays the dealer: the seat after them still opens. EXPECT_EQ(away->getDealer(), 0); - EXPECT_EQ(away->getDeal()->getWhoseTurn(), 1); + EXPECT_EQ(away->basicDeal()->getWhoseTurn(), 1); EXPECT_EQ(table.chooseVariant(7, Variant::Basic, deck(), true).status().code(), absl::StatusCode::kInvalidArgument); } @@ -99,17 +197,19 @@ TEST(Table, NoChoosingWhileADealIsInPlay) { TEST(Table, DealMovesGoToTheDealAndNowhereElse) { const TableState table = opened(); - EXPECT_EQ(table.inDeal([](const GameState& d) { return d.drawStock(1); }).status().code(), - absl::StatusCode::kFailedPrecondition); + EXPECT_EQ( + table.inDeal([](const GameState& d) { return d.drawStock(1); }).status().code(), + absl::StatusCode::kFailedPrecondition); auto dealt = table.chooseVariant(0, Variant::Basic, deck()); ASSERT_TRUE(dealt.ok()); - auto drew = dealt->inDeal([](const GameState& d) { return d.drawStock(1); }); + auto drew = dealt->inDeal([](const GameState& d) { return d.drawStock(1); }); ASSERT_TRUE(drew.ok()) << drew.status(); - EXPECT_EQ(drew->getDeal()->getStage(), Stage::Play); + EXPECT_EQ(drew->basicDeal()->getStage(), Stage::Play); EXPECT_EQ(drew->getPhase(), TablePhase::Playing); // The deal's refusal is the table's. - EXPECT_EQ(dealt->inDeal([](const GameState& d) { return d.drawStock(0); }).status().code(), - absl::StatusCode::kFailedPrecondition); + EXPECT_EQ( + dealt->inDeal([](const GameState& d) { return d.drawStock(0); }).status().code(), + absl::StatusCode::kFailedPrecondition); } TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { @@ -127,7 +227,7 @@ TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { std::nullopt, "T1", ""}; - auto out = withDeal(table, deal).inDeal([](const GameState& d) { + auto out = withDeal(table, deal).inDeal([](const GameState& d) { return d.discard(1, Card{Suit::Clubs, Rank::Five}); }); ASSERT_TRUE(out.ok()) << out.status(); @@ -136,13 +236,13 @@ TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { EXPECT_EQ(out->getDealer(), 1); // The finished deal stays, to show its hands and result. ASSERT_TRUE(out->getDeal().has_value()); - EXPECT_EQ(out->getDeal()->winner(), "b"); + EXPECT_EQ(out->basicDeal()->winner(), "b"); EXPECT_FALSE(out->isOver()); // The next dealer is b; the seat after b opens. auto next = out->chooseVariant(1, Variant::Basic, deck()); ASSERT_TRUE(next.ok()) << next.status(); EXPECT_EQ(next->getDealNumber(), out->getDealNumber() + 1); - EXPECT_EQ(next->getDeal()->getWhoseTurn(), 2); + EXPECT_EQ(next->basicDeal()->getWhoseTurn(), 2); EXPECT_EQ(next->getWins(), (vector{0, 1, 0})); } @@ -151,7 +251,7 @@ TEST(Table, TheDealWrapsRoundTheTable) { Variant::Basic, std::nullopt, "T", ""}; auto dealt = last.chooseVariant(1, Variant::Basic, deck()); ASSERT_TRUE(dealt.ok()); - EXPECT_EQ(dealt->getDeal()->getWhoseTurn(), 0); + EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 0); } TEST(Table, ALeaveWhileChoosingCompactsTheSeatsAndTheDealerFollows) { @@ -189,7 +289,7 @@ TEST(Table, ALeaveMidDealLeavesTheDealToo) { ASSERT_TRUE(left.ok()) << left.status(); EXPECT_EQ(left->getPhase(), TablePhase::Playing); EXPECT_EQ(left->getSeats(), (vector{"a", "b"})); - EXPECT_EQ(left->getDeal()->getPlayers().size(), 2u); + EXPECT_EQ(left->basicDeal()->getPlayers().size(), 2u); EXPECT_EQ(left->getWins().size(), 2u); } @@ -213,8 +313,8 @@ TEST(Table, TheDealerLeavingMidDealPassesTheNextDealToTheSeatAfter) { auto left = withDeal(opened(), deal).removePlayer(0); ASSERT_TRUE(left.ok()) << left.status(); ASSERT_EQ(left->getPhase(), TablePhase::Playing); - auto out = - left->inDeal([](const GameState& d) { return d.discard(0, Card{Suit::Clubs, Rank::Five}); }); + auto out = left->inDeal( + [](const GameState& d) { return d.discard(0, Card{Suit::Clubs, Rank::Five}); }); ASSERT_TRUE(out.ok()) << out.status(); ASSERT_EQ(out->getPhase(), TablePhase::Choosing); EXPECT_EQ(out->getSeats().at(out->getDealer()), "b"); @@ -242,8 +342,8 @@ TEST(Table, TheDealerLeavingMidDealPassesTheNextDealToTheSeatAfter) { ""}; auto gone = last_dealt.removePlayer(2); ASSERT_TRUE(gone.ok()) << gone.status(); - auto ended = - gone->inDeal([](const GameState& d) { return d.discard(0, Card{Suit::Clubs, Rank::Four}); }); + auto ended = gone->inDeal( + [](const GameState& d) { return d.discard(0, Card{Suit::Clubs, Rank::Four}); }); ASSERT_TRUE(ended.ok()) << ended.status(); EXPECT_EQ(ended->getSeats().at(ended->getDealer()), "a"); } @@ -254,7 +354,7 @@ TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { auto left = dealt->removePlayer(0); ASSERT_TRUE(left.ok()); EXPECT_TRUE(left->isOver()); - EXPECT_EQ(left->getDeal()->getPhase(), Phase::Abandoned); + EXPECT_EQ(left->basicDeal()->getPhase(), Phase::Abandoned); auto choosing_left = opened({"a", "b"}).removePlayer(1); ASSERT_TRUE(choosing_left.ok()); EXPECT_TRUE(choosing_left->isOver()); @@ -272,7 +372,7 @@ TEST(Table, SeatsAreFoundByIdAndTheIdsAreTheRowsToSet) { const TableState stamped = dealt->withIdAndVersion("G", "V"); EXPECT_EQ(stamped.getGameId(), "G"); EXPECT_EQ(stamped.getVersionId(), "V"); - EXPECT_EQ(stamped.getDeal()->getGameId(), "G"); + EXPECT_EQ(stamped.basicDeal()->getGameId(), "G"); } TEST(Deal, OpensAtTheSeatNamed) { From 23f0a2082705781393fd27d98e04d83daca90c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 03:25:59 +0000 Subject: [PATCH 3/7] games_hub: gin and 10-card deals; basic becomes 7-card --- domains/ai/apis/deja/src/hub.rs | 2 +- domains/games/apis/games_hub/README.md | 10 +- domains/games/apis/games_hub/game_events.cc | 4 +- .../games/apis/games_hub/game_events_test.cc | 9 +- domains/games/apis/games_hub/golf_hub.cc | 261 +++++++++++++----- domains/games/apis/games_hub/hub_e2e_test.cc | 4 +- .../games/apis/games_hub/model/rummy.smithy | 79 +++++- .../games/apis/games_hub/pg_hub_e2e_test.cc | 2 +- .../games/apis/games_hub/pg_hub_store_test.cc | 5 +- .../games/apis/games_hub/rummy_e2e_test.cc | 24 +- .../games/apis/games_hub/rummy_wire_test.cc | 188 ++++++++++--- .../apis/games_hub/stream_test_fixture.h | 4 +- domains/games/libs/cards/rummy/BUILD.bazel | 4 + domains/games/libs/cards/rummy/README.md | 17 +- domains/games/libs/cards/rummy/table.cc | 20 +- domains/games/libs/cards/rummy/table.h | 10 +- domains/games/libs/cards/rummy/table_serde.cc | 6 +- domains/games/libs/cards/rummy/table_serde.h | 2 +- .../libs/cards/rummy/table_serde_test.cc | 27 +- domains/games/libs/cards/rummy/table_test.cc | 63 ++--- domains/platform/apis/stats/games.go | 2 +- .../platform/libs/otel_contract/BUILD.bazel | 1 + .../hub_event_vocabulary_test.go | 52 ++-- 23 files changed, 576 insertions(+), 220 deletions(-) diff --git a/domains/ai/apis/deja/src/hub.rs b/domains/ai/apis/deja/src/hub.rs index aa1d381d..89f2a417 100644 --- a/domains/ai/apis/deja/src/hub.rs +++ b/domains/ai/apis/deja/src/hub.rs @@ -54,7 +54,7 @@ const EVENTS: [&str; 7] = [ "game_started", "game_finished", ]; -const VARIANTS: [&str; 3] = ["golf", "castle", "rummy"]; +const VARIANTS: [&str; 4] = ["golf", "castle", "rummy", "gin"]; const SURFACES: [&str; 3] = ["plane", "sphere", "glasshouse"]; const OUTCOMES: [&str; 2] = ["completed", "abandoned"]; /// The seats the engine deals a table, from golf_hub's kMaxSeats. diff --git a/domains/games/apis/games_hub/README.md b/domains/games/apis/games_hub/README.md index 2db6dca4..e4393dc8 100644 --- a/domains/games/apis/games_hub/README.md +++ b/domains/games/apis/games_hub/README.md @@ -354,8 +354,14 @@ engine's `GameState`. A rummy table is dealer's choice (#1609, `libs/cards/rummy`'s `TableState`): `startGame` seats it between deals, the dealer's `chooseVariant` deals, and each deal's end passes the deal to the next -seat. A dealer the room shows as not connected lets any seat deal. Each -deal is a game to the room's stats and the event log: its end credits the +seat. The variants are `7-card` and `10-card` rummy (one engine, dealt +seven or ten) and heads-up `gin` (`GinState`, #1610); the view's +`choosing.options` lists the ones that fit the seats. Gin's reckoning — +both hands arranged, the defender's lay-offs, the ending — rides +`lastDeal.gin`. Tables stored before the variants had names read +`basic` as `7-card`. A dealer the room shows as not connected lets any seat deal. Each +deal is a game to the room's stats and the event log, recorded as +`rummy` or `gin`: its end credits the seat that went out with a win and every seat with a game played. Its points (what the others still held) ride the view's `lastDeal` and stay out of the room's running total, which is golf's lower-is-better scale. diff --git a/domains/games/apis/games_hub/game_events.cc b/domains/games/apis/games_hub/game_events.cc index 054bb4dc..b784b559 100644 --- a/domains/games/apis/games_hub/game_events.cc +++ b/domains/games/apis/games_hub/game_events.cc @@ -53,13 +53,13 @@ std::optional FinishedOf(const HostedState& state, std::size_t pla if (const auto* table = std::get_if(&state)) { const auto& deal = table->getDeal(); if (!deal.has_value()) return std::nullopt; - if (deal->getPhase() == rummy::Phase::Abandoned) { + if (rummy::dealPhase(*deal) == rummy::Phase::Abandoned) { finished.outcome = kOutcomeAbandoned; return finished; } // Won by play, and the table dealt on to choosing: the deal's end. A // closed table's finished deal was recorded when it finished. - if (deal->getPhase() == rummy::Phase::Over && + if (rummy::dealPhase(*deal) == rummy::Phase::Over && table->getPhase() == rummy::TablePhase::Choosing) { return finished; } diff --git a/domains/games/apis/games_hub/game_events_test.cc b/domains/games/apis/games_hub/game_events_test.cc index 4d01fcd9..ab1c07d1 100644 --- a/domains/games/apis/games_hub/game_events_test.cc +++ b/domains/games/apis/games_hub/game_events_test.cc @@ -74,12 +74,13 @@ rummy::TableState Rummy() { std::nullopt, "game", "v0"}; - return rummy::TableState{{"andy", "mercy"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, - rummy::Variant::Basic, deal, "game", "v0"}; + return rummy::TableState{ + {"andy", "mercy"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, + rummy::Variant::SevenCard, deal, "game", "v0"}; } rummy::TableState RummyDealWon() { - auto won = Rummy().inDeal( + auto won = Rummy().inDeal( [](const rummy::GameState& deal) { return deal.discard(0, Card{Suit::Clubs, Rank::Five}); }); EXPECT_TRUE(won.ok()) << won.status(); return *won; @@ -132,7 +133,7 @@ TEST(GameEvents, ACastleGameLeftBelowTwoSeatsIsAbandoned) { } // A rummy table's games are its deals (#1609): a deal won by play is a -// game completed, under the deal's variant word — basic rummy is "rummy". +// game completed, under the deal's variant word — seven-card rummy is "rummy". TEST(GameEvents, ARummyDealPlayedOutIsCompleted) { const rummy::TableState table = RummyDealWon(); ASSERT_EQ(table.getPhase(), rummy::TablePhase::Choosing); diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index fdd5f5dd..86cf2fac 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -194,6 +194,49 @@ std::string RummyMoveName(rummy::MoveKind kind) { return "drawStock"; } +std::string GinMoveName(rummy::GinMoveKind kind) { + switch (kind) { + case rummy::GinMoveKind::Pass: + return "pass"; + case rummy::GinMoveKind::DrawStock: + return "drawStock"; + case rummy::GinMoveKind::DrawDiscard: + return "drawDiscard"; + case rummy::GinMoveKind::Discard: + return "discard"; + case rummy::GinMoveKind::Knock: + return "knock"; + } + return "pass"; +} + +std::string GinStageName(rummy::GinStage stage) { + switch (stage) { + case rummy::GinStage::Upcard: + return "upcard"; + case rummy::GinStage::StockOnly: + case rummy::GinStage::Draw: + return "draw"; + case rummy::GinStage::Play: + return "play"; + } + return "draw"; +} + +std::string GinEndingName(rummy::GinEnding ending) { + switch (ending) { + case rummy::GinEnding::Knock: + return "knock"; + case rummy::GinEnding::Gin: + return "gin"; + case rummy::GinEnding::Undercut: + return "undercut"; + case rummy::GinEnding::Draw: + return "draw"; + } + return "draw"; +} + std::vector WireCards(const std::vector& cards) { std::vector wire; wire.reserve(cards.size()); @@ -232,8 +275,8 @@ std::string CurrentTurnOf(const HostedState& state) { if (const auto* table = std::get_if(&state)) { // Between deals nobody has a turn: the dealer's choice is not one. if (table->getPhase() != rummy::TablePhase::Playing) return ""; - const int seat = table->getDeal()->getWhoseTurn(); - return seat < 0 ? "" : table->getDeal()->getPlayer(seat).id; + const int seat = rummy::dealWhoseTurn(*table->getDeal()); + return seat < 0 ? "" : rummy::dealPlayers(*table->getDeal()).at(seat).id; } const auto& castle_state = std::get(state); return CastlePlayerIdAt(castle_state, castle_state.getWhoseTurn()); @@ -291,10 +334,10 @@ std::vector CastleStatsDeltas(const castle::Gam // the winner where golf's count against every seat, so adding them to the // room's running total would mix two scales; the points ride the view's // lastDeal instead. -std::vector RummyStatsDeltas(const rummy::GameState& state) { - const std::string winner = state.winner().value_or(""); +std::vector RummyStatsDeltas(const rummy::Deal& deal) { + const std::string winner = rummy::dealWinner(deal).value_or(""); std::vector deltas; - for (const rummy::Player& seat : state.getPlayers()) { + for (const rummy::Player& seat : rummy::dealPlayers(deal)) { games_hub::HubStore::StatsDelta delta; delta.player_id = seat.id; delta.played = 1; @@ -313,7 +356,7 @@ std::vector StatsDeltasOf(const HostedState& st // A table closes by a leave. A deal it broke up was played by those // still dealt in; one that had already ended was credited as it did. const auto& deal = table->getDeal(); - if (deal.has_value() && deal->getPhase() == rummy::Phase::Abandoned) { + if (deal.has_value() && rummy::dealPhase(*deal) == rummy::Phase::Abandoned) { return RummyStatsDeltas(*deal); } return {}; @@ -438,11 +481,13 @@ const std::vector& GolfHub::DeclaredCounterSeries() { {"rummy_commands", {{"command", "startGame"}}}, {"rummy_commands", {{"command", "leaveGame"}}}, {"rummy_commands", {{"command", "chooseVariant"}}}, + {"rummy_commands", {{"command", "pass"}}}, {"rummy_commands", {{"command", "drawStock"}}}, {"rummy_commands", {{"command", "drawDiscard"}}}, {"rummy_commands", {{"command", "meld"}}}, {"rummy_commands", {{"command", "layOff"}}}, {"rummy_commands", {{"command", "discard"}}}, + {"rummy_commands", {{"command", "knock"}}}, {"rummy_events", {{"event", "gameJoined"}}}, {"rummy_events", {{"event", "gameState"}}}, {"rummy_events", {{"event", "gameCreated"}}}, @@ -1998,36 +2043,42 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m void GolfHub::HandleRummyMove(const std::string& player_id, const RummyMove& move) { if (LifecycleMove(player_id, move, GameKind::kRummy)) return; using Next = absl::StatusOr; - using DealNext = absl::StatusOr; + using Cards = std::vector; // A move in the deal in play, by the mover's seat — the table's and the - // deal's seats are the same while a deal is on. - using InDeal = std::function; - const auto deal_move = [&](InDeal in_deal) { - TableEngineMove( - player_id, GameKind::kRummy, - [in_deal = std::move(in_deal)](const rummy::TableState& table, int seat) -> Next { - return table.inDeal([&](const rummy::GameState& deal) { return in_deal(deal, seat); }); - }); - }; - using Laying = std::function& cards)>; - const auto lay = [&](const std::vector& wire, Laying laying) { + // deal's seats are the same while a deal is on — as each game makes it. + // A game without the move has no step: a basic-only move at a gin table + // is refused, and the other way round. + using BasicStep = + std::function(const rummy::GameState&, int, const Cards&)>; + using GinStep = + std::function(const rummy::GinState&, int, const Cards&)>; + const auto step = [&](const std::vector& wire, BasicStep basic, + GinStep gin) { auto named = CardsFromWire(wire); if (!named.ok()) { Reject(player_id, RejectKind::kInvalid, std::string(named.status().message())); return; } - deal_move([cards = *std::move(named), laying = std::move(laying)](const rummy::GameState& deal, - int seat) -> DealNext { - // The engine's turn and stage come first; only a move it would make - // but for a card the hand lacks is stale, and that one is named in - // the wire's spelling. - DealNext next = laying(deal, seat, cards); - if (next.status().code() == absl::StatusCode::kNotFound) { - return HeldInHand(deal.getPlayer(seat).hand, cards); - } - return next; - }); + TableEngineMove( + player_id, GameKind::kRummy, + [cards = *std::move(named), basic = std::move(basic), gin = std::move(gin)]( + const rummy::TableState& table, int seat) -> Next { + // The engine's turn and stage come first; only a move it would + // make but for a card the hand lacks is stale, and that one is + // named in the wire's spelling. + const auto held = [&](const auto& deal, auto next) -> decltype(next) { + if (next.status().code() == absl::StatusCode::kNotFound) { + return HeldInHand(deal.getPlayer(seat).hand, cards); + } + return next; + }; + if (table.ginDeal() != nullptr ? gin != nullptr : basic == nullptr) { + return table.inDeal( + [&](const rummy::GinState& deal) { return held(deal, gin(deal, seat, cards)); }); + } + return table.inDeal( + [&](const rummy::GameState& deal) { return held(deal, basic(deal, seat, cards)); }); + }); }; if (const auto* choose = move.as_chooseVariant_or_null()) { @@ -2052,33 +2103,60 @@ void GolfHub::HandleRummyMove(const std::string& player_id, const RummyMove& mov }); return; } + if (move.as_pass_or_null() != nullptr) { + step({}, nullptr, + [](const rummy::GinState& deal, int seat, const Cards&) { return deal.pass(seat); }); + return; + } if (move.as_drawStock_or_null() != nullptr) { - deal_move([](const rummy::GameState& deal, int seat) { return deal.drawStock(seat); }); + step( + {}, + [](const rummy::GameState& deal, int seat, const Cards&) { return deal.drawStock(seat); }, + [](const rummy::GinState& deal, int seat, const Cards&) { return deal.drawStock(seat); }); return; } if (move.as_drawDiscard_or_null() != nullptr) { - deal_move([](const rummy::GameState& deal, int seat) { return deal.drawDiscard(seat); }); + step( + {}, + [](const rummy::GameState& deal, int seat, const Cards&) { return deal.drawDiscard(seat); }, + [](const rummy::GinState& deal, int seat, const Cards&) { return deal.drawDiscard(seat); }); return; } if (const auto* meld = move.as_meld_or_null()) { - lay(meld->cards, [](const rummy::GameState& deal, int seat, - const std::vector& cards) { return deal.meld(seat, cards); }); + step( + meld->cards, + [](const rummy::GameState& deal, int seat, const Cards& cards) { + return deal.meld(seat, cards); + }, + nullptr); return; } if (const auto* lay_off = move.as_layOff_or_null()) { - lay({lay_off->card}, [index = lay_off->meldIndex](const rummy::GameState& deal, int seat, - const std::vector& cards) { - return deal.layOff(seat, cards.front(), index); - }); + step( + {lay_off->card}, + [index = lay_off->meldIndex](const rummy::GameState& deal, int seat, const Cards& cards) { + return deal.layOff(seat, cards.front(), index); + }, + nullptr); return; } if (const auto* discard = move.as_discard_or_null()) { - lay({discard->card}, - [](const rummy::GameState& deal, int seat, const std::vector& cards) { + step( + {discard->card}, + [](const rummy::GameState& deal, int seat, const Cards& cards) { + return deal.discard(seat, cards.front()); + }, + [](const rummy::GinState& deal, int seat, const Cards& cards) { return deal.discard(seat, cards.front()); }); return; } + if (const auto* knock = move.as_knock_or_null()) { + step({knock->card}, nullptr, [](const rummy::GinState& deal, int seat, const Cards& cards) { + return deal.knock(seat, cards.front()); + }); + return; + } Reject(player_id, RejectKind::kUnknown, "unknown move"); } @@ -3147,56 +3225,97 @@ moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, view.choosing = std::move(choosing); } - const std::optional& deal = table.getDeal(); + const std::optional& deal = table.getDeal(); // A deal that is over shows every hand: between deals the last one is // what the table looks at. - const bool shown = deal.has_value() && deal->isOver(); + const bool shown = deal.has_value() && rummy::dealPhase(*deal) != rummy::Phase::Playing; if (deal.has_value()) { - if (!deal->isOver()) { + if (!shown) { view.currentPlayerId = CurrentTurnOf(*entry.state); - view.stage = deal->getStage() == rummy::Stage::Draw ? "draw" : "play"; - view.canDrawStock = deal->canDrawStock(); - if (deal->getTakenDiscard().has_value()) { - view.takenDiscard = WireCard(*deal->getTakenDiscard()); - } } else { moonbase::games::RummyDealResult result; result.variant = std::string(rummy::variantName(table.getVariant())); - result.winner = deal->winner(); - result.points = deal->winnerPoints(); - for (std::size_t i = 0; i < deal->getPlayers().size(); ++i) { + result.winner = rummy::dealWinner(*deal); + result.points = rummy::dealWinnerPoints(*deal); + const auto& seats = rummy::dealPlayers(*deal); + for (std::size_t i = 0; i < seats.size(); ++i) { moonbase::games::RummyScore score; - score.playerId = deal->getPlayer(static_cast(i)).id; - score.deadwood = deal->deadwood(static_cast(i)); + score.playerId = seats[i].id; + score.deadwood = rummy::dealDeadwood(*deal, static_cast(i)); result.scores.push_back(std::move(score)); } + if (const rummy::GinState* gin = table.ginDeal(); + gin != nullptr && gin->getResult().has_value()) { + const rummy::GinResult& ended = *gin->getResult(); + moonbase::games::RummyGinResult reckoning; + reckoning.ending = GinEndingName(ended.ending); + if (ended.knocker >= 0) reckoning.knocker = seats.at(ended.knocker).id; + for (std::size_t i = 0; i < ended.hands.size(); ++i) { + moonbase::games::RummyArrangedHand hand; + hand.playerId = seats.at(i).id; + for (const auto& meld : ended.hands[i].melds) hand.melds.push_back(WireCards(meld)); + hand.deadwood = WireCards(ended.hands[i].deadwood); + reckoning.hands.push_back(std::move(hand)); + } + reckoning.laidOff = WireCards(ended.laidOff); + result.gin = std::move(reckoning); + } view.lastDeal = std::move(result); } - view.stockCount = static_cast(deal->getStock().size()); - view.discardCount = static_cast(deal->getDiscard().size()); - if (!deal->getDiscard().empty()) view.discardTop = WireCard(deal->getDiscard().back()); - for (const rummy::Meld& meld : deal->getMelds()) { - moonbase::games::RummyTableMeld table_meld; - table_meld.owner = meld.owner; - table_meld.cards = WireCards(meld.cards); - view.melds.push_back(std::move(table_meld)); - } - if (const auto& move = deal->getLastMove(); move.has_value()) { - moonbase::games::RummyLastMove last; - last.playerId = move->playerId; - last.move = RummyMoveName(move->kind); - last.cards = WireCards(move->cards); - if (move->meld >= 0) last.meldIndex = move->meld; - view.lastMove = std::move(last); + std::visit( + [&](const auto& d) { + view.stockCount = static_cast(d.getStock().size()); + view.discardCount = static_cast(d.getDiscard().size()); + if (!d.getDiscard().empty()) view.discardTop = WireCard(d.getDiscard().back()); + if (!shown) { + view.canDrawStock = d.canDrawStock(); + if (d.getTakenDiscard().has_value()) view.takenDiscard = WireCard(*d.getTakenDiscard()); + } + }, + *deal); + if (const rummy::GameState* basic = table.basicDeal(); basic != nullptr) { + if (!shown) { + view.stage = basic->getStage() == rummy::Stage::Draw ? "draw" : "play"; + view.canDrawDiscard = + basic->getStage() == rummy::Stage::Draw && !basic->getDiscard().empty(); + } + for (const rummy::Meld& meld : basic->getMelds()) { + moonbase::games::RummyTableMeld table_meld; + table_meld.owner = meld.owner; + table_meld.cards = WireCards(meld.cards); + view.melds.push_back(std::move(table_meld)); + } + if (const auto& move = basic->getLastMove(); move.has_value()) { + moonbase::games::RummyLastMove last; + last.playerId = move->playerId; + last.move = RummyMoveName(move->kind); + last.cards = WireCards(move->cards); + if (move->meld >= 0) last.meldIndex = move->meld; + view.lastMove = std::move(last); + } + } else if (const rummy::GinState* gin = table.ginDeal(); gin != nullptr) { + if (!shown) { + view.stage = GinStageName(gin->getStage()); + view.canDrawDiscard = gin->canDrawDiscard(); + } + if (const auto& move = gin->getLastMove(); move.has_value()) { + moonbase::games::RummyLastMove last; + last.playerId = move->playerId; + last.move = GinMoveName(move->kind); + last.cards = WireCards(move->cards); + view.lastMove = std::move(last); + } } } for (const std::string& seat_id : table.getSeats()) { moonbase::games::RummyPlayer player; player.playerId = seat_id; player.handCount = 0; - const int at = deal.has_value() ? deal->playerIndex(seat_id) : -1; + const int at = deal.has_value() + ? std::visit([&](const auto& d) { return d.playerIndex(seat_id); }, *deal) + : -1; if (at >= 0) { - const std::vector& hand = deal->getPlayer(at).hand; + const std::vector& hand = rummy::dealPlayers(*deal).at(at).hand; player.handCount = static_cast(hand.size()); // Own hand faces only while a deal is on, everyone's once it ends. if (shown || seat_id == viewer_id) player.hand = WireCards(hand); diff --git a/domains/games/apis/games_hub/hub_e2e_test.cc b/domains/games/apis/games_hub/hub_e2e_test.cc index 9c21d608..f435050e 100644 --- a/domains/games/apis/games_hub/hub_e2e_test.cc +++ b/domains/games/apis/games_hub/hub_e2e_test.cc @@ -2050,11 +2050,13 @@ TEST_F(GamesHubStreamFixture, BuildingAHandlerDeclaresEveryCounterSeriesAtZero) {"rummy_commands", {{"command", "startGame"}}}, {"rummy_commands", {{"command", "leaveGame"}}}, {"rummy_commands", {{"command", "chooseVariant"}}}, + {"rummy_commands", {{"command", "pass"}}}, {"rummy_commands", {{"command", "drawStock"}}}, {"rummy_commands", {{"command", "drawDiscard"}}}, {"rummy_commands", {{"command", "meld"}}}, {"rummy_commands", {{"command", "layOff"}}}, {"rummy_commands", {{"command", "discard"}}}, + {"rummy_commands", {{"command", "knock"}}}, {"rummy_events", {{"event", "gameJoined"}}}, {"rummy_events", {{"event", "gameState"}}}, {"rummy_events", {{"event", "gameCreated"}}}, @@ -2765,7 +2767,7 @@ TEST_F(GameEventFixture, ARummyTableRecordsEachDealAsAGame) { // Alice deals the second: another game. moonbase::games::RummyChooseVariant basic; - basic.variant = "basic"; + basic.variant = "7-card"; ASSERT_TRUE(alice.stream.Send(Rummy(RummyMove::FromChoosevariant(basic))).ok()); ASSERT_TRUE( AwaitRummyView( diff --git a/domains/games/apis/games_hub/model/rummy.smithy b/domains/games/apis/games_hub/model/rummy.smithy index b7e2ec4b..804606f5 100644 --- a/domains/games/apis/games_hub/model/rummy.smithy +++ b/domains/games/apis/games_hub/model/rummy.smithy @@ -38,22 +38,28 @@ union RummyMove { startGame: StartGame leaveGame: LeaveGame chooseVariant: RummyChooseVariant + pass: RummyPass drawStock: RummyDrawStock drawDiscard: RummyDrawDiscard meld: RummyMeld layOff: RummyLayOff discard: RummyDiscard + knock: RummyKnock } /// Between deals, the dealer deals the next: its variant, one the table's /// view offers (RummyChoosing.options). A dealer who is not connected lets /// any seat deal. structure RummyChooseVariant { - /// basic + /// 7-card | 10-card | gin @required variant: String } +/// Gin: turn down the upcard (stage upcard). Passed by both, the opener +/// draws from the stock. +structure RummyPass {} + /// Take the top of the stock. An empty stock is refilled from the /// discard pile, turned over under its top card. structure RummyDrawStock {} @@ -86,6 +92,14 @@ structure RummyDiscard { card: Card } +/// Gin: end the deal, throwing `card`, with 10 or less deadwood left in +/// the hand's best arrangement. The hub arranges both hands and lays the +/// defender's cards off; the result rides lastDeal.gin. +structure RummyKnock { + @required + card: Card +} + /// The rummy envelope on the event stream. structure RummyEvent { @required @@ -135,9 +149,9 @@ structure RummyStanding { handsWon: Integer } -/// A deal's result: the seat that went out and what it scored (every other -/// seat's cards left in hand), in seat order. No winner for a deal broken -/// up by a leave. +/// A deal's result: the seat that scored and what (every other seat's +/// cards left in hand, or gin's reckoning), and each seat's deadwood in +/// seat order. No winner for a deal broken up by a leave or drawn. structure RummyDealResult { @required variant: String @@ -149,6 +163,46 @@ structure RummyDealResult { @required scores: RummyScores + + /// A gin deal's end, as the hub arranged it. + gin: RummyGinResult +} + +structure RummyGinResult { + /// knock | gin | undercut | draw + @required + ending: String + + /// Absent for a draw. + knocker: String + + /// Each seat's hand as melds and deadwood, in seat order; the + /// defender's without what it laid off. + @required + hands: RummyArrangedHands + + /// The defender's cards laid off onto the knocker's melds. + @required + laidOff: RummyCards +} + +list RummyArrangedHands { + member: RummyArrangedHand +} + +structure RummyArrangedHand { + @required + playerId: String + + @required + melds: RummyCardGroups + + @required + deadwood: RummyCards +} + +list RummyCardGroups { + member: RummyCards } /// Between deals: who deals next and what they may deal. A dealer the @@ -217,8 +271,10 @@ structure RummyView { currentPlayerId: String - /// Where the seat on turn is: draw (about to draw) | play (drawn; - /// melds, lay-offs, then a discard). Absent when nobody is on turn. + /// Where the seat on turn is: upcard (gin: take the upcard or pass) | + /// draw (about to draw) | play (drawn; melds, lay-offs, then a + /// discard — or in gin, a discard or a knock). Absent when nobody is on + /// turn. stage: String @required @@ -229,6 +285,11 @@ structure RummyView { @required canDrawStock: Boolean + /// Whether drawDiscard would take the discard pile's top now: not + /// after gin's upcard was passed by both. + @required + canDrawDiscard: Boolean + @required discardCount: Integer @@ -287,13 +348,13 @@ structure RummyLastMove { @required playerId: String - /// drawStock | drawDiscard | meld | layOff | discard + /// drawStock | drawDiscard | meld | layOff | discard | pass | knock @required move: String /// What went on or came off the table: nothing for a stock draw - /// (nobody else sees it), the card taken, the meld as laid, the card - /// laid off, the card discarded. + /// (nobody else sees it) or a pass, the card taken, the meld as laid, + /// the card laid off, the card discarded or knocked on. @required cards: RummyCards diff --git a/domains/games/apis/games_hub/pg_hub_e2e_test.cc b/domains/games/apis/games_hub/pg_hub_e2e_test.cc index c6332a7c..19160e3f 100644 --- a/domains/games/apis/games_hub/pg_hub_e2e_test.cc +++ b/domains/games/apis/games_hub/pg_hub_e2e_test.cc @@ -786,7 +786,7 @@ TEST_F(PgGamesHubFixture, RummyTableSurvivesARestartMidTurn) { EXPECT_TRUE(after.players[0].hand.empty()); EXPECT_EQ(after.players[0].handCount, 7); EXPECT_EQ(after.dealNumber, 1); - EXPECT_EQ(after.variant.value_or(""), "basic"); + EXPECT_EQ(after.variant.value_or(""), "7-card"); auto bob_back = OpenSeat(bob_token); ASSERT_TRUE(bob_back.has_value()); diff --git a/domains/games/apis/games_hub/pg_hub_store_test.cc b/domains/games/apis/games_hub/pg_hub_store_test.cc index 3be7d52a..ab580bf5 100644 --- a/domains/games/apis/games_hub/pg_hub_store_test.cc +++ b/domains/games/apis/games_hub/pg_hub_store_test.cc @@ -169,8 +169,9 @@ TEST_F(PgHubStoreTest, RummyRowsKeepTheirKindAndDecodeWithRummySerde) { cards::Card{cards::Suit::Spades, cards::Rank::King}, cards::Card{cards::Suit::Spades, cards::Rank::Queen}}); ASSERT_TRUE(melded.ok()) << melded.status(); - const rummy::TableState table{{"alice", "bob"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, - rummy::Variant::Basic, *melded, "", ""}; + const rummy::TableState table{ + {"alice", "bob"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, + rummy::Variant::SevenCard, *melded, "", ""}; PgHubStore::GameRow started{ "R1", "M2", {"alice", "bob"}, games_hub::HostedState(table), 1, games_hub::GameKind::kRummy}; ASSERT_TRUE(*store_->CommitGameSave(started, "")); diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc index 45b6a760..792f37fb 100644 --- a/domains/games/apis/games_hub/rummy_e2e_test.cc +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -31,6 +31,14 @@ namespace games_hub { namespace { +// What the dealer may deal at each table size: gin only heads-up, ten +// cards a seat only while the deck covers them. +std::vector OfferedAt(size_t seats) { + if (seats == 2) return {"7-card", "10-card", "gin"}; + if (seats == 3) return {"7-card", "10-card"}; + return {"7-card"}; +} + using moonbase::games::CastleMove; using moonbase::games::GameCommands; using moonbase::games::RummyMove; @@ -189,7 +197,7 @@ class RummyGameFixture : public GamesHubStreamFixture { EXPECT_EQ(seen.phase, !ended ? "playing" : mirror.getPhase() == rummy::Phase::Over ? "choosing" : "ended"); - EXPECT_EQ(seen.variant.value_or(""), "basic"); + EXPECT_EQ(seen.variant.value_or(""), "7-card"); if (ended) { EXPECT_FALSE(seen.currentPlayerId.has_value()); EXPECT_FALSE(seen.stage.has_value()); @@ -198,7 +206,7 @@ class RummyGameFixture : public GamesHubStreamFixture { // The deal's result rides the view, for every chair and every // instance, until the next deal replaces it. ASSERT_TRUE(seen.lastDeal.has_value()); - EXPECT_EQ(seen.lastDeal->variant, "basic"); + EXPECT_EQ(seen.lastDeal->variant, "7-card"); EXPECT_EQ(seen.lastDeal->winner, mirror.winner()); EXPECT_EQ(seen.lastDeal->points, mirror.winnerPoints()); ASSERT_EQ(seen.lastDeal->scores.size(), mirror.getPlayers().size()); @@ -349,7 +357,7 @@ class RummyGameFixture : public GamesHubStreamFixture { ExpectBoard(view, seat->player_id, mirror); ASSERT_TRUE(view.choosing.has_value()); EXPECT_EQ(view.choosing->dealer, next_dealer); - EXPECT_EQ(view.choosing->options, std::vector{"basic"}); + EXPECT_EQ(view.choosing->options, OfferedAt(seats.size())); for (const auto& standing : view.standings) { if (standing.playerId == winner) EXPECT_GE(standing.handsWon, 1); } @@ -897,7 +905,7 @@ TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { ExpectNoEvent(bob.stream); // Bob dealt the first; alice deals the second. - ASSERT_TRUE(bob.stream.Send(Choose("basic")).ok()); + ASSERT_TRUE(bob.stream.Send(Choose("7-card")).ok()); auto refused = ReceiveCase(bob.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "the dealer chooses"); @@ -907,7 +915,7 @@ TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "no deal in play"); - ASSERT_TRUE(alice.stream.Send(Choose("basic")).ok()); + ASSERT_TRUE(alice.stream.Send(Choose("7-card")).ok()); for (auto* seat : {&alice, &bob}) { auto dealt = ReceiveRummy(seat->stream, "gameState"); ASSERT_TRUE(dealt.has_value()); @@ -930,7 +938,7 @@ TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, bob.player_id); } // Mid-deal there is nothing to choose. - ASSERT_TRUE(alice.stream.Send(Choose("basic")).ok()); + ASSERT_TRUE(alice.stream.Send(Choose("7-card")).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not between deals"); @@ -953,7 +961,7 @@ TEST_F(RummyGameFixture, AnAwayDealerLetsAnySeatDeal) { auto table = ChoosingRummyTable(3); ASSERT_TRUE(table.has_value()); Seat& dealer = table->seats[0]; - ASSERT_TRUE(table->seats[2].stream.Send(Choose("basic")).ok()); + ASSERT_TRUE(table->seats[2].stream.Send(Choose("7-card")).ok()); auto refused = ReceiveCase(table->seats[2].stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "the dealer chooses"); @@ -969,7 +977,7 @@ TEST_F(RummyGameFixture, AnAwayDealerLetsAnySeatDeal) { }, "the dealer shown away"); ASSERT_TRUE(away.has_value()); - ASSERT_TRUE(table->seats[2].stream.Send(Choose("basic")).ok()); + ASSERT_TRUE(table->seats[2].stream.Send(Choose("7-card")).ok()); auto dealt = AwaitRummyView( table->seats[2].stream, [](const RummyView& view) { return view.phase == "playing"; }, "the deal"); diff --git a/domains/games/apis/games_hub/rummy_wire_test.cc b/domains/games/apis/games_hub/rummy_wire_test.cc index ffe07f47..f5b5c7b6 100644 --- a/domains/games/apis/games_hub/rummy_wire_test.cc +++ b/domains/games/apis/games_hub/rummy_wire_test.cc @@ -68,14 +68,14 @@ class RummyWireTest : public HubWireFixture { } } - // The same table with basic dealt by player-1: the creator's frames read + // The same table with seven-card dealt by player-1: the creator's frames read // up to its dealt view, which is returned; the room's listing and the // opening turn follow it. The joiner's are all still to read. std::string DealtTable(std::shared_ptr& creator, std::shared_ptr& joiner) { ChoosingTable(creator, joiner); EXPECT_TRUE( - creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"basic"}}})")) + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"7-card"}}})")) .ok()); const std::string dealt = EventPayload(NextFrame(*creator), "rummy"); (void)EventPayload(NextFrame(*creator), "roomState"); @@ -104,7 +104,8 @@ TEST_F(RummyWireTest, CreateAndJoinPinTheWaitingViewAndTheLobbysWord) { EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), R"({"update":{"gameCreated":{"createdBy":"player-1","gameId":"GAME01"}}})"); EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"gameJoined":{"view":{"canDrawStock":false,"dealNumber":0,)" + R"({"update":{"gameJoined":{"view":{"canDrawDiscard":false,"canDrawStock":false,)" + R"("dealNumber":0,)" R"("discardCount":0,"gameId":"GAME01","melds":[],"phase":"waiting","players":[)" R"({"hand":[],"handCount":0,"playerId":"player-1"}],"standings":[{"handsWon":0,)" R"("playerId":"player-1"}],"stockCount":0}}}})"); @@ -140,8 +141,9 @@ TEST_F(RummyWireTest, AStartedTableWaitsOnTheDealersChoice) { ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"startGame":{}}})")).ok()); EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), R"({"update":{"gameStarted":{}}})"); EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"gameState":{"view":{"canDrawStock":false,"choosing":{"dealer":)" - R"("player-1","options":["basic"]},"dealNumber":0,"discardCount":0,"gameId":)" + R"({"update":{"gameState":{"view":{"canDrawDiscard":false,"canDrawStock":false,)" + R"("choosing":{"dealer":"player-1","options":["7-card","10-card","gin"]},)" + R"("dealNumber":0,"discardCount":0,"gameId":)" R"("GAME01","melds":[],"phase":"choosing","players":[{"hand":[],"handCount":0,)" R"("playerId":"player-1"},{"hand":[],"handCount":0,"playerId":"player-2"}],)" R"("standings":[{"handsWon":0,"playerId":"player-1"},{"handsWon":0,"playerId":)" @@ -155,14 +157,15 @@ TEST_F(RummyWireTest, AStartedTableWaitsOnTheDealersChoice) { for (int i = 0; i < 3; ++i) (void)NextFrame(*joiner); ASSERT_TRUE( - joiner->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"basic"}}})")) + joiner->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"7-card"}}})")) .ok()); EXPECT_EQ(EventPayload(NextFrame(*joiner), "commandRejected"), R"({"reason":"the dealer chooses"})"); ASSERT_TRUE( - creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"gin"}}})")).ok()); + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"canasta"}}})")) + .ok()); EXPECT_EQ(EventPayload(NextFrame(*creator), "commandRejected"), - R"({"reason":"no such game: gin"})"); + R"({"reason":"no such game: canasta"})"); } TEST_F(RummyWireTest, TheDealtViewFromEachChair) { @@ -171,19 +174,20 @@ TEST_F(RummyWireTest, TheDealtViewFromEachChair) { const std::string dealt = DealtTable(creator, joiner); EXPECT_EQ( dealt, - R"({"update":{"gameState":{"view":{"canDrawStock":true,"currentPlayerId":"player-2",)" + R"({"update":{"gameState":{"view":{"canDrawDiscard":true,"canDrawStock":true,)" + R"("currentPlayerId":"player-2",)" R"("dealNumber":1,"discardCount":1,"discardTop":{"rank":"J","suit":"♦"},"gameId":"GAME01",)" R"("melds":[],"phase":"playing","players":[{"hand":[{"rank":"A","suit":"♠"},)" R"({"rank":"A","suit":"♦"},{"rank":"K","suit":"♠"},{"rank":"K","suit":"♦"},)" R"({"rank":"Q","suit":"♠"},{"rank":"Q","suit":"♦"},{"rank":"J","suit":"♠"}],)" R"("handCount":7,"playerId":"player-1"},{"hand":[],"handCount":7,"playerId":"player-2"}],)" R"("stage":"draw","standings":[{"handsWon":0,"playerId":"player-1"},{"handsWon":0,)" - R"("playerId":"player-2"}],"stockCount":37,"variant":"basic"}}}})"); + R"("playerId":"player-2"}],"stockCount":37,"variant":"7-card"}}}})"); const json view = json::parse(dealt)["update"]["gameState"]["view"]; - EXPECT_EQ(KeysOf(view), - (std::set{"canDrawStock", "currentPlayerId", "dealNumber", "discardCount", - "discardTop", "gameId", "melds", "phase", "players", "stage", - "standings", "stockCount", "variant"})); + EXPECT_EQ(KeysOf(view), (std::set{ + "canDrawDiscard", "canDrawStock", "currentPlayerId", "dealNumber", + "discardCount", "discardTop", "gameId", "melds", "phase", "players", + "stage", "standings", "stockCount", "variant"})); EXPECT_EQ(KeysOf(view["players"][0]), (std::set{"hand", "handCount", "playerId"})); // Absent optionals are omitted keys, not nulls: nothing taken, no move // yet, no deal before this one, nobody choosing. @@ -211,17 +215,18 @@ TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheDealsEndBytes) { ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"drawDiscard":{}}})")).ok()); (void)EventPayload(NextFrame(*joiner), "rummy"); - EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"gameState":{"view":{"canDrawStock":true,"currentPlayerId":"player-2",)" - R"("dealNumber":1,"discardCount":0,"gameId":"GAME01","lastMove":{"cards":[{"rank":)" - R"("J","suit":"♦"}],"move":"drawDiscard","playerId":"player-2"},"melds":[],"phase":)" - R"("playing","players":[{"hand":[{"rank":"A","suit":"♠"},{"rank":"A","suit":"♦"},)" - R"({"rank":"K","suit":"♠"},{"rank":"K","suit":"♦"},{"rank":"Q","suit":"♠"},)" - R"({"rank":"Q","suit":"♦"},{"rank":"J","suit":"♠"}],"handCount":7,"playerId":)" - R"("player-1"},{"hand":[],"handCount":8,"playerId":"player-2"}],"stage":"play",)" - R"("standings":[{"handsWon":0,"playerId":"player-1"},{"handsWon":0,"playerId":)" - R"("player-2"}],"stockCount":37,"takenDiscard":{"rank":"J","suit":"♦"},)" - R"("variant":"basic"}}}})"); + EXPECT_EQ( + EventPayload(NextFrame(*creator), "rummy"), + R"({"update":{"gameState":{"view":{"canDrawDiscard":false,"canDrawStock":true,)" + R"("currentPlayerId":"player-2","dealNumber":1,"discardCount":0,"gameId":"GAME01","lastMove":{"cards":[{"rank":)" + R"("J","suit":"♦"}],"move":"drawDiscard","playerId":"player-2"},"melds":[],"phase":)" + R"("playing","players":[{"hand":[{"rank":"A","suit":"♠"},{"rank":"A","suit":"♦"},)" + R"({"rank":"K","suit":"♠"},{"rank":"K","suit":"♦"},{"rank":"Q","suit":"♠"},)" + R"({"rank":"Q","suit":"♦"},{"rank":"J","suit":"♠"}],"handCount":7,"playerId":)" + R"("player-1"},{"hand":[],"handCount":8,"playerId":"player-2"}],"stage":"play",)" + R"("standings":[{"handsWon":0,"playerId":"player-1"},{"handsWon":0,"playerId":)" + R"("player-2"}],"stockCount":37,"takenDiscard":{"rank":"J","suit":"♦"},)" + R"("variant":"7-card"}}}})"); ASSERT_TRUE( joiner @@ -271,16 +276,18 @@ TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheDealsEndBytes) { const json between = json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; EXPECT_EQ(between["phase"], "choosing"); - EXPECT_EQ(between["choosing"].dump(), R"({"dealer":"player-2","options":["basic"]})"); + EXPECT_EQ(between["choosing"].dump(), + R"({"dealer":"player-2","options":["7-card","10-card","gin"]})"); EXPECT_EQ(between["lastDeal"].dump(), R"({"points":52,"scores":[{"deadwood":52,"playerId":"player-1"},)" - R"({"deadwood":0,"playerId":"player-2"}],"variant":"basic","winner":"player-2"})"); + R"({"deadwood":0,"playerId":"player-2"}],"variant":"7-card","winner":"player-2"})"); EXPECT_EQ(between["standings"].dump(), R"([{"handsWon":0,"playerId":"player-1"},{"handsWon":1,"playerId":"player-2"}])"); for (const char* absent : {"currentPlayerId", "stage", "takenDiscard"}) { EXPECT_FALSE(between.contains(absent)) << absent; } EXPECT_EQ(between["canDrawStock"], false); + EXPECT_EQ(between["canDrawDiscard"], false); EXPECT_EQ(between["dealNumber"], 1); EXPECT_EQ(between["players"][0]["hand"].size(), 7u); const json room = json::parse(EventPayload(NextFrame(*creator), "roomState")); @@ -288,11 +295,12 @@ TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheDealsEndBytes) { EXPECT_EQ(room["players"][1]["gamesWon"], 1); // Between deals the view keeps the last deal's variant and cards, and // adds who deals next and how the last deal went. - EXPECT_EQ(KeysOf(between), - (std::set{"canDrawStock", "choosing", "dealNumber", "discardCount", - "discardTop", "gameId", "lastDeal", "lastMove", "melds", "phase", - "players", "standings", "stockCount", "variant"})); - EXPECT_EQ(between["variant"], "basic"); + EXPECT_EQ( + KeysOf(between), + (std::set{"canDrawDiscard", "canDrawStock", "choosing", "dealNumber", + "discardCount", "discardTop", "gameId", "lastDeal", "lastMove", + "melds", "phase", "players", "standings", "stockCount", "variant"})); + EXPECT_EQ(between["variant"], "7-card"); } // Below two seats the table closes: the last deal, broken up, names no @@ -309,12 +317,126 @@ TEST_F(RummyWireTest, ALeaveMidDealClosesTheTableWithItsStandings) { EXPECT_EQ(closed["phase"], "ended"); EXPECT_FALSE(closed.contains("choosing")); EXPECT_EQ(closed["lastDeal"].dump(), - R"({"points":0,"scores":[{"deadwood":52,"playerId":"player-1"}],"variant":"basic"})"); + R"({"points":0,"scores":[{"deadwood":52,"playerId":"player-1"}],"variant":"7-card"})"); EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), R"({"update":{"gameEnded":{"dealsPlayed":1,"standings":[{"handsWon":0,"playerId":)" R"("player-1"}]}}})"); } +// Gin's moves and its end. Dealt ten a seat, player-1 holds 10-A in ♠ +// and ♦, player-2 10-A♥ and 10-K♣ with A♣ over; 9♠ is turned up and 9♥ +// tops the stock. Both pass the upcard, player-2 draws 9♥ and knocks on +// A♣ with nothing left over: gin, 25 and player-1's deadwood (none), and +// nothing laid off after gin. +TEST_F(RummyWireTest, GinPassesKnocksAndReckons) { + std::shared_ptr creator; + std::shared_ptr joiner; + ChoosingTable(creator, joiner); + ASSERT_TRUE( + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"gin"}}})")).ok()); + const json dealt = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + EXPECT_EQ(dealt["variant"], "gin"); + EXPECT_EQ(dealt["stage"], "upcard"); + EXPECT_EQ(dealt["canDrawStock"], false); + EXPECT_EQ(dealt["canDrawDiscard"], true); + EXPECT_EQ(dealt["stockCount"], 31); + EXPECT_EQ(dealt["discardTop"].dump(), R"({"rank":"9","suit":"♠"})"); + EXPECT_EQ(dealt["players"][0]["handCount"], 10); + (void)EventPayload(NextFrame(*creator), "roomState"); + (void)EventPayload(NextFrame(*creator), "rummy"); // turnChanged + for (int i = 0; i < 3; ++i) (void)NextFrame(*joiner); + + // A move gin does not have is refused by name. + ASSERT_TRUE( + joiner + ->Send(CommandFrame( + "rummy", + R"({"move":{"meld":{"cards":[{"rank":"K","suit":"♥"},{"rank":"Q","suit":"♥"},)" + R"({"rank":"J","suit":"♥"}]}}})")) + .ok()); + EXPECT_EQ(EventPayload(NextFrame(*joiner), "commandRejected"), + R"({"reason":"not a move in gin"})"); + + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"pass":{}}})")).ok()); + (void)EventPayload(NextFrame(*joiner), "rummy"); + (void)EventPayload(NextFrame(*joiner), "rummy"); // turnChanged + const json passed = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + EXPECT_EQ(passed["lastMove"].dump(), R"({"cards":[],"move":"pass","playerId":"player-2"})"); + EXPECT_EQ(passed["stage"], "upcard"); + EXPECT_EQ(passed["currentPlayerId"], "player-1"); + (void)EventPayload(NextFrame(*creator), "rummy"); // turnChanged + + // Passed by both, the opener draws from the stock and only there. + ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"pass":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + (void)EventPayload(NextFrame(*creator), "rummy"); // turnChanged + const json stockOnly = + json::parse(EventPayload(NextFrame(*joiner), "rummy"))["update"]["gameState"]["view"]; + EXPECT_EQ(stockOnly["stage"], "draw"); + EXPECT_EQ(stockOnly["canDrawStock"], true); + EXPECT_EQ(stockOnly["canDrawDiscard"], false); + (void)EventPayload(NextFrame(*joiner), "rummy"); // turnChanged + + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"drawStock":{}}})")).ok()); + const json drew = + json::parse(EventPayload(NextFrame(*joiner), "rummy"))["update"]["gameState"]["view"]; + EXPECT_EQ(drew["players"][1]["hand"][10].dump(), R"({"rank":"9","suit":"♥"})"); + (void)EventPayload(NextFrame(*creator), "rummy"); + + ASSERT_TRUE( + joiner->Send(CommandFrame("rummy", R"({"move":{"knock":{"card":{"rank":"A","suit":"♣"}}}})")) + .ok()); + const json between = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + EXPECT_EQ(between["phase"], "choosing"); + EXPECT_EQ(between["lastMove"].dump(), + R"({"cards":[{"rank":"A","suit":"♣"}],"move":"knock","playerId":"player-2"})"); + EXPECT_EQ(between["lastDeal"].dump(), + R"({"gin":{"ending":"gin","hands":[{"deadwood":[],"melds":[[{"rank":"10","suit":"♠"},)" + R"({"rank":"J","suit":"♠"},{"rank":"Q","suit":"♠"},{"rank":"K","suit":"♠"},)" + R"({"rank":"A","suit":"♠"}],[{"rank":"10","suit":"♦"},{"rank":"J","suit":"♦"},)" + R"({"rank":"Q","suit":"♦"},{"rank":"K","suit":"♦"},{"rank":"A","suit":"♦"}]],)" + R"("playerId":"player-1"},{"deadwood":[],"melds":[[{"rank":"Q","suit":"♥"},)" + R"({"rank":"K","suit":"♥"},{"rank":"A","suit":"♥"}],[{"rank":"10","suit":"♣"},)" + R"({"rank":"J","suit":"♣"},{"rank":"Q","suit":"♣"},{"rank":"K","suit":"♣"}],)" + R"([{"rank":"9","suit":"♥"},{"rank":"10","suit":"♥"},{"rank":"J","suit":"♥"}]],)" + R"("playerId":"player-2"}],"knocker":"player-2","laidOff":[]},"points":25,)" + R"("scores":[{"deadwood":0,"playerId":"player-1"},{"deadwood":0,"playerId":)" + R"("player-2"}],"variant":"gin","winner":"player-2"})"); + EXPECT_EQ(between["standings"].dump(), + R"([{"handsWon":0,"playerId":"player-1"},{"handsWon":1,"playerId":"player-2"}])"); +} + +// The same deal, player-2 taking the upcard 9♠ and knocking on K♣ with +// 9♠ and A♣ over (10): player-1's 10-A runs leave nothing, which +// undercuts — player-1 scores 25 and the 10. +TEST_F(RummyWireTest, GinUndercut) { + std::shared_ptr creator; + std::shared_ptr joiner; + ChoosingTable(creator, joiner); + ASSERT_TRUE( + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"gin"}}})")).ok()); + for (int i = 0; i < 3; ++i) (void)NextFrame(*creator); + for (int i = 0; i < 3; ++i) (void)NextFrame(*joiner); + + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"drawDiscard":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + ASSERT_TRUE( + joiner->Send(CommandFrame("rummy", R"({"move":{"knock":{"card":{"rank":"K","suit":"♣"}}}})")) + .ok()); + const json between = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + const json& gin = between["lastDeal"]["gin"]; + EXPECT_EQ(gin["ending"], "undercut"); + EXPECT_EQ(gin["knocker"], "player-2"); + EXPECT_EQ(gin["hands"][1]["deadwood"].dump(), + R"([{"rank":"A","suit":"♣"},{"rank":"9","suit":"♠"}])"); + EXPECT_EQ(between["lastDeal"]["winner"], "player-1"); + EXPECT_EQ(between["lastDeal"]["points"], 35); +} + TEST_F(RummyWireTest, StockDrawAndDiscardSpellings) { std::shared_ptr creator; std::shared_ptr joiner; diff --git a/domains/games/apis/games_hub/stream_test_fixture.h b/domains/games/apis/games_hub/stream_test_fixture.h index d0b6710b..d7f2037b 100644 --- a/domains/games/apis/games_hub/stream_test_fixture.h +++ b/domains/games/apis/games_hub/stream_test_fixture.h @@ -712,14 +712,14 @@ class GamesHubStreamFixture : public testing::Test { return table; } - // The same table with basic rummy dealt: each seat still has its dealt + // The same table with seven-card rummy dealt: each seat still has its dealt // view to read. The NoShuffleDealer deals one card a seat from the back, // seat 0 first; the seat after the dealer (seat 1) opens. std::optional MultiSeatRummyTable(int count) { auto table = ChoosingRummyTable(count); if (!table.has_value()) return std::nullopt; moonbase::games::RummyChooseVariant basic; - basic.variant = "basic"; + basic.variant = "7-card"; if (!table->seats.front() .stream.Send(Rummy(moonbase::games::RummyMove::FromChoosevariant(basic))) .ok()) { diff --git a/domains/games/libs/cards/rummy/BUILD.bazel b/domains/games/libs/cards/rummy/BUILD.bazel index 9b8bfbf9..eafd71c6 100644 --- a/domains/games/libs/cards/rummy/BUILD.bazel +++ b/domains/games/libs/cards/rummy/BUILD.bazel @@ -226,3 +226,7 @@ cc_test( "@nlohmann_json//:json", ], ) + +# Read as text by //domains/platform/libs/otel_contract, which pins the +# words recordedName records deals under against the stats readers. +exports_files(["table.cc"]) diff --git a/domains/games/libs/cards/rummy/README.md b/domains/games/libs/cards/rummy/README.md index f46f71a6..bf0b6ce0 100644 --- a/domains/games/libs/cards/rummy/README.md +++ b/domains/games/libs/cards/rummy/README.md @@ -1,11 +1,13 @@ # rummy -The rules engine for basic Rummy (#245): an immutable `GameState` value -type in the style of [`../castle`](../castle), for the games hub. +The rules engines for Rummy (#245) and Gin (#1610): immutable +`GameState` and `GinState` value types in the style of +[`../castle`](../castle), for the games hub, and `TableState`, the +dealer's-choice table (#1609) that deals one after another. 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`: seven cards -a seat; one card turned up to start the discard pile; a turn is a draw from the stock or the discard, any melds +`game_state.h` and pinned one by one in `game_state_test.cc`: seven (or +ten) cards a seat; one card turned up to start the discard pile; a turn is a draw from the stock or the discard, any melds and lay-offs, then a discard, and the card taken from the discard may not go straight back unless it is the last in the hand; an empty stock is the discard pile turned over under its top card; the first seat to empty its hand wins and scores what everyone @@ -21,3 +23,10 @@ bazel test //domains/games/libs/cards/rummy/... rummy table in — the engine's full truth, stock and every hand included, so it is server-side only; redaction stays in the hub. `game_state_serde_test.cc` pins the schema. + +Gin's rules are `GinState`'s, in `gin.h`, pinned in `gin_test.cc`. Its +reckoning needs each hand's best arrangement: `arrange.h`'s search, which +`arrange_test.cc` checks against a brute force and against +`testdata/arrange_corpus.json` — cases generated by an independent Python +oracle (`testdata/make_arrange_corpus.py`) that the UI's own search +replays too. diff --git a/domains/games/libs/cards/rummy/table.cc b/domains/games/libs/cards/rummy/table.cc index 2b555bf6..99b0744b 100644 --- a/domains/games/libs/cards/rummy/table.cc +++ b/domains/games/libs/cards/rummy/table.cc @@ -21,19 +21,19 @@ using absl::StatusOr; std::string_view variantName(Variant variant) { switch (variant) { - case Variant::Basic: - return "basic"; + case Variant::SevenCard: + return "7-card"; case Variant::TenCard: - return "ten-card"; + return "10-card"; case Variant::Gin: return "gin"; } - return "basic"; + return "7-card"; } std::string_view recordedName(Variant variant) { switch (variant) { - case Variant::Basic: + case Variant::SevenCard: case Variant::TenCard: return "rummy"; case Variant::Gin: @@ -43,7 +43,7 @@ std::string_view recordedName(Variant variant) { } std::optional parseVariant(std::string_view name) { - for (const Variant variant : {Variant::Basic, Variant::TenCard, Variant::Gin}) { + for (const Variant variant : {Variant::SevenCard, Variant::TenCard, Variant::Gin}) { if (name == variantName(variant)) return variant; } return std::nullopt; @@ -77,11 +77,11 @@ int dealWhoseTurn(const Deal& deal) { std::vector variantsFor(int seats) { switch (seats) { case 2: - return {Variant::Basic, Variant::TenCard, Variant::Gin}; + return {Variant::SevenCard, Variant::TenCard, Variant::Gin}; case 3: - return {Variant::Basic, Variant::TenCard}; + return {Variant::SevenCard, Variant::TenCard}; case 4: - return {Variant::Basic}; + return {Variant::SevenCard}; } return {}; } @@ -110,7 +110,7 @@ StatusOr TableState::open(const std::string& game_id, 0, 0, TablePhase::Choosing, - Variant::Basic, + Variant::SevenCard, std::nullopt, game_id, ""}; diff --git a/domains/games/libs/cards/rummy/table.h b/domains/games/libs/cards/rummy/table.h index 1b7a20e4..ebe83254 100644 --- a/domains/games/libs/cards/rummy/table.h +++ b/domains/games/libs/cards/rummy/table.h @@ -16,22 +16,22 @@ namespace rummy { -/// The games a rummy deal can be (#1609). Basic is GameState's rules, +/// The games a rummy deal can be (#1609). Seven-card is GameState's rules, /// seven cards a seat; ten-card is the same game dealt ten; gin is /// GinState's (#1610). Rummy 500 (#1611) joins here. -enum class Variant { Basic, TenCard, Gin }; +enum class Variant { SevenCard, TenCard, Gin }; /// The wire's word for a variant. [[nodiscard]] std::string_view variantName(Variant variant); /// The word a deal of this variant is recorded under as a game (#1571): -/// basic, at either hand size, keeps "rummy", the word the game had +/// GameState's rummy, at either hand size, keeps "rummy", the word the game had /// before it had variants. [[nodiscard]] std::string_view recordedName(Variant variant); [[nodiscard]] std::optional parseVariant(std::string_view name); /// The variants a table of this many seats may deal. [[nodiscard]] std::vector variantsFor(int seats); -/// A deal of any variant: basic and ten-card play GameState, gin +/// A deal of any variant: seven- and ten-card play GameState, gin /// GinState. using Deal = std::variant; @@ -108,7 +108,7 @@ class TableState { [[nodiscard]] Variant getVariant() const { return variant; } /// The deal in play, or the one that just ended; absent before the first. [[nodiscard]] const std::optional& getDeal() const { return deal; } - /// The deal as basic's or gin's engine, if it is that game's. + /// The deal as GameState or GinState, if it is that engine's. [[nodiscard]] const GameState* basicDeal() const { return deal.has_value() ? std::get_if(&*deal) : nullptr; } diff --git a/domains/games/libs/cards/rummy/table_serde.cc b/domains/games/libs/cards/rummy/table_serde.cc index 994ea612..0672d73a 100644 --- a/domains/games/libs/cards/rummy/table_serde.cc +++ b/domains/games/libs/cards/rummy/table_serde.cc @@ -78,7 +78,7 @@ absl::StatusOr fromVersionOne(const std::string& serialized) { dealer, 1, live ? TablePhase::Playing : TablePhase::Closed, - Variant::Basic, + Variant::SevenCard, *std::move(deal), "", ""}; @@ -130,7 +130,9 @@ absl::StatusOr deserializeTableState(const std::string& serialized) auto variant_name = readString(parsed, "variant"); if (!variant_name.ok()) return variant_name.status(); - const std::optional variant = parseVariant(*variant_name); + // "basic" is seven-card's name in rows written before it had its own. + const std::optional variant = + *variant_name == "basic" ? Variant::SevenCard : parseVariant(*variant_name); if (!variant.has_value()) return absl::InvalidArgumentError("unknown variant"); if (!parsed.contains("seats") || !parsed["seats"].is_array()) { diff --git a/domains/games/libs/cards/rummy/table_serde.h b/domains/games/libs/cards/rummy/table_serde.h index 50831dfd..17cb4584 100644 --- a/domains/games/libs/cards/rummy/table_serde.h +++ b/domains/games/libs/cards/rummy/table_serde.h @@ -14,7 +14,7 @@ namespace rummy { /// Schema v2 — v1 (game_state_serde.h) is a lone deal, which is what a /// rummy row held before the table: /// {"v":2, "phase":"choosing"|"playing"|"closed", "seats":[str...], -/// "wins":[int...], "dealer":int, "dealNumber":int, "variant":"basic", +/// "wins":[int...], "dealer":int, "dealNumber":int, "variant":"7-card", /// "deal":} /// deal is absent before the first deal. A v1 row reads as that deal at a /// table of its seats: in play, dealt by the seat before the one on turn, diff --git a/domains/games/libs/cards/rummy/table_serde_test.cc b/domains/games/libs/cards/rummy/table_serde_test.cc index d22bee5b..16974c37 100644 --- a/domains/games/libs/cards/rummy/table_serde_test.cc +++ b/domains/games/libs/cards/rummy/table_serde_test.cc @@ -32,7 +32,7 @@ TableState opened() { } TableState playing() { - auto dealt = opened().chooseVariant(0, Variant::Basic, pristineDeck()); + auto dealt = opened().chooseVariant(0, Variant::SevenCard, pristineDeck()); EXPECT_TRUE(dealt.ok()) << dealt.status(); return *dealt; } @@ -49,7 +49,8 @@ TableState between() { std::nullopt, "", ""}; - return TableState{{"a", "b"}, {0, 1}, 0, 1, TablePhase::Choosing, Variant::Basic, over, "", ""}; + return TableState{{"a", "b"}, {0, 1}, 0, 1, TablePhase::Choosing, + Variant::SevenCard, over, "", ""}; } json payloadOf(const TableState& table) { return json::parse(serializeTableState(table)); } @@ -84,8 +85,8 @@ void expectRoundTrips(const TableState& table) { } // namespace -// Each variant's deal is stored in its own engine's form: ten-card as -// basic's, gin as gin's. +// Each variant's deal is stored in its own engine's form: 10-card as +// GameState's, gin as GinState's. TEST(TableSerde, EveryVariantsDealRoundTrips) { auto ten = opened().chooseVariant(0, Variant::TenCard, pristineDeck()); ASSERT_TRUE(ten.ok()); @@ -102,7 +103,7 @@ TEST(TableSerde, EveryVariantsDealRoundTrips) { basicAsGin["variant"] = "gin"; expectRejected(basicAsGin); json ginAsBasic = payload; - ginAsBasic["variant"] = "basic"; + ginAsBasic["variant"] = "7-card"; expectRejected(ginAsBasic); } @@ -120,7 +121,7 @@ TEST(TableSerde, EveryPhaseRoundTrips) { TEST(TableSerde, FrozenPayload) { constexpr const char* kRow = R"({"dealNumber":0,"dealer":0,"phase":"choosing","seats":["a","b"],"v":2,)" - R"("variant":"basic","wins":[0,0]})"; + R"("variant":"7-card","wins":[0,0]})"; EXPECT_EQ(serializeTableState(opened()), kRow); const auto restored = deserializeTableState(kRow); ASSERT_TRUE(restored.ok()) << restored.status(); @@ -132,6 +133,18 @@ TEST(TableSerde, FrozenPayload) { EXPECT_EQ(dealt["dealNumber"], 1); } +// Seven-card rummy was stored as "basic" before it had a name of its own; +// such a row reads as seven-card, and is written back under the new word. +TEST(TableSerde, AStoredBasicTableIsSevenCard) { + constexpr const char* kRow = + R"({"dealNumber":0,"dealer":0,"phase":"choosing","seats":["a","b"],"v":2,)" + R"("variant":"basic","wins":[0,0]})"; + const auto restored = deserializeTableState(kRow); + ASSERT_TRUE(restored.ok()) << restored.status(); + EXPECT_EQ(restored->getVariant(), Variant::SevenCard); + EXPECT_EQ(payloadOf(*restored)["variant"], "7-card"); +} + // A row from before the table (#1608) is one deal: it reads as that deal // in play at a table of its seats, dealt by the seat before the one on // turn, or as a closed table if the deal had ended — a finished row stays @@ -148,7 +161,7 @@ TEST(TableSerde, AVersionOneRowIsOneDeal) { EXPECT_EQ(table->getWins(), (std::vector{0, 0, 0})); EXPECT_EQ(table->getDealNumber(), 1); EXPECT_EQ(table->getDealer(), 2); - EXPECT_EQ(table->getVariant(), Variant::Basic); + EXPECT_EQ(table->getVariant(), Variant::SevenCard); EXPECT_EQ(serializeGameState(*table->basicDeal()), serializeGameState(*drew)); auto gone = drew->removePlayer(1); diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 0edc21f6..1d1b0a8a 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -30,30 +30,31 @@ TableState opened(vector seats = {"a", "b", "c"}) { // The table with this deal in play. TableState withDeal(const TableState& table, GameState deal) { return TableState{table.getSeats(), table.getWins(), table.getDealer(), - table.getDealNumber(), TablePhase::Playing, Variant::Basic, + table.getDealNumber(), TablePhase::Playing, Variant::SevenCard, std::move(deal), table.getGameId(), table.getVersionId()}; } } // namespace TEST(Variants, NamesRoundTripAndFitTheSeats) { - for (const Variant variant : {Variant::Basic, Variant::TenCard, Variant::Gin}) { + for (const Variant variant : {Variant::SevenCard, Variant::TenCard, Variant::Gin}) { EXPECT_EQ(parseVariant(variantName(variant)), variant); } - EXPECT_EQ(variantName(Variant::Basic), "basic"); - EXPECT_EQ(variantName(Variant::TenCard), "ten-card"); + EXPECT_EQ(variantName(Variant::SevenCard), "7-card"); + EXPECT_EQ(variantName(Variant::TenCard), "10-card"); EXPECT_EQ(variantName(Variant::Gin), "gin"); EXPECT_FALSE(parseVariant("rummy500").has_value()); + EXPECT_FALSE(parseVariant("basic").has_value()); EXPECT_FALSE(parseVariant("").has_value()); // Ten cards a seat leaves too thin a stock at four; gin is for two. - EXPECT_EQ(variantsFor(2), (vector{Variant::Basic, Variant::TenCard, Variant::Gin})); - EXPECT_EQ(variantsFor(3), (vector{Variant::Basic, Variant::TenCard})); - EXPECT_EQ(variantsFor(4), vector{Variant::Basic}); + EXPECT_EQ(variantsFor(2), (vector{Variant::SevenCard, Variant::TenCard, Variant::Gin})); + EXPECT_EQ(variantsFor(3), (vector{Variant::SevenCard, Variant::TenCard})); + EXPECT_EQ(variantsFor(4), vector{Variant::SevenCard}); EXPECT_TRUE(variantsFor(1).empty()); EXPECT_TRUE(variantsFor(5).empty()); - // Stats record basic rummy, at either hand size, as the game it always + // Stats record rummy, at either hand size, as the game it always // was; gin is a game of its own. - EXPECT_EQ(recordedName(Variant::Basic), "rummy"); + EXPECT_EQ(recordedName(Variant::SevenCard), "rummy"); EXPECT_EQ(recordedName(Variant::TenCard), "rummy"); EXPECT_EQ(recordedName(Variant::Gin), "gin"); } @@ -162,11 +163,11 @@ TEST(Table, OpensForTwoToFourSeats) { } TEST(Table, TheDealerChoosesAndTheSeatAfterOpens) { - auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); + auto dealt = opened().chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()) << dealt.status(); EXPECT_EQ(dealt->getPhase(), TablePhase::Playing); EXPECT_EQ(dealt->getDealNumber(), 1); - EXPECT_EQ(dealt->getVariant(), Variant::Basic); + EXPECT_EQ(dealt->getVariant(), Variant::SevenCard); ASSERT_TRUE(dealt->getDeal().has_value()); EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 1); EXPECT_EQ(dealt->basicDeal()->getStage(), Stage::Draw); @@ -176,21 +177,21 @@ TEST(Table, TheDealerChoosesAndTheSeatAfterOpens) { TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { const TableState table = opened(); - EXPECT_EQ(table.chooseVariant(1, Variant::Basic, deck()).status().code(), + EXPECT_EQ(table.chooseVariant(1, Variant::SevenCard, deck()).status().code(), absl::StatusCode::kFailedPrecondition); - auto away = table.chooseVariant(2, Variant::Basic, deck(), /*dealerAway=*/true); + auto away = table.chooseVariant(2, Variant::SevenCard, deck(), /*dealerAway=*/true); ASSERT_TRUE(away.ok()) << away.status(); // The dealer stays the dealer: the seat after them still opens. EXPECT_EQ(away->getDealer(), 0); EXPECT_EQ(away->basicDeal()->getWhoseTurn(), 1); - EXPECT_EQ(table.chooseVariant(7, Variant::Basic, deck(), true).status().code(), + EXPECT_EQ(table.chooseVariant(7, Variant::SevenCard, deck(), true).status().code(), absl::StatusCode::kInvalidArgument); } TEST(Table, NoChoosingWhileADealIsInPlay) { - auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); + auto dealt = opened().chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); - const absl::Status refused = dealt->chooseVariant(0, Variant::Basic, deck()).status(); + const absl::Status refused = dealt->chooseVariant(0, Variant::SevenCard, deck()).status(); EXPECT_EQ(refused.code(), absl::StatusCode::kFailedPrecondition); EXPECT_EQ(refused.message(), "not between deals"); } @@ -200,7 +201,7 @@ TEST(Table, DealMovesGoToTheDealAndNowhereElse) { EXPECT_EQ( table.inDeal([](const GameState& d) { return d.drawStock(1); }).status().code(), absl::StatusCode::kFailedPrecondition); - auto dealt = table.chooseVariant(0, Variant::Basic, deck()); + auto dealt = table.chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); auto drew = dealt->inDeal([](const GameState& d) { return d.drawStock(1); }); ASSERT_TRUE(drew.ok()) << drew.status(); @@ -239,7 +240,7 @@ TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { EXPECT_EQ(out->basicDeal()->winner(), "b"); EXPECT_FALSE(out->isOver()); // The next dealer is b; the seat after b opens. - auto next = out->chooseVariant(1, Variant::Basic, deck()); + auto next = out->chooseVariant(1, Variant::SevenCard, deck()); ASSERT_TRUE(next.ok()) << next.status(); EXPECT_EQ(next->getDealNumber(), out->getDealNumber() + 1); EXPECT_EQ(next->basicDeal()->getWhoseTurn(), 2); @@ -247,16 +248,16 @@ TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { } TEST(Table, TheDealWrapsRoundTheTable) { - const TableState last{{"a", "b"}, {0, 0}, 1, 3, TablePhase::Choosing, - Variant::Basic, std::nullopt, "T", ""}; - auto dealt = last.chooseVariant(1, Variant::Basic, deck()); + const TableState last{{"a", "b"}, {0, 0}, 1, 3, TablePhase::Choosing, + Variant::SevenCard, std::nullopt, "T", ""}; + auto dealt = last.chooseVariant(1, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 0); } TEST(Table, ALeaveWhileChoosingCompactsTheSeatsAndTheDealerFollows) { - const TableState choosing{{"a", "b", "c"}, {2, 1, 0}, 2, 3, TablePhase::Choosing, - Variant::Basic, std::nullopt, "T", ""}; + const TableState choosing{{"a", "b", "c"}, {2, 1, 0}, 2, 3, TablePhase::Choosing, + Variant::SevenCard, std::nullopt, "T", ""}; auto left = choosing.removePlayer(0); ASSERT_TRUE(left.ok()) << left.status(); EXPECT_EQ(left->getSeats(), (vector{"b", "c"})); @@ -266,8 +267,8 @@ TEST(Table, ALeaveWhileChoosingCompactsTheSeatsAndTheDealerFollows) { } TEST(Table, TheDealerLeavingPassesTheChoiceOn) { - const TableState choosing{{"a", "b", "c"}, {0, 0, 0}, 2, 1, TablePhase::Choosing, - Variant::Basic, std::nullopt, "T", ""}; + const TableState choosing{{"a", "b", "c"}, {0, 0, 0}, 2, 1, TablePhase::Choosing, + Variant::SevenCard, std::nullopt, "T", ""}; auto left = choosing.removePlayer(2); ASSERT_TRUE(left.ok()); // The seat after the leaver, wrapping. @@ -276,14 +277,14 @@ TEST(Table, TheDealerLeavingPassesTheChoiceOn) { TEST(Table, ADealerMidTableLeavingPassesTheChoiceToTheSeatAfter) { const TableState choosing{{"a", "b", "c", "d"}, {0, 0, 0, 0}, 1, 1, TablePhase::Choosing, - Variant::Basic, std::nullopt, "T", ""}; + Variant::SevenCard, std::nullopt, "T", ""}; auto left = choosing.removePlayer(1); ASSERT_TRUE(left.ok()); EXPECT_EQ(left->getSeats().at(left->getDealer()), "c"); } TEST(Table, ALeaveMidDealLeavesTheDealToo) { - auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); + auto dealt = opened().chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); auto left = dealt->removePlayer(2); ASSERT_TRUE(left.ok()) << left.status(); @@ -325,7 +326,7 @@ TEST(Table, TheDealerLeavingMidDealPassesTheNextDealToTheSeatAfter) { 2, 1, TablePhase::Playing, - Variant::Basic, + Variant::SevenCard, GameState{{Card{Suit::Clubs, Rank::Two}}, {Card{Suit::Clubs, Rank::Three}}, {{"a", {Card{Suit::Clubs, Rank::Four}}}, @@ -349,7 +350,7 @@ TEST(Table, TheDealerLeavingMidDealPassesTheNextDealToTheSeatAfter) { } TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { - auto dealt = opened({"a", "b"}).chooseVariant(0, Variant::Basic, deck()); + auto dealt = opened({"a", "b"}).chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); auto left = dealt->removePlayer(0); ASSERT_TRUE(left.ok()); @@ -360,14 +361,14 @@ TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { EXPECT_TRUE(choosing_left->isOver()); // Closed is closed. EXPECT_FALSE(left->removePlayer(0).ok()); - EXPECT_FALSE(left->chooseVariant(0, Variant::Basic, deck()).ok()); + EXPECT_FALSE(left->chooseVariant(0, Variant::SevenCard, deck()).ok()); } TEST(Table, SeatsAreFoundByIdAndTheIdsAreTheRowsToSet) { const TableState table = opened(); EXPECT_EQ(table.playerIndex("c"), 2); EXPECT_EQ(table.playerIndex("z"), -1); - auto dealt = table.chooseVariant(0, Variant::Basic, deck()); + auto dealt = table.chooseVariant(0, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); const TableState stamped = dealt->withIdAndVersion("G", "V"); EXPECT_EQ(stamped.getGameId(), "G"); diff --git a/domains/platform/apis/stats/games.go b/domains/platform/apis/stats/games.go index 8b723e2e..a8b8718e 100644 --- a/domains/platform/apis/stats/games.go +++ b/domains/platform/apis/stats/games.go @@ -62,7 +62,7 @@ var ( "chat_message": true, "geometry_changed": true, "game_started": true, "game_finished": true, } - hubVariants = map[string]bool{"golf": true, "castle": true, "rummy": true} + hubVariants = map[string]bool{"golf": true, "castle": true, "rummy": true, "gin": true} hubOutcomes = map[string]bool{"completed": true, "abandoned": true} hubSurfaces = map[string]bool{"plane": true, "sphere": true, "glasshouse": true} ) diff --git a/domains/platform/libs/otel_contract/BUILD.bazel b/domains/platform/libs/otel_contract/BUILD.bazel index 08798dc3..dd0fb34b 100644 --- a/domains/platform/libs/otel_contract/BUILD.bazel +++ b/domains/platform/libs/otel_contract/BUILD.bazel @@ -32,6 +32,7 @@ go_test( "//domains/games/apis/games_hub:surface.h", "//domains/games/apis/games_hub:world.h", "//domains/games/apis/one_d4:src/main/java/com/muchq/games/one_d4/api/QueryEvent.java", + "//domains/games/libs/cards/rummy:table.cc", "//domains/platform/apis/stats:aggregate.go", "//domains/platform/apis/stats:classify.go", "//domains/platform/apis/stats:games.go", diff --git a/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go b/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go index bbb67ce6..6ab620f7 100644 --- a/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go +++ b/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go @@ -29,6 +29,7 @@ const ( hubHostedGameH = "../../../games/apis/games_hub/hosted_game.h" hubGolfHubCc = "../../../games/apis/games_hub/golf_hub.cc" hubWorldH = "../../../games/apis/games_hub/world.h" + rummyTableCc = "../../../games/libs/cards/rummy/table.cc" statsGames = "../../apis/stats/games.go" dejaHub = "../../../ai/apis/deja/src/hub.rs" ) @@ -97,18 +98,7 @@ func TestHubEventValuesAgreeBetweenGamesHubAndStats(t *testing.T) { regexp.MustCompile(`hubOutcomes = map\[string\]bool\{([^}]*)\}`), "hubOutcomes"), "an ending the hub can write that stats would count as \"other\"") - // GameKindName: case GameKind::kCastle: return "castle"; ... return "golf"; - hosted, err := os.ReadFile(hubHostedGameH) - require.NoError(t, err) - kindName := regexp.MustCompile(`(?s)GameKindName\(GameKind kind\) \{(.*?)\n\}`). - FindSubmatch(hosted) - require.NotNil(t, kindName, "no GameKindName in %s", hubHostedGameH) - var fromKinds []string - for _, match := range regexp.MustCompile(`"([a-z_]+)"`).FindAllSubmatch(kindName[1], -1) { - fromKinds = append(fromKinds, string(match[1])) - } - sort.Strings(fromKinds) - assert.Equal(t, fromKinds, goWords(t, reader, + assert.Equal(t, gameWords(t), goWords(t, reader, regexp.MustCompile(`hubVariants = map\[string\]bool\{([^}]*)\}`), "hubVariants"), "a game the hub can host that stats would count as \"other\"") @@ -124,6 +114,32 @@ func TestHubEventValuesAgreeBetweenGamesHubAndStats(t *testing.T) { "a shape a room can be that stats would count as \"other\"") } +// Every word a game is recorded under (#1571), sorted: GameKindName's — +// case GameKind::kCastle: return "castle"; ... — and, since a rummy +// table's deals are recorded by variant, rummy::recordedName's. +func gameWords(t *testing.T) []string { + t.Helper() + words := map[string]bool{} + for _, site := range []struct{ path, function string }{ + {hubHostedGameH, `GameKindName\(GameKind kind\)`}, + {rummyTableCc, `recordedName\(Variant variant\)`}, + } { + source, err := os.ReadFile(site.path) + require.NoError(t, err) + body := regexp.MustCompile(`(?s)` + site.function + ` \{(.*?)\n\}`).FindSubmatch(source) + require.NotNil(t, body, "no %s in %s", site.function, site.path) + for _, match := range regexp.MustCompile(`"([a-z_]+)"`).FindAllSubmatch(body[1], -1) { + words[string(match[1])] = true + } + } + var sorted []string + for word := range words { + sorted = append(sorted, word) + } + sort.Strings(sorted) + return sorted +} + // The body of SurfaceKindName, which is where the three spellings live — // SurfaceJson renders them through it, so this is the declaration site. func surfaceKindNameBody(t *testing.T, source []byte) []byte { @@ -209,8 +225,6 @@ func TestHubEventVocabularyAgreesWithDeja(t *testing.T) { require.NoError(t, err) surface, err := os.ReadFile(hubSurfaceH) require.NoError(t, err) - hosted, err := os.ReadFile(hubHostedGameH) - require.NoError(t, err) reader, err := os.ReadFile(dejaHub) require.NoError(t, err) @@ -242,15 +256,7 @@ func TestHubEventVocabularyAgreesWithDeja(t *testing.T) { assert.Equal(t, surfaces, rustWords("SURFACES"), "a shape a room can be that deja would read as \"other\"") - kindName := regexp.MustCompile(`(?s)GameKindName\(GameKind kind\) \{(.*?)\n\}`). - FindSubmatch(hosted) - require.NotNil(t, kindName, "no GameKindName in %s", hubHostedGameH) - var kinds []string - for _, match := range regexp.MustCompile(`"([a-z_]+)"`).FindAllSubmatch(kindName[1], -1) { - kinds = append(kinds, string(match[1])) - } - sort.Strings(kinds) - assert.Equal(t, kinds, rustWords("VARIANTS"), + assert.Equal(t, gameWords(t), rustWords("VARIANTS"), "a game the hub can host that deja would read as \"other\"") } From 5afb675ffa16650d65eeb6f2f8744378ffb9cb33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:16:43 +0000 Subject: [PATCH 4/7] rummy: store 7-card as basic; pin panel's uncovered cases --- domains/games/apis/games_hub/README.md | 5 ++- .../games/apis/games_hub/rummy_wire_test.cc | 45 +++++++++++++++++++ domains/games/libs/cards/rummy/gin_test.cc | 8 ++++ domains/games/libs/cards/rummy/table_serde.cc | 15 +++++-- domains/games/libs/cards/rummy/table_serde.h | 2 +- .../libs/cards/rummy/table_serde_test.cc | 21 +++------ 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/domains/games/apis/games_hub/README.md b/domains/games/apis/games_hub/README.md index e4393dc8..b6070a2b 100644 --- a/domains/games/apis/games_hub/README.md +++ b/domains/games/apis/games_hub/README.md @@ -358,8 +358,9 @@ seat. The variants are `7-card` and `10-card` rummy (one engine, dealt seven or ten) and heads-up `gin` (`GinState`, #1610); the view's `choosing.options` lists the ones that fit the seats. Gin's reckoning — both hands arranged, the defender's lay-offs, the ending — rides -`lastDeal.gin`. Tables stored before the variants had names read -`basic` as `7-card`. A dealer the room shows as not connected lets any seat deal. Each +`lastDeal.gin`. A stored table spells +`7-card` as `basic`, its name before the others, so a rolled-back hub +still reads it. A dealer the room shows as not connected lets any seat deal. Each deal is a game to the room's stats and the event log, recorded as `rummy` or `gin`: its end credits the seat that went out with a win and every seat with a game played. Its diff --git a/domains/games/apis/games_hub/rummy_wire_test.cc b/domains/games/apis/games_hub/rummy_wire_test.cc index f5b5c7b6..3ca38fd2 100644 --- a/domains/games/apis/games_hub/rummy_wire_test.cc +++ b/domains/games/apis/games_hub/rummy_wire_test.cc @@ -437,6 +437,51 @@ TEST_F(RummyWireTest, GinUndercut) { EXPECT_EQ(between["lastDeal"]["points"], 35); } +// The dealer can knock too: player-1, dealing, takes the 9♠ player-2 +// passed and knocks on A♦ with 9-A♠ and 10-K♦ — gin, 25, and player-2's +// 10-A runs leave nothing to add. +TEST_F(RummyWireTest, TheDealerKnocksFromSeatZero) { + std::shared_ptr creator; + std::shared_ptr joiner; + ChoosingTable(creator, joiner); + ASSERT_TRUE( + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"gin"}}})")).ok()); + for (int i = 0; i < 3; ++i) (void)NextFrame(*creator); + for (int i = 0; i < 3; ++i) (void)NextFrame(*joiner); + + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"pass":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + (void)EventPayload(NextFrame(*creator), "rummy"); // turnChanged + ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"drawDiscard":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + ASSERT_TRUE( + creator->Send(CommandFrame("rummy", R"({"move":{"knock":{"card":{"rank":"A","suit":"♦"}}}})")) + .ok()); + const json between = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + const json& gin = between["lastDeal"]["gin"]; + EXPECT_EQ(gin["ending"], "gin"); + EXPECT_EQ(gin["knocker"], "player-1"); + EXPECT_EQ(between["lastDeal"]["winner"], "player-1"); + EXPECT_EQ(between["lastDeal"]["points"], 25); +} + +// Gin's moves are not rummy's: a pass or a knock at a 7-card table is +// refused by the table's game, as a meld at a gin table is. +TEST_F(RummyWireTest, GinMovesAtASevenCardTableAreRefused) { + std::shared_ptr creator; + std::shared_ptr joiner; + DealtAndRead(creator, joiner); + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"pass":{}}})")).ok()); + EXPECT_EQ(EventPayload(NextFrame(*joiner), "commandRejected"), + R"({"reason":"not a move in 7-card"})"); + ASSERT_TRUE( + joiner->Send(CommandFrame("rummy", R"({"move":{"knock":{"card":{"rank":"A","suit":"♥"}}}})")) + .ok()); + EXPECT_EQ(EventPayload(NextFrame(*joiner), "commandRejected"), + R"({"reason":"not a move in 7-card"})"); +} + TEST_F(RummyWireTest, StockDrawAndDiscardSpellings) { std::shared_ptr creator; std::shared_ptr joiner; diff --git a/domains/games/libs/cards/rummy/gin_test.cc b/domains/games/libs/cards/rummy/gin_test.cc index c3c82ce2..a5e8e3ac 100644 --- a/domains/games/libs/cards/rummy/gin_test.cc +++ b/domains/games/libs/cards/rummy/gin_test.cc @@ -196,6 +196,14 @@ TEST(GinKnock, MoreThanTenDeadwoodCannotKnock) { EXPECT_EQ(over.message(), "more than 10 deadwood: no knock"); } +TEST(GinKnock, ExactlyTenMayKnock) { + vector hand = knocker(); + hand[9] = c(Rank::Ten, Suit::Diamonds); // 10♦ for 2♦ + auto state = playing(hand, {c(Rank::Ace, Suit::Clubs)}, someStock(10), {}, 0, GinStage::Play); + // Throwing the 9♦ leaves the 10♦: ten, the most a knock may carry. + EXPECT_TRUE(state.knock(0, c(Rank::Nine, Suit::Diamonds)).ok()); +} + // No deadwood: gin. Bob may not lay off, and alice scores 25 and all he // holds. TEST(GinKnock, GinScoresTheBonusAndBlocksLayOffs) { diff --git a/domains/games/libs/cards/rummy/table_serde.cc b/domains/games/libs/cards/rummy/table_serde.cc index 0672d73a..4fc113d9 100644 --- a/domains/games/libs/cards/rummy/table_serde.cc +++ b/domains/games/libs/cards/rummy/table_serde.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "absl/status/status.h" @@ -19,6 +20,15 @@ using nlohmann::json; constexpr int kSchemaVersion = 2; +// Seven-card is stored under "basic", its name before 10-card and gin +// (#1610): a hub rolled back past them still reads every seven-card table. +// The others are stored as the wire spells them. +constexpr std::string_view kSevenCardStored = "basic"; + +std::string_view storedName(Variant variant) { + return variant == Variant::SevenCard ? kSevenCardStored : variantName(variant); +} + // postgres jsonb rejects a NUL byte: U+FFFD, as the deal's serde does. std::string sanitized(const std::string& text) { std::string safe; @@ -96,7 +106,7 @@ std::string serializeTableState(const TableState& table) { {"wins", table.getWins()}, {"dealer", table.getDealer()}, {"dealNumber", table.getDealNumber()}, - {"variant", std::string(variantName(table.getVariant()))}, + {"variant", std::string(storedName(table.getVariant()))}, }; // The deal in its own engine's form; the variant says which. if (const GameState* basic = table.basicDeal(); basic != nullptr) { @@ -130,9 +140,8 @@ absl::StatusOr deserializeTableState(const std::string& serialized) auto variant_name = readString(parsed, "variant"); if (!variant_name.ok()) return variant_name.status(); - // "basic" is seven-card's name in rows written before it had its own. const std::optional variant = - *variant_name == "basic" ? Variant::SevenCard : parseVariant(*variant_name); + *variant_name == kSevenCardStored ? Variant::SevenCard : parseVariant(*variant_name); if (!variant.has_value()) return absl::InvalidArgumentError("unknown variant"); if (!parsed.contains("seats") || !parsed["seats"].is_array()) { diff --git a/domains/games/libs/cards/rummy/table_serde.h b/domains/games/libs/cards/rummy/table_serde.h index 17cb4584..50831dfd 100644 --- a/domains/games/libs/cards/rummy/table_serde.h +++ b/domains/games/libs/cards/rummy/table_serde.h @@ -14,7 +14,7 @@ namespace rummy { /// Schema v2 — v1 (game_state_serde.h) is a lone deal, which is what a /// rummy row held before the table: /// {"v":2, "phase":"choosing"|"playing"|"closed", "seats":[str...], -/// "wins":[int...], "dealer":int, "dealNumber":int, "variant":"7-card", +/// "wins":[int...], "dealer":int, "dealNumber":int, "variant":"basic", /// "deal":} /// deal is absent before the first deal. A v1 row reads as that deal at a /// table of its seats: in play, dealt by the seat before the one on turn, diff --git a/domains/games/libs/cards/rummy/table_serde_test.cc b/domains/games/libs/cards/rummy/table_serde_test.cc index 16974c37..8db5c43a 100644 --- a/domains/games/libs/cards/rummy/table_serde_test.cc +++ b/domains/games/libs/cards/rummy/table_serde_test.cc @@ -103,7 +103,7 @@ TEST(TableSerde, EveryVariantsDealRoundTrips) { basicAsGin["variant"] = "gin"; expectRejected(basicAsGin); json ginAsBasic = payload; - ginAsBasic["variant"] = "7-card"; + ginAsBasic["variant"] = "basic"; expectRejected(ginAsBasic); } @@ -117,15 +117,18 @@ TEST(TableSerde, EveryPhaseRoundTrips) { } // The exact bytes of a table opened and not yet dealt. A change to the -// shape is a schema change: a version bump, not an edit here. +// shape is a schema change: a version bump, not an edit here. Seven-card +// is stored as "basic", its name before 10-card and gin (#1610), so a +// hub rolled back past them still reads every seven-card table. TEST(TableSerde, FrozenPayload) { constexpr const char* kRow = R"({"dealNumber":0,"dealer":0,"phase":"choosing","seats":["a","b"],"v":2,)" - R"("variant":"7-card","wins":[0,0]})"; + R"("variant":"basic","wins":[0,0]})"; EXPECT_EQ(serializeTableState(opened()), kRow); const auto restored = deserializeTableState(kRow); ASSERT_TRUE(restored.ok()) << restored.status(); EXPECT_EQ(restored->getPhase(), TablePhase::Choosing); + EXPECT_EQ(restored->getVariant(), Variant::SevenCard); // The deal nests as the v1 deal it is, so the deal's own schema pins it. const json dealt = payloadOf(playing()); EXPECT_EQ(dealt["phase"], "playing"); @@ -133,18 +136,6 @@ TEST(TableSerde, FrozenPayload) { EXPECT_EQ(dealt["dealNumber"], 1); } -// Seven-card rummy was stored as "basic" before it had a name of its own; -// such a row reads as seven-card, and is written back under the new word. -TEST(TableSerde, AStoredBasicTableIsSevenCard) { - constexpr const char* kRow = - R"({"dealNumber":0,"dealer":0,"phase":"choosing","seats":["a","b"],"v":2,)" - R"("variant":"basic","wins":[0,0]})"; - const auto restored = deserializeTableState(kRow); - ASSERT_TRUE(restored.ok()) << restored.status(); - EXPECT_EQ(restored->getVariant(), Variant::SevenCard); - EXPECT_EQ(payloadOf(*restored)["variant"], "7-card"); -} - // A row from before the table (#1608) is one deal: it reads as that deal // in play at a table of its seats, dealt by the seat before the one on // turn, or as a closed table if the deal had ended — a finished row stays From 53cc0ab0f4dc8a88ebceeecd587f5d8550775484 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:50:24 +0000 Subject: [PATCH 5/7] docs: no scheduled check-ins on PRs --- CLAUDE.md | 4 +++- docs/WORKING_AGREEMENT.md | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe0af9f2..d3e9ec3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Sources live under `domains//{libs,apis,apps}`. picked up, reviewed, verified, and shipped here. It is the governing process document; the highlights below are pointers, not a substitute. -The three rules that change behavior most: +The rules that change behavior most: - **A test beats an argument, and it lands first.** If a behavior is worth reasoning about, the deliverable is a CI test, not a paragraph — written @@ -22,6 +22,8 @@ The three rules that change behavior most: each refuting its own findings. Landing first makes the panel's work visible as commits on a judged baseline rather than invisible inside the first diff. If it didn't run, say so rather than letting the reader assume it did. +- **Never schedule check-ins on a PR** — hourly or otherwise. Subscribe to its + activity and end the turn; events wake the session. - **Write the conclusion, not the journey.** Comments state the live rule and never narrate deleted code. A commit subject is ten words at most and most commits need no body at all; squash-merge concatenates them onto `main`, so diff --git a/docs/WORKING_AGREEMENT.md b/docs/WORKING_AGREEMENT.md index deba0449..d3a08484 100644 --- a/docs/WORKING_AGREEMENT.md +++ b/docs/WORKING_AGREEMENT.md @@ -114,6 +114,11 @@ have a reason to record instead of an assumption. the PR only on request. Reference the tracking issue and, when the issue is a checklist, tick the item once merged. +**No scheduled check-ins on a PR.** Never schedule an hourly check-in — or +any timed one — to poll a PR's CI, mergeability or review threads. Subscribe +to the PR's activity and end the turn: its events wake the session, and a PR +with nothing happening on it waits for its owner, not for a timer. + **Update the tracking issue.** Fold new data — reproductions, measurements, scope corrections — back into the issue so it stays the source of truth. File follow-ups for what you deliberately left out rather than leaving it implicit. From a73993ac10f8debfde4b15c9c47045255cc18ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:58:38 +0000 Subject: [PATCH 6/7] rummy: pin the ace high or low, never wrapping, per variant --- .../games/libs/cards/rummy/game_state_test.cc | 50 +++++++++++++++++ domains/games/libs/cards/rummy/gin.h | 2 + domains/games/libs/cards/rummy/gin_test.cc | 54 +++++++++++++++++++ domains/games/libs/cards/rummy/table_test.cc | 44 +++++++++++++-- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/domains/games/libs/cards/rummy/game_state_test.cc b/domains/games/libs/cards/rummy/game_state_test.cc index 93ea7588..0a7e54fa 100644 --- a/domains/games/libs/cards/rummy/game_state_test.cc +++ b/domains/games/libs/cards/rummy/game_state_test.cc @@ -314,6 +314,56 @@ TEST(LayOff, NotBeforeTheDraw) { absl::StatusCode::kFailedPrecondition); } +// --- The ace: low under the two or high over the king, never both --- + +TEST(Ace, MeldsAtEitherEndOfARunButNeverAroundTheCorner) { + auto game = playing( + {{"alice", + {c(Rank::Queen), c(Rank::King), c(Rank::Ace), c(Rank::Ace, Suit::Hearts), + c(Rank::Two, Suit::Hearts), c(Rank::Three, Suit::Hearts), c(Rank::King, Suit::Spades), + c(Rank::Ace, Suit::Spades), c(Rank::Two, Suit::Spades), c(Rank::Nine, Suit::Diamonds)}}, + {"bob", {c(Rank::Three, Suit::Diamonds)}}}, + {}, {c(Rank::Two)}, 0, Stage::Play); + auto high = game.meld(0, {c(Rank::Queen), c(Rank::King), c(Rank::Ace)}); + ASSERT_TRUE(high.ok()) << high.status(); + auto low = high->meld( + 0, {c(Rank::Ace, Suit::Hearts), c(Rank::Two, Suit::Hearts), c(Rank::Three, Suit::Hearts)}); + ASSERT_TRUE(low.ok()) << low.status(); + EXPECT_EQ(low->meld(0, {c(Rank::King, Suit::Spades), c(Rank::Ace, Suit::Spades), + c(Rank::Two, Suit::Spades)}) + .status() + .code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(Ace, LaysOffAtEitherEndButNeverAroundTheCorner) { + auto game = playing( + {{"alice", + {c(Rank::Ace), c(Rank::Ace, Suit::Hearts), c(Rank::Two, Suit::Spades), + c(Rank::King, Suit::Diamonds), c(Rank::Nine, Suit::Diamonds)}}, + {"bob", {c(Rank::Three, Suit::Diamonds)}}}, + {}, {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Jack), c(Rank::Queen), c(Rank::King)}}, + {"bob", + {c(Rank::Two, Suit::Hearts), c(Rank::Three, Suit::Hearts), c(Rank::Four, Suit::Hearts)}}, + {"bob", + {c(Rank::Queen, Suit::Spades), c(Rank::King, Suit::Spades), c(Rank::Ace, Suit::Spades)}}, + {"bob", + {c(Rank::Ace, Suit::Diamonds), c(Rank::Two, Suit::Diamonds), + c(Rank::Three, Suit::Diamonds)}}}); + auto high = game.layOff(0, c(Rank::Ace), 0); + ASSERT_TRUE(high.ok()) << high.status(); + EXPECT_EQ(high->getMelds()[0].cards.back(), c(Rank::Ace)); + auto low = high->layOff(0, c(Rank::Ace, Suit::Hearts), 1); + ASSERT_TRUE(low.ok()) << low.status(); + EXPECT_EQ(low->getMelds()[1].cards.front(), c(Rank::Ace, Suit::Hearts)); + // A two after the high ace, or a king under the low one, wraps. + EXPECT_EQ(low->layOff(0, c(Rank::Two, Suit::Spades), 2).status().code(), + absl::StatusCode::kInvalidArgument); + EXPECT_EQ(low->layOff(0, c(Rank::King, Suit::Diamonds), 3).status().code(), + absl::StatusCode::kInvalidArgument); +} + // --- The discard --- TEST(Discard, EndsTheTurnAtTheNextSeatsDraw) { diff --git a/domains/games/libs/cards/rummy/gin.h b/domains/games/libs/cards/rummy/gin.h index bb1d5494..71f426bb 100644 --- a/domains/games/libs/cards/rummy/gin.h +++ b/domains/games/libs/cards/rummy/gin.h @@ -28,6 +28,8 @@ namespace rummy { /// left with 10 or less deadwood in their best arrangement /// (bestArrangement). The defender's hand is arranged too, laying cards /// off onto the knocker's melds (bestWithLayOffs) — not after gin. +/// - Melds are rummy's (arrangedMeld): the ace runs low under the two +/// or high over the king, never around the corner. /// - Scoring: a knock scores the knocker the deadwood difference; gin /// (no deadwood) scores 25 plus the defender's deadwood; a defender /// with deadwood no more than the knocker's undercuts, scoring 25 plus diff --git a/domains/games/libs/cards/rummy/gin_test.cc b/domains/games/libs/cards/rummy/gin_test.cc index a5e8e3ac..b107efbf 100644 --- a/domains/games/libs/cards/rummy/gin_test.cc +++ b/domains/games/libs/cards/rummy/gin_test.cc @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -254,6 +255,59 @@ TEST(GinKnock, ADefenderWithNoMoreDeadwoodUndercuts) { EXPECT_EQ(under->getResult()->points, 25 + 7); } +// --- The ace: low under the two or high over the king, never both --- + +// Q-K-A is a run and leaves the 2♦: a knock. K-A-2 is not, and leaves 15. +TEST(GinAce, RunsAtEitherEndButNeverAroundTheCorner) { + const vector sets = {c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Diamonds), + c(Rank::Seven, Suit::Hearts), c(Rank::Three, Suit::Hearts), + c(Rank::Four, Suit::Hearts), c(Rank::Five, Suit::Hearts), + c(Rank::Two, Suit::Diamonds), c(Rank::King, Suit::Diamonds)}; + vector high = {c(Rank::Queen, Suit::Spades), c(Rank::King, Suit::Spades), + c(Rank::Ace, Suit::Spades)}; + high.insert(high.end(), sets.begin(), sets.end()); + EXPECT_TRUE(playing(high, {c(Rank::Ace, Suit::Clubs)}, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::King, Suit::Diamonds)) + .ok()); + vector around = {c(Rank::King, Suit::Spades), c(Rank::Ace, Suit::Spades), + c(Rank::Two, Suit::Spades)}; + around.insert(around.end(), sets.begin(), sets.end()); + EXPECT_EQ(playing(around, {c(Rank::Ace, Suit::Clubs)}, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::King, Suit::Diamonds)) + .status() + .message(), + "more than 10 deadwood: no knock"); +} + +// Alice knocks with J-Q-K♣, 2-3-4♥ and Q-K-A♦, the 5♠ over. Bob lays A♣ +// off high and A♥ low; his 2♦ would go around the corner, so it stays: 8 +// against her 5. +TEST(GinAce, LaysOffAtEitherEndButNeverAroundTheCorner) { + const vector alice = { + c(Rank::Jack, Suit::Clubs), c(Rank::Queen, Suit::Clubs), c(Rank::King, Suit::Clubs), + c(Rank::Two, Suit::Hearts), c(Rank::Three, Suit::Hearts), c(Rank::Four, Suit::Hearts), + c(Rank::Queen, Suit::Diamonds), c(Rank::King, Suit::Diamonds), c(Rank::Ace, Suit::Diamonds), + c(Rank::Five, Suit::Spades), c(Rank::Nine, Suit::Spades)}; + const vector bob = {c(Rank::Ace, Suit::Clubs), c(Rank::Ace, Suit::Hearts), + c(Rank::Two, Suit::Diamonds), c(Rank::Eight, Suit::Spades), + c(Rank::Eight, Suit::Hearts), c(Rank::Eight, Suit::Clubs), + c(Rank::Ten, Suit::Spades), c(Rank::Ten, Suit::Hearts), + c(Rank::Ten, Suit::Diamonds), c(Rank::Six, Suit::Spades)}; + auto knocked = playing(alice, bob, someStock(10), {}, 0, GinStage::Play) + .knock(0, c(Rank::Nine, Suit::Spades)); + ASSERT_TRUE(knocked.ok()) << knocked.status(); + const GinResult& result = *knocked->getResult(); + vector laidOff = result.laidOff; + std::sort(laidOff.begin(), laidOff.end(), + [](const Card& a, const Card& b) { return a.getSuit() < b.getSuit(); }); + vector expected = {c(Rank::Ace, Suit::Clubs), c(Rank::Ace, Suit::Hearts)}; + std::sort(expected.begin(), expected.end(), + [](const Card& a, const Card& b) { return a.getSuit() < b.getSuit(); }); + EXPECT_EQ(laidOff, expected); + EXPECT_EQ(result.ending, GinEnding::Knock); + EXPECT_EQ(result.points, 3); +} + TEST(GinStock, ADiscardLeavingTwoInTheStockDrawsTheDeal) { auto state = playing(knocker(), {c(Rank::King, Suit::Clubs)}, someStock(3), {c(Rank::Six, Suit::Clubs)}, 0, GinStage::Draw); diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 1d1b0a8a..49c11a3d 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -27,10 +27,10 @@ TableState opened(vector seats = {"a", "b", "c"}) { return *table; } -// The table with this deal in play. -TableState withDeal(const TableState& table, GameState deal) { +// The table with this deal of `variant` in play. +TableState withDeal(const TableState& table, GameState deal, Variant variant = Variant::SevenCard) { return TableState{table.getSeats(), table.getWins(), table.getDealer(), - table.getDealNumber(), TablePhase::Playing, Variant::SevenCard, + table.getDealNumber(), TablePhase::Playing, variant, std::move(deal), table.getGameId(), table.getVersionId()}; } @@ -93,6 +93,44 @@ TEST(Variants, AMoveOfTheWrongGameIsRefused) { EXPECT_EQ(passed->ginDeal()->getWhoseTurn(), 0); } +// 10-card plays 7-card's melds: the ace runs high or low, never around. +TEST(Variants, TenCardsAceRunsAtEitherEndButNeverAroundTheCorner) { + const auto card = [](Rank rank, Suit suit) { return Card{suit, rank}; }; + GameState deal{{card(Rank::Two, Suit::Hearts)}, + {card(Rank::Three, Suit::Hearts)}, + {{"a", + {card(Rank::Queen, Suit::Clubs), card(Rank::King, Suit::Clubs), + card(Rank::Ace, Suit::Clubs), card(Rank::Ace, Suit::Diamonds), + card(Rank::Two, Suit::Diamonds), card(Rank::Three, Suit::Diamonds), + card(Rank::King, Suit::Spades), card(Rank::Ace, Suit::Spades), + card(Rank::Two, Suit::Spades), card(Rank::Nine, Suit::Hearts), + card(Rank::Ten, Suit::Hearts)}}, + {"b", {card(Rank::Four, Suit::Hearts)}}, + {"c", {card(Rank::Five, Suit::Hearts)}}}, + {}, + 0, + Stage::Play, + Phase::Playing, + std::nullopt, + "T1", + ""}; + const TableState ten = withDeal(opened(), deal, Variant::TenCard); + const auto meld = [&](const TableState& table, vector cards) { + return table.inDeal([&](const GameState& d) { return d.meld(0, cards); }); + }; + auto high = meld(ten, {card(Rank::Queen, Suit::Clubs), card(Rank::King, Suit::Clubs), + card(Rank::Ace, Suit::Clubs)}); + ASSERT_TRUE(high.ok()) << high.status(); + auto low = meld(*high, {card(Rank::Ace, Suit::Diamonds), card(Rank::Two, Suit::Diamonds), + card(Rank::Three, Suit::Diamonds)}); + ASSERT_TRUE(low.ok()) << low.status(); + EXPECT_EQ(meld(*low, {card(Rank::King, Suit::Spades), card(Rank::Ace, Suit::Spades), + card(Rank::Two, Suit::Spades)}) + .status() + .code(), + absl::StatusCode::kInvalidArgument); +} + // A gin deal ends like any other: its winner takes the hand, the deal // passes on. A drawn one credits nobody and still passes on. TEST(Variants, AGinDealsEndPassesTheDealOn) { From 36333e328b3c1ef053dcfd49f8ab0e17f162869f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:06:53 +0000 Subject: [PATCH 7/7] rummy: gin rows need a move; pin gin's event word --- domains/games/apis/games_hub/BUILD.bazel | 2 + .../games/apis/games_hub/game_events_test.cc | 31 ++++++++++++++- domains/games/apis/games_hub/golf_hub.cc | 2 +- domains/games/apis/games_hub/hub_e2e_test.cc | 4 +- domains/games/libs/cards/rummy/README.md | 11 ++++-- domains/games/libs/cards/rummy/gin_serde.cc | 38 +++++++++++++------ .../games/libs/cards/rummy/gin_serde_test.cc | 18 +++++++++ domains/games/libs/cards/rummy/table.h | 2 +- domains/games/libs/cards/rummy/table_serde.cc | 2 +- .../libs/cards/rummy/table_serde_test.cc | 8 ++-- domains/games/libs/cards/rummy/table_test.cc | 30 +++++++-------- 11 files changed, 107 insertions(+), 41 deletions(-) diff --git a/domains/games/apis/games_hub/BUILD.bazel b/domains/games/apis/games_hub/BUILD.bazel index 7a5cd97d..440be6f1 100644 --- a/domains/games/apis/games_hub/BUILD.bazel +++ b/domains/games/apis/games_hub/BUILD.bazel @@ -124,11 +124,13 @@ cc_test( ":id_generator", ":world", "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", "//domains/games/libs/cards/castle:game_state", "//domains/games/libs/cards/castle:player", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/golf:player", "//domains/games/libs/cards/rummy:game_state", + "//domains/games/libs/cards/rummy:gin", "//domains/games/libs/cards/rummy:table", "@com_google_absl//absl/status:statusor", "@com_google_absl//absl/time", diff --git a/domains/games/apis/games_hub/game_events_test.cc b/domains/games/apis/games_hub/game_events_test.cc index ab1c07d1..e45abea8 100644 --- a/domains/games/apis/games_hub/game_events_test.cc +++ b/domains/games/apis/games_hub/game_events_test.cc @@ -17,9 +17,11 @@ #include "domains/games/libs/cards/card.h" #include "domains/games/libs/cards/castle/game_state.h" #include "domains/games/libs/cards/castle/player.h" +#include "domains/games/libs/cards/dealer.h" #include "domains/games/libs/cards/golf/game_state.h" #include "domains/games/libs/cards/golf/player.h" #include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/gin.h" #include "domains/games/libs/cards/rummy/table.h" #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -146,6 +148,31 @@ TEST(GameEvents, ARummyDealPlayedOutIsCompleted) { EXPECT_EQ(finished->players, 2u); } +/// A gin deal (#1610) played out: from the pristine deck mercy, opening, +/// holds two runs; both pass, she draws the 9♥ and goes gin on it. +rummy::TableState GinDealWon() { + auto dealt = + rummy::TableState::open("game", {"andy", "mercy"}) + ->chooseVariant(0, rummy::Variant::Gin, cards::NoShuffleDealer().DealNewUnshuffledDeck()); + EXPECT_TRUE(dealt.ok()) << dealt.status(); + auto won = dealt->inDeal([](const rummy::GinState& deal) { + auto drew = deal.pass(1)->pass(0)->drawStock(1); + return drew->knock(1, drew->getPlayer(1).hand.back()); + }); + EXPECT_TRUE(won.ok()) << won.status(); + return *won; +} + +// A gin deal is a game of its own: recorded as "gin", not "rummy". +TEST(GameEvents, AGinDealPlayedOutIsCompletedAsGin) { + const rummy::TableState table = GinDealWon(); + ASSERT_EQ(table.getPhase(), rummy::TablePhase::Choosing); + const auto finished = FinishedOf(HostedState(table), 2); + ASSERT_TRUE(finished.has_value()); + EXPECT_EQ(finished->variant, "gin"); + EXPECT_EQ(finished->outcome, "completed"); +} + TEST(GameEvents, ARummyDealBrokenUpByALeaveIsAbandoned) { const absl::StatusOr gone = Rummy().removePlayer(1); ASSERT_TRUE(gone.ok()) << gone.status(); @@ -243,6 +270,7 @@ TEST(GameEvents, EveryEventsLineIsTextWithNothingToEscape) { lines.emplace_back(GameStartedLine(when, room, "golf", 2), 16); lines.emplace_back(GameStartedLine(when, room, "castle", 4), 16); lines.emplace_back(GameStartedLine(when, room, "rummy", 3), 16); + lines.emplace_back(GameStartedLine(when, room, "gin", 2), 16); std::vector endings; endings.emplace_back(Golf(0)); @@ -251,13 +279,14 @@ TEST(GameEvents, EveryEventsLineIsTextWithNothingToEscape) { endings.emplace_back(*Castle().removePlayer(1)); endings.emplace_back(RummyDealWon()); endings.emplace_back(*Rummy().removePlayer(1)); + endings.emplace_back(GinDealWon()); for (const HostedState& state : endings) { for (std::size_t players = 1; players <= 4; ++players) { const GameFinished finished = *FinishedOf(state, players); EXPECT_TRUE(finished.outcome == kOutcomeCompleted || finished.outcome == kOutcomeAbandoned) << finished.outcome; EXPECT_TRUE(finished.variant == "golf" || finished.variant == "castle" || - finished.variant == "rummy") + finished.variant == "rummy" || finished.variant == "gin") << finished.variant; lines.emplace_back(GameFinishedLine(when, room, finished), 20); } diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index 86cf2fac..27878c38 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -3273,7 +3273,7 @@ moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, } }, *deal); - if (const rummy::GameState* basic = table.basicDeal(); basic != nullptr) { + if (const rummy::GameState* basic = table.rummyDeal(); basic != nullptr) { if (!shown) { view.stage = basic->getStage() == rummy::Stage::Draw ? "draw" : "play"; view.canDrawDiscard = diff --git a/domains/games/apis/games_hub/hub_e2e_test.cc b/domains/games/apis/games_hub/hub_e2e_test.cc index f435050e..e11349f7 100644 --- a/domains/games/apis/games_hub/hub_e2e_test.cc +++ b/domains/games/apis/games_hub/hub_e2e_test.cc @@ -2733,8 +2733,8 @@ TEST_F(GameEventFixture, ABiggerTableIsRecordedAtTheSizeItWasDealt) { // A rummy table is dealer's choice (#1609): seating it is no game, and // each deal is one — started when the dealer deals, finished when a seat -// goes out — recorded as rummy whatever the deal's variant is called on -// the wire. +// goes out — recorded as rummy at either hand size (gin's deals are +// "gin"; game_events_test pins that). TEST_F(GameEventFixture, ARummyTableRecordsEachDealAsAGame) { using moonbase::games::RummyMove; auto table = SeatedRummyTable(); // the first deal dealt by bob diff --git a/domains/games/libs/cards/rummy/README.md b/domains/games/libs/cards/rummy/README.md index bf0b6ce0..b9217849 100644 --- a/domains/games/libs/cards/rummy/README.md +++ b/domains/games/libs/cards/rummy/README.md @@ -19,10 +19,13 @@ or high but never both. bazel test //domains/games/libs/cards/rummy/... ``` -`game_state_serde.{h,cc}` is the versioned JSON the games hub stores a -rummy table in — the engine's full truth, stock and every hand included, -so it is server-side only; redaction stays in the hub. -`game_state_serde_test.cc` pins the schema. +`table_serde.{h,cc}` is the versioned JSON the games hub stores a table +in: the seats, wins and dealer, and the deal in play or just ended, nested +in its own engine's schema — `game_state_serde` for 7-card and 10-card, +`gin_serde` for gin. 7-card is stored as `basic`, its name before the +others. Each is the engine's full truth, stock and every hand included, +so it is server-side only; redaction stays in the hub. The `*_serde_test.cc` +files pin the schemas. Gin's rules are `GinState`'s, in `gin.h`, pinned in `gin_test.cc`. Its reckoning needs each hand's best arrangement: `arrange.h`'s search, which diff --git a/domains/games/libs/cards/rummy/gin_serde.cc b/domains/games/libs/cards/rummy/gin_serde.cc index 010c3598..15414c18 100644 --- a/domains/games/libs/cards/rummy/gin_serde.cc +++ b/domains/games/libs/cards/rummy/gin_serde.cc @@ -204,6 +204,11 @@ absl::StatusOr deserializeGinState(const std::string& serialized) { if (!id.ok()) return id.status(); auto hand = readCardField(seat, "hand"); if (!hand.ok()) return hand.status(); + // Ten and the card drawn: what the engine deals and what arranging a + // hand is sized for. + if (hand->size() > GinState::kHandSize + 1) { + return absl::InvalidArgumentError("a hand holds at most eleven cards"); + } players.push_back(Player{*std::move(id), *std::move(hand)}); } auto stage = readName(parsed, "stage", kStages); @@ -277,18 +282,27 @@ absl::StatusOr deserializeGinState(const std::string& serialized) { if ((*phase == Phase::Over) != result.has_value()) { return absl::InvalidArgumentError("a deal over by play has its result"); } - return GinState{std::deque(stock->begin(), stock->end()), - *std::move(discard), - std::move(players), - *turn, - *stage, - *phase, - *passes, - taken, - std::move(lastMove), - std::move(result), - "", - ""}; + GinState state{std::deque(stock->begin(), stock->end()), + *std::move(discard), + std::move(players), + *turn, + *stage, + *phase, + *passes, + taken, + std::move(lastMove), + std::move(result), + "", + ""}; + // A deal in play has a move to make: something to take or draw before + // the turn's throw, a card to throw after it. A row without one would + // hold both seats at "not your turn" until they left. + if (*phase == Phase::Playing) { + const bool stuck = *stage == GinStage::Play ? state.getPlayer(*turn).hand.empty() + : !state.canDrawStock() && !state.canDrawDiscard(); + if (stuck) return absl::InvalidArgumentError("a playing row with no move to make"); + } + return state; } } // namespace rummy diff --git a/domains/games/libs/cards/rummy/gin_serde_test.cc b/domains/games/libs/cards/rummy/gin_serde_test.cc index a3758946..721c5eb4 100644 --- a/domains/games/libs/cards/rummy/gin_serde_test.cc +++ b/domains/games/libs/cards/rummy/gin_serde_test.cc @@ -126,6 +126,24 @@ TEST(GinSerde, RejectsWhatTheEngineCouldNotPlay) { payload = base; payload["lastMove"]["kind"] = "meld"; expectRejected(payload); + // No hand holds more than ten and the drawn card; the search that + // arranges a hand is sized for that. + payload = base; + for (int card = 0; card < 12; card++) payload["players"][1]["hand"].push_back(card); + expectRejected(payload); + // A playing row has a move to make: something to take or draw at the + // upcard or the draw, a card to throw mid-turn. + const json upcard = json::parse(serializeGinState(dealt())); + payload = upcard; + payload["discard"] = json::array(); + expectRejected(payload); + const json stockOnly = json::parse(serializeGinState(*dealt().pass(1)->pass(0))); + payload = stockOnly; + payload["stock"] = json::array(); + expectRejected(payload); + payload = base; + payload["players"][0]["hand"] = json::array(); + expectRejected(payload); const json over = json::parse(serializeGinState(ginned())); payload = over; diff --git a/domains/games/libs/cards/rummy/table.h b/domains/games/libs/cards/rummy/table.h index ebe83254..228aa65f 100644 --- a/domains/games/libs/cards/rummy/table.h +++ b/domains/games/libs/cards/rummy/table.h @@ -109,7 +109,7 @@ class TableState { /// The deal in play, or the one that just ended; absent before the first. [[nodiscard]] const std::optional& getDeal() const { return deal; } /// The deal as GameState or GinState, if it is that engine's. - [[nodiscard]] const GameState* basicDeal() const { + [[nodiscard]] const GameState* rummyDeal() const { return deal.has_value() ? std::get_if(&*deal) : nullptr; } [[nodiscard]] const GinState* ginDeal() const { diff --git a/domains/games/libs/cards/rummy/table_serde.cc b/domains/games/libs/cards/rummy/table_serde.cc index 4fc113d9..25c4edae 100644 --- a/domains/games/libs/cards/rummy/table_serde.cc +++ b/domains/games/libs/cards/rummy/table_serde.cc @@ -109,7 +109,7 @@ std::string serializeTableState(const TableState& table) { {"variant", std::string(storedName(table.getVariant()))}, }; // The deal in its own engine's form; the variant says which. - if (const GameState* basic = table.basicDeal(); basic != nullptr) { + if (const GameState* basic = table.rummyDeal(); basic != nullptr) { serialized["deal"] = json::parse(serializeGameState(*basic)); } else if (const GinState* gin = table.ginDeal(); gin != nullptr) { serialized["deal"] = json::parse(serializeGinState(*gin)); diff --git a/domains/games/libs/cards/rummy/table_serde_test.cc b/domains/games/libs/cards/rummy/table_serde_test.cc index 8db5c43a..f7f81ef3 100644 --- a/domains/games/libs/cards/rummy/table_serde_test.cc +++ b/domains/games/libs/cards/rummy/table_serde_test.cc @@ -72,9 +72,9 @@ void expectRoundTrips(const TableState& table) { EXPECT_EQ(restored->getPhase(), table.getPhase()); EXPECT_EQ(restored->getVariant(), table.getVariant()); ASSERT_EQ(restored->getDeal().has_value(), table.getDeal().has_value()); - if (table.basicDeal() != nullptr) { - ASSERT_NE(restored->basicDeal(), nullptr); - EXPECT_EQ(serializeGameState(*restored->basicDeal()), serializeGameState(*table.basicDeal())); + if (table.rummyDeal() != nullptr) { + ASSERT_NE(restored->rummyDeal(), nullptr); + EXPECT_EQ(serializeGameState(*restored->rummyDeal()), serializeGameState(*table.rummyDeal())); } if (table.ginDeal() != nullptr) { ASSERT_NE(restored->ginDeal(), nullptr); @@ -153,7 +153,7 @@ TEST(TableSerde, AVersionOneRowIsOneDeal) { EXPECT_EQ(table->getDealNumber(), 1); EXPECT_EQ(table->getDealer(), 2); EXPECT_EQ(table->getVariant(), Variant::SevenCard); - EXPECT_EQ(serializeGameState(*table->basicDeal()), serializeGameState(*drew)); + EXPECT_EQ(serializeGameState(*table->rummyDeal()), serializeGameState(*drew)); auto gone = drew->removePlayer(1); ASSERT_TRUE(gone.ok()); diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 49c11a3d..e4ba3b23 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -63,12 +63,12 @@ TEST(Variants, EachDealsItsOwnGame) { auto ten = opened().chooseVariant(0, Variant::TenCard, deck()); ASSERT_TRUE(ten.ok()) << ten.status(); EXPECT_EQ(ten->getVariant(), Variant::TenCard); - ASSERT_NE(ten->basicDeal(), nullptr); - for (const Player& seat : ten->basicDeal()->getPlayers()) EXPECT_EQ(seat.hand.size(), 10u); + ASSERT_NE(ten->rummyDeal(), nullptr); + for (const Player& seat : ten->rummyDeal()->getPlayers()) EXPECT_EQ(seat.hand.size(), 10u); auto gin = opened({"a", "b"}).chooseVariant(0, Variant::Gin, deck()); ASSERT_TRUE(gin.ok()) << gin.status(); - EXPECT_EQ(gin->basicDeal(), nullptr); + EXPECT_EQ(gin->rummyDeal(), nullptr); ASSERT_NE(gin->ginDeal(), nullptr); EXPECT_EQ(gin->ginDeal()->getStage(), GinStage::Upcard); // The seat after the dealer has the upcard first. @@ -207,10 +207,10 @@ TEST(Table, TheDealerChoosesAndTheSeatAfterOpens) { EXPECT_EQ(dealt->getDealNumber(), 1); EXPECT_EQ(dealt->getVariant(), Variant::SevenCard); ASSERT_TRUE(dealt->getDeal().has_value()); - EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 1); - EXPECT_EQ(dealt->basicDeal()->getStage(), Stage::Draw); - EXPECT_EQ(dealt->basicDeal()->getPlayer(0).hand.size(), 7u); - EXPECT_EQ(dealt->basicDeal()->getGameId(), "T1"); + EXPECT_EQ(dealt->rummyDeal()->getWhoseTurn(), 1); + EXPECT_EQ(dealt->rummyDeal()->getStage(), Stage::Draw); + EXPECT_EQ(dealt->rummyDeal()->getPlayer(0).hand.size(), 7u); + EXPECT_EQ(dealt->rummyDeal()->getGameId(), "T1"); } TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { @@ -221,7 +221,7 @@ TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { ASSERT_TRUE(away.ok()) << away.status(); // The dealer stays the dealer: the seat after them still opens. EXPECT_EQ(away->getDealer(), 0); - EXPECT_EQ(away->basicDeal()->getWhoseTurn(), 1); + EXPECT_EQ(away->rummyDeal()->getWhoseTurn(), 1); EXPECT_EQ(table.chooseVariant(7, Variant::SevenCard, deck(), true).status().code(), absl::StatusCode::kInvalidArgument); } @@ -243,7 +243,7 @@ TEST(Table, DealMovesGoToTheDealAndNowhereElse) { ASSERT_TRUE(dealt.ok()); auto drew = dealt->inDeal([](const GameState& d) { return d.drawStock(1); }); ASSERT_TRUE(drew.ok()) << drew.status(); - EXPECT_EQ(drew->basicDeal()->getStage(), Stage::Play); + EXPECT_EQ(drew->rummyDeal()->getStage(), Stage::Play); EXPECT_EQ(drew->getPhase(), TablePhase::Playing); // The deal's refusal is the table's. EXPECT_EQ( @@ -275,13 +275,13 @@ TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { EXPECT_EQ(out->getDealer(), 1); // The finished deal stays, to show its hands and result. ASSERT_TRUE(out->getDeal().has_value()); - EXPECT_EQ(out->basicDeal()->winner(), "b"); + EXPECT_EQ(out->rummyDeal()->winner(), "b"); EXPECT_FALSE(out->isOver()); // The next dealer is b; the seat after b opens. auto next = out->chooseVariant(1, Variant::SevenCard, deck()); ASSERT_TRUE(next.ok()) << next.status(); EXPECT_EQ(next->getDealNumber(), out->getDealNumber() + 1); - EXPECT_EQ(next->basicDeal()->getWhoseTurn(), 2); + EXPECT_EQ(next->rummyDeal()->getWhoseTurn(), 2); EXPECT_EQ(next->getWins(), (vector{0, 1, 0})); } @@ -290,7 +290,7 @@ TEST(Table, TheDealWrapsRoundTheTable) { Variant::SevenCard, std::nullopt, "T", ""}; auto dealt = last.chooseVariant(1, Variant::SevenCard, deck()); ASSERT_TRUE(dealt.ok()); - EXPECT_EQ(dealt->basicDeal()->getWhoseTurn(), 0); + EXPECT_EQ(dealt->rummyDeal()->getWhoseTurn(), 0); } TEST(Table, ALeaveWhileChoosingCompactsTheSeatsAndTheDealerFollows) { @@ -328,7 +328,7 @@ TEST(Table, ALeaveMidDealLeavesTheDealToo) { ASSERT_TRUE(left.ok()) << left.status(); EXPECT_EQ(left->getPhase(), TablePhase::Playing); EXPECT_EQ(left->getSeats(), (vector{"a", "b"})); - EXPECT_EQ(left->basicDeal()->getPlayers().size(), 2u); + EXPECT_EQ(left->rummyDeal()->getPlayers().size(), 2u); EXPECT_EQ(left->getWins().size(), 2u); } @@ -393,7 +393,7 @@ TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { auto left = dealt->removePlayer(0); ASSERT_TRUE(left.ok()); EXPECT_TRUE(left->isOver()); - EXPECT_EQ(left->basicDeal()->getPhase(), Phase::Abandoned); + EXPECT_EQ(left->rummyDeal()->getPhase(), Phase::Abandoned); auto choosing_left = opened({"a", "b"}).removePlayer(1); ASSERT_TRUE(choosing_left.ok()); EXPECT_TRUE(choosing_left->isOver()); @@ -411,7 +411,7 @@ TEST(Table, SeatsAreFoundByIdAndTheIdsAreTheRowsToSet) { const TableState stamped = dealt->withIdAndVersion("G", "V"); EXPECT_EQ(stamped.getGameId(), "G"); EXPECT_EQ(stamped.getVersionId(), "V"); - EXPECT_EQ(stamped.basicDeal()->getGameId(), "G"); + EXPECT_EQ(stamped.rummyDeal()->getGameId(), "G"); } TEST(Deal, OpensAtTheSeatNamed) {