From f26a2fb92a914512ab866d3c7d23d79928652f31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:44:24 +0000 Subject: [PATCH 1/4] WIP: rummy dealer's-choice table --- domains/games/apis/games_hub/BUILD.bazel | 10 +- domains/games/apis/games_hub/game_events.cc | 22 +- domains/games/apis/games_hub/game_events.h | 12 +- .../games/apis/games_hub/game_events_test.cc | 77 ++-- domains/games/apis/games_hub/golf_hub.cc | 351 ++++++++++++------ domains/games/apis/games_hub/golf_hub.h | 9 +- domains/games/apis/games_hub/hosted_game.h | 20 +- domains/games/apis/games_hub/hub_e2e_test.cc | 1 + .../games/apis/games_hub/model/rummy.smithy | 96 ++++- domains/games/apis/games_hub/pg_hub_store.cc | 15 +- .../games/apis/games_hub/rummy_e2e_test.cc | 114 +++--- .../apis/games_hub/stream_test_fixture.h | 43 ++- domains/games/libs/cards/rummy/BUILD.bazel | 61 +++ domains/games/libs/cards/rummy/game_state.cc | 5 +- domains/games/libs/cards/rummy/game_state.h | 8 +- domains/games/libs/cards/rummy/table.cc | 170 +++++++++ domains/games/libs/cards/rummy/table.h | 107 ++++++ domains/games/libs/cards/rummy/table_serde.cc | 197 ++++++++++ domains/games/libs/cards/rummy/table_serde.h | 34 ++ .../libs/cards/rummy/table_serde_test.cc | 206 ++++++++++ domains/games/libs/cards/rummy/table_test.cc | 226 +++++++++++ 21 files changed, 1563 insertions(+), 221 deletions(-) create mode 100644 domains/games/libs/cards/rummy/table.cc create mode 100644 domains/games/libs/cards/rummy/table.h create mode 100644 domains/games/libs/cards/rummy/table_serde.cc create mode 100644 domains/games/libs/cards/rummy/table_serde.h create mode 100644 domains/games/libs/cards/rummy/table_serde_test.cc create mode 100644 domains/games/libs/cards/rummy/table_test.cc diff --git a/domains/games/apis/games_hub/BUILD.bazel b/domains/games/apis/games_hub/BUILD.bazel index 6e9e740b..db13113c 100644 --- a/domains/games/apis/games_hub/BUILD.bazel +++ b/domains/games/apis/games_hub/BUILD.bazel @@ -42,6 +42,7 @@ cc_library( "//domains/games/libs/cards/castle:game_state", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/rummy:game_state", + "//domains/games/libs/cards/rummy:table", ], ) @@ -107,6 +108,7 @@ cc_library( "//domains/games/libs/cards/castle:game_state", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/rummy:game_state", + "//domains/games/libs/cards/rummy:table", "@com_google_absl//absl/strings:str_format", "@com_google_absl//absl/time", ], @@ -127,6 +129,7 @@ cc_test( "//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:table", "@com_google_absl//absl/status:statusor", "@com_google_absl//absl/time", "@googletest//:gtest_main", @@ -348,7 +351,7 @@ cc_library( "//domains/games/libs/cards/castle:game_state_serde", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/golf:game_state_serde", - "//domains/games/libs/cards/rummy:game_state_serde", + "//domains/games/libs/cards/rummy:table_serde", "//domains/platform/libs/pg", "@com_google_absl//absl/log", "@com_google_absl//absl/status", @@ -426,7 +429,8 @@ cc_test( "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/golf:game_state_serde", "//domains/games/libs/cards/rummy:game_state", - "//domains/games/libs/cards/rummy:game_state_serde", + "//domains/games/libs/cards/rummy:table", + "//domains/games/libs/cards/rummy:table_serde", "//domains/platform/libs/pg", "//domains/platform/libs/pg:listener", "@googletest//:gtest_main", @@ -642,6 +646,7 @@ cc_library( "//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:table", "//domains/games/libs/mithril_cpp:client", "//domains/platform/libs/futility/otel:metrics", "//domains/platform/libs/pg:listener", @@ -860,6 +865,7 @@ cc_test( "//domains/games/libs/cards", "//domains/games/libs/cards:dealer", "//domains/games/libs/cards/rummy:game_state", + "//domains/games/libs/cards/rummy:table", "//domains/games/libs/cards/rummy:meld", "//domains/platform/libs/futility/otel:capturing_metrics_recorder", "//domains/platform/libs/futility/otel:metrics", diff --git a/domains/games/apis/games_hub/game_events.cc b/domains/games/apis/games_hub/game_events.cc index faa24426..054bb4dc 100644 --- a/domains/games/apis/games_hub/game_events.cc +++ b/domains/games/apis/games_hub/game_events.cc @@ -12,6 +12,7 @@ #include "domains/games/libs/cards/castle/game_state.h" #include "domains/games/libs/cards/golf/game_state.h" #include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/table.h" namespace games_hub { namespace { @@ -40,8 +41,8 @@ std::string RoomTag(std::string_view room) { return tag; } -GameFinished FinishedOf(const HostedState& state, std::size_t players) { - GameFinished finished{GameKindName(KindOf(state)), kOutcomeCompleted, players}; +std::optional FinishedOf(const HostedState& state, std::size_t players) { + GameFinished finished{VariantWordOf(state), kOutcomeCompleted, players}; if (const auto* golf_state = std::get_if(&state)) { // The engine supersedes any knock with this sentinel when a leave // drops the table below two seats. @@ -49,9 +50,20 @@ GameFinished FinishedOf(const HostedState& state, std::size_t players) { finished.outcome = kOutcomeAbandoned; return finished; } - if (const auto* rummy_state = std::get_if(&state)) { - if (rummy_state->getPhase() == rummy::Phase::Abandoned) finished.outcome = kOutcomeAbandoned; - return finished; + 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) { + 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 && + table->getPhase() == rummy::TablePhase::Choosing) { + return finished; + } + return std::nullopt; } if (std::get(state).getPhase() == castle::Phase::Abandoned) { finished.outcome = kOutcomeAbandoned; diff --git a/domains/games/apis/games_hub/game_events.h b/domains/games/apis/games_hub/game_events.h index 81be73f5..b4899b8c 100644 --- a/domains/games/apis/games_hub/game_events.h +++ b/domains/games/apis/games_hub/game_events.h @@ -2,6 +2,7 @@ #define DOMAINS_GAMES_APIS_GAMES_HUB_GAME_EVENTS_H #include +#include #include #include @@ -58,8 +59,8 @@ inline constexpr std::string_view kOutcomeAbandoned = "abandoned"; /// A game that ended, as the event describes it. struct GameFinished { - /// "golf", "castle" or "rummy" — GameKindName's spelling, which is also the - /// wire's and the stored row's. + /// VariantWordOf's spelling: GameKindName's for golf and castle, a rummy + /// deal's own variant's for a rummy table. std::string_view variant; /// kOutcomeCompleted — the engine played it out — or kOutcomeAbandoned: too few /// seats were left to go on. @@ -77,8 +78,11 @@ struct GameFinished { /// unchanged; this is the guarantee, not a transformation. std::string RoomTag(std::string_view room); -/// How `state` ended, for a roster of `players` seats. -GameFinished FinishedOf(const HostedState& state, std::size_t players); +/// How `state` ended, for a roster of `players` seats; nothing when no game +/// ended. A rummy table's games are its deals: a deal won by play is +/// completed, one broken up by a leave abandoned, and a table closing +/// between deals ended nothing — its deals were each recorded as they ended. +std::optional FinishedOf(const HostedState& state, std::size_t players); /// A room was made, on the surface it chose — SurfaceKindName's /// spelling, which is the wire's and the stored row's. Its creator is diff --git a/domains/games/apis/games_hub/game_events_test.cc b/domains/games/apis/games_hub/game_events_test.cc index 19ba8030..4d01fcd9 100644 --- a/domains/games/apis/games_hub/game_events_test.cc +++ b/domains/games/apis/games_hub/game_events_test.cc @@ -20,6 +20,7 @@ #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/table.h" #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -59,10 +60,10 @@ castle::GameState Castle() { "game", "v0"}; } -/// Two rummy seats, seat 0 holding one card after its draw: a discard -/// goes out. -rummy::GameState Rummy() { - return rummy::GameState{ +/// A rummy table (#1609) with a deal in play: seat 0 holds one card after +/// its draw, so a discard goes out. +rummy::TableState Rummy() { + rummy::GameState deal{ {Card{Suit::Clubs, Rank::Two}}, {Card{Suit::Clubs, Rank::Three}}, {{"andy", {Card{Suit::Clubs, Rank::Five}}}, {"mercy", {Card{Suit::Clubs, Rank::Six}}}}, @@ -73,13 +74,22 @@ rummy::GameState Rummy() { std::nullopt, "game", "v0"}; + return rummy::TableState{{"andy", "mercy"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, + rummy::Variant::Basic, deal, "game", "v0"}; +} + +rummy::TableState RummyDealWon() { + 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; } TEST(GameEvents, AGolfGamePlayedOutIsCompleted) { const golf::GameState knocked = Golf(0); ASSERT_TRUE(knocked.isOver()); - const GameFinished finished = FinishedOf(HostedState(knocked), 2); + const GameFinished finished = *FinishedOf(HostedState(knocked), 2); EXPECT_EQ(finished.variant, "golf"); EXPECT_EQ(finished.outcome, "completed"); EXPECT_EQ(finished.players, 2u); @@ -93,7 +103,7 @@ TEST(GameEvents, AGolfGameLeftBelowTwoSeatsIsAbandoned) { ASSERT_TRUE(lone.isOver()); ASSERT_EQ(lone.getWhoKnocked(), golf::GameState::kAbandoned); - const GameFinished finished = FinishedOf(HostedState(lone), 1); + const GameFinished finished = *FinishedOf(HostedState(lone), 1); EXPECT_EQ(finished.variant, "golf"); EXPECT_EQ(finished.outcome, "abandoned"); EXPECT_EQ(finished.players, 1u); @@ -104,7 +114,7 @@ TEST(GameEvents, ACastleGamePlayedOutIsCompleted) { ASSERT_TRUE(over.ok()) << over.status(); ASSERT_EQ(over->getPhase(), castle::Phase::Over); - const GameFinished finished = FinishedOf(HostedState(*over), 2); + const GameFinished finished = *FinishedOf(HostedState(*over), 2); EXPECT_EQ(finished.variant, "castle"); EXPECT_EQ(finished.outcome, "completed"); EXPECT_EQ(finished.players, 2u); @@ -115,32 +125,49 @@ TEST(GameEvents, ACastleGameLeftBelowTwoSeatsIsAbandoned) { ASSERT_TRUE(gone.ok()) << gone.status(); ASSERT_EQ(gone->getPhase(), castle::Phase::Abandoned); - const GameFinished finished = FinishedOf(HostedState(*gone), 1); + const GameFinished finished = *FinishedOf(HostedState(*gone), 1); EXPECT_EQ(finished.variant, "castle"); EXPECT_EQ(finished.outcome, "abandoned"); EXPECT_EQ(finished.players, 1u); } -TEST(GameEvents, ARummyGamePlayedOutIsCompleted) { - const absl::StatusOr over = Rummy().discard(0, Card{Suit::Clubs, Rank::Five}); - ASSERT_TRUE(over.ok()) << over.status(); - ASSERT_EQ(over->getPhase(), rummy::Phase::Over); +// 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". +TEST(GameEvents, ARummyDealPlayedOutIsCompleted) { + const rummy::TableState table = RummyDealWon(); + ASSERT_EQ(table.getPhase(), rummy::TablePhase::Choosing); + ASSERT_FALSE(table.isOver()); - const GameFinished finished = FinishedOf(HostedState(*over), 2); - EXPECT_EQ(finished.variant, "rummy"); - EXPECT_EQ(finished.outcome, "completed"); - EXPECT_EQ(finished.players, 2u); + const auto finished = FinishedOf(HostedState(table), 2); + ASSERT_TRUE(finished.has_value()); + EXPECT_EQ(finished->variant, "rummy"); + EXPECT_EQ(finished->outcome, "completed"); + EXPECT_EQ(finished->players, 2u); } -TEST(GameEvents, ARummyGameLeftBelowTwoSeatsIsAbandoned) { - const absl::StatusOr gone = Rummy().removePlayer(1); +TEST(GameEvents, ARummyDealBrokenUpByALeaveIsAbandoned) { + const absl::StatusOr gone = Rummy().removePlayer(1); ASSERT_TRUE(gone.ok()) << gone.status(); - ASSERT_EQ(gone->getPhase(), rummy::Phase::Abandoned); + ASSERT_TRUE(gone->isOver()); - const GameFinished finished = FinishedOf(HostedState(*gone), 1); - EXPECT_EQ(finished.variant, "rummy"); - EXPECT_EQ(finished.outcome, "abandoned"); - EXPECT_EQ(finished.players, 1u); + const auto finished = FinishedOf(HostedState(*gone), 1); + ASSERT_TRUE(finished.has_value()); + EXPECT_EQ(finished->variant, "rummy"); + EXPECT_EQ(finished->outcome, "abandoned"); + EXPECT_EQ(finished->players, 1u); +} + +// A table closing between deals, or a deal still going, is no game +// ending: the deals it played were each recorded as they ended. +TEST(GameEvents, ARummyTableClosingBetweenDealsRecordsNothing) { + const absl::StatusOr closed = RummyDealWon().removePlayer(1); + ASSERT_TRUE(closed.ok()) << closed.status(); + ASSERT_TRUE(closed->isOver()); + EXPECT_FALSE(FinishedOf(HostedState(*closed), 1).has_value()); + EXPECT_FALSE(FinishedOf(HostedState(Rummy()), 2).has_value()); + const absl::StatusOr opened = rummy::TableState::open("T", {"a", "b"}); + ASSERT_TRUE(opened.ok()); + EXPECT_FALSE(FinishedOf(HostedState(*opened), 2).has_value()); } // 2026-09-21T13:40:00Z, so a reader can see the stamp is epoch millis @@ -221,11 +248,11 @@ TEST(GameEvents, EveryEventsLineIsTextWithNothingToEscape) { endings.emplace_back(*Golf(golf::GameState::kNoKnock).removePlayer(1)); endings.emplace_back(*Castle().playFromHand(0, {0})); endings.emplace_back(*Castle().removePlayer(1)); - endings.emplace_back(*Rummy().discard(0, Card{Suit::Clubs, Rank::Five})); + endings.emplace_back(RummyDealWon()); endings.emplace_back(*Rummy().removePlayer(1)); for (const HostedState& state : endings) { for (std::size_t players = 1; players <= 4; ++players) { - const GameFinished finished = FinishedOf(state, 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" || diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index 2a118dd7..3b7cea89 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -27,6 +27,7 @@ #include "domains/games/libs/cards/castle/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/table.h" namespace games_hub { @@ -200,8 +201,16 @@ std::vector WireCards(const std::vector& car return wire; } -std::string RummyPhaseString(const rummy::GameState& state) { - return state.isOver() ? "ended" : "playing"; +std::string RummyPhaseString(const rummy::TableState& table) { + switch (table.getPhase()) { + case rummy::TablePhase::Choosing: + return "choosing"; + case rummy::TablePhase::Playing: + return "playing"; + case rummy::TablePhase::Closed: + break; + } + return "ended"; } // The table's phase for the room's lobby summary, whichever game it plays. @@ -209,8 +218,8 @@ std::string PhaseStringOf(const HostedState& state) { if (const auto* golf_state = std::get_if(&state)) { return PhaseString(*golf_state); } - if (const auto* rummy_state = std::get_if(&state)) { - return RummyPhaseString(*rummy_state); + if (const auto* table = std::get_if(&state)) { + return RummyPhaseString(*table); } return CastlePhaseString(std::get(state)); } @@ -220,9 +229,11 @@ std::string CurrentTurnOf(const HostedState& state) { if (const auto* golf_state = std::get_if(&state)) { return golf_state->isOver() ? "" : PlayerIdAt(*golf_state, golf_state->getWhoseTurn()); } - if (const auto* rummy_state = std::get_if(&state)) { - const int seat = rummy_state->getWhoseTurn(); - return seat < 0 ? "" : rummy_state->getPlayer(seat).id; + 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 auto& castle_state = std::get(state); return CastlePlayerIdAt(castle_state, castle_state.getWhoseTurn()); @@ -274,11 +285,12 @@ std::vector CastleStatsDeltas(const castle::Gam return deltas; } -// Rummy's finish: the seat that went out wins and everyone seated played. -// A leaver is compacted out of the engine's seats, as in castle. No -// score: rummy's points count up for 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 gameEnded instead. +// A rummy deal's finish (#1609): each deal is a game — the seat that went +// out won it and everyone dealt in played it. A leaver is compacted out of +// the engine's seats, as in castle. No score: rummy's points count up for +// 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 deltas; @@ -297,12 +309,40 @@ std::vector StatsDeltasOf(const HostedState& st if (const auto* golf_state = std::get_if(&state)) { return StatsDeltas(*golf_state, roster); } - if (const auto* rummy_state = std::get_if(&state)) { - return RummyStatsDeltas(*rummy_state); + if (const auto* table = std::get_if(&state)) { + // 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) { + return RummyStatsDeltas(*deal); + } + return {}; } return CastleStatsDeltas(std::get(state)); } +// What a transition credits when it ends a deal without closing the table: +// a rummy deal won by play. Nothing for anything else — a finish that +// closes the table is StatsDeltasOf's. +std::optional> DealEndDeltas( + const HostedState& before, const HostedState& after) { + const auto* was = std::get_if(&before); + const auto* now = std::get_if(&after); + if (was == nullptr || now == nullptr) return std::nullopt; + if (was->getPhase() != rummy::TablePhase::Playing || + now->getPhase() != rummy::TablePhase::Choosing) { + return std::nullopt; + } + return RummyStatsDeltas(*now->getDeal()); +} + +// Whether a transition dealt a new rummy deal: the moment a game starts. +bool DealStarted(const HostedState& before, const HostedState& after) { + const auto* was = std::get_if(&before); + const auto* now = std::get_if(&after); + return was != nullptr && now != nullptr && now->getDealNumber() > was->getDealNumber(); +} + // The seat a leaver vacates, in whichever engine: compacted while seats // remain, or the game resolved (golf keeps every seat for the scorecard; // castle and rummy abandon below two seats). @@ -397,6 +437,7 @@ const std::vector& GolfHub::DeclaredCounterSeries() { {"rummy_commands", {{"command", "joinGame"}}}, {"rummy_commands", {{"command", "startGame"}}}, {"rummy_commands", {{"command", "leaveGame"}}}, + {"rummy_commands", {{"command", "chooseVariant"}}}, {"rummy_commands", {{"command", "drawStock"}}}, {"rummy_commands", {{"command", "drawDiscard"}}}, {"rummy_commands", {{"command", "meld"}}}, @@ -920,9 +961,9 @@ GolfHub::Commit GolfHub::CommitEntryLocked(const std::string& room_id, const std // an unavailable store leaves a live row somebody finishes again — // either would count the game twice. if (finish != nullptr && !was_over) { - RecordLocked([&](absl::Time now) { - return GameFinishedLine(now, room_id, FinishedOf(*state, roster.size())); - }); + if (const auto finished = FinishedOf(*state, roster.size()); finished.has_value()) { + RecordLocked([&](absl::Time now) { return GameFinishedLine(now, room_id, *finished); }); + } } return Commit::kCommitted; } @@ -1956,57 +1997,85 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m // named here in the wire's spelling. void GolfHub::HandleRummyMove(const std::string& player_id, const RummyMove& move) { if (LifecycleMove(player_id, move, GameKind::kRummy)) return; - using Next = absl::StatusOr; - using Laying = - std::function& cards)>; + using Next = absl::StatusOr; + using DealNext = absl::StatusOr; + // 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) { auto named = CardsFromWire(wire); if (!named.ok()) { Reject(player_id, RejectKind::kInvalid, std::string(named.status().message())); return; } - TableEngineMove(player_id, GameKind::kRummy, - [cards = *std::move(named), laying = std::move(laying)]( - const rummy::GameState& state, 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. - Next next = laying(state, seat, cards); - if (next.status().code() == absl::StatusCode::kNotFound) { - return HeldInHand(state.getPlayer(seat).hand, cards); - } - return next; - }); + 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; + }); }; - if (move.as_drawStock_or_null() != nullptr) { - TableEngineMove( + if (const auto* choose = move.as_chooseVariant_or_null()) { + const std::optional variant = rummy::parseVariant(choose->variant); + if (!variant.has_value()) { + Reject(player_id, RejectKind::kInvalid, absl::StrCat("no such game: ", choose->variant)); + return; + } + TableEngineMove( player_id, GameKind::kRummy, - [](const rummy::GameState& state, int seat) { return state.drawStock(seat); }); + [this, &player_id, chosen = *variant](const rummy::TableState& table, int seat) -> Next { + // Under mu_: whether the dealer is here to choose, which lets any + // seat deal when they are not. + bool dealer_away = false; + if (const Room* room = FindRoomLocked(player_id); room != nullptr) { + const auto dealer = room->members.find(table.getSeats().at(table.getDealer())); + dealer_away = dealer == room->members.end() || !dealer->second.connected; + } + std::deque deck = dealer_->DealNewUnshuffledDeck(); + dealer_->ShuffleDeck(deck); + return table.chooseVariant(seat, chosen, std::move(deck), dealer_away); + }); + return; + } + if (move.as_drawStock_or_null() != nullptr) { + deal_move([](const rummy::GameState& deal, int seat) { return deal.drawStock(seat); }); return; } if (move.as_drawDiscard_or_null() != nullptr) { - TableEngineMove( - player_id, GameKind::kRummy, - [](const rummy::GameState& state, int seat) { return state.drawDiscard(seat); }); + deal_move([](const rummy::GameState& deal, int seat) { return deal.drawDiscard(seat); }); return; } if (const auto* meld = move.as_meld_or_null()) { - lay(meld->cards, [](const rummy::GameState& state, int seat, - const std::vector& cards) { return state.meld(seat, cards); }); + lay(meld->cards, [](const rummy::GameState& deal, int seat, + const std::vector& cards) { return deal.meld(seat, cards); }); return; } if (const auto* lay_off = move.as_layOff_or_null()) { - lay({lay_off->card}, [index = lay_off->meldIndex](const rummy::GameState& state, int seat, + lay({lay_off->card}, [index = lay_off->meldIndex](const rummy::GameState& deal, int seat, const std::vector& cards) { - return state.layOff(seat, cards.front(), index); + return deal.layOff(seat, cards.front(), index); }); return; } if (const auto* discard = move.as_discard_or_null()) { lay({discard->card}, - [](const rummy::GameState& state, int seat, const std::vector& cards) { - return state.discard(seat, cards.front()); + [](const rummy::GameState& deal, int seat, const std::vector& cards) { + return deal.discard(seat, cards.front()); }); return; } @@ -2175,12 +2244,14 @@ void GolfHub::StartGameMove(const std::string& player_id) { dealer_->ShuffleDeck(deck); std::optional dealt; if (ref->entry->kind == GameKind::kRummy) { - auto rummy_deal = rummy::dealRummyGame(ref->game_id, ref->entry->roster, std::move(deck)); - if (!rummy_deal.ok()) { - refusal = Refusal{RejectKind::kRules, std::string(rummy_deal.status().message())}; + // Seated, not dealt: the table opens with its first seat to deal, + // and the dealer's chooseVariant is each deal's start. + auto table = rummy::TableState::open(ref->game_id, ref->entry->roster); + if (!table.ok()) { + refusal = Refusal{RejectKind::kRules, std::string(table.status().message())}; break; } - dealt.emplace(*std::move(rummy_deal)); + dealt.emplace(*std::move(table)); } else if (ref->entry->kind == GameKind::kCastle) { auto castle_deal = castle::dealCastleGame(ref->game_id, ref->entry->roster, std::move(deck)); @@ -2211,10 +2282,13 @@ void GolfHub::StartGameMove(const std::string& player_id) { break; } started = true; - RecordLocked([&id = ref->room_id, variant = GameKindName(ref->entry->kind), - seats = ref->entry->roster.size()](absl::Time now) { - return GameStartedLine(now, id, variant, seats); - }); + // A rummy table's games are its deals, each recorded as it is dealt. + if (ref->entry->kind != GameKind::kRummy) { + RecordLocked([&id = ref->room_id, variant = GameKindName(ref->entry->kind), + seats = ref->entry->roster.size()](absl::Time now) { + return GameStartedLine(now, id, variant, seats); + }); + } for (const std::string& recipient : ref->entry->roster) { outbox.To(recipient, StartedEvent(ref->entry->kind)); } @@ -2384,11 +2458,17 @@ void GolfHub::TableEngineMove(const std::string& player_id, GameKind kind, const std::string previous_turn = CurrentTurnOf(*ref->entry->state); const bool over = next->isOver(); HostedState next_state(*std::move(next)); - std::vector deltas; + // A finish credits the room's stats in the same conditional commit + // that lands it: the table's end, or a rummy deal's (#1609), which + // leaves the table dealing on. + std::optional> deltas = + DealEndDeltas(*ref->entry->state, next_state); + const bool deal_ended = deltas.has_value(); + const bool deal_started = DealStarted(*ref->entry->state, next_state); if (over) deltas = StatsDeltasOf(next_state, ref->entry->roster); const Commit commit = CommitEntryLocked(ref->room_id, ref->game_id, *ref->entry, ref->entry->roster, - std::move(next_state), over ? &deltas : nullptr); + std::move(next_state), deltas.has_value() ? &*deltas : nullptr); if (commit == Commit::kRebased) continue; if (commit == Commit::kGone) { DropGameLocked(*ref); @@ -2401,12 +2481,24 @@ void GolfHub::TableEngineMove(const std::string& player_id, GameKind kind, break; } landed = true; + if (deal_started) { + RecordLocked([&id = ref->room_id, variant = VariantWordOf(*ref->entry->state), + seats = ref->entry->roster.size()](absl::Time now) { + return GameStartedLine(now, id, variant, seats); + }); + } if (over) { FinalizeGameLocked(ref->room_id, *ref->room, ref->game_id, outbox); } else { StageGameViewsLocked(ref->game_id, *ref->entry, outbox); + if (deal_ended) { + // The hand is the room's too: its stats moved. + MirrorStatsLocked(*ref->room, *deltas); + StageRoomStateLocked(ref->room_id, outbox); + } + // Between deals nobody is on turn, and that is no turn to announce. const std::string current_turn = CurrentTurnOf(*ref->entry->state); - if (current_turn != previous_turn) { + if (current_turn != previous_turn && !current_turn.empty()) { moonbase::games::TurnChanged turn; turn.playerId = current_turn; for (const std::string& recipient : ref->entry->roster) { @@ -3015,6 +3107,7 @@ moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, const std::string& viewer_id) const { moonbase::games::RummyView view; view.gameId = game_id; + view.dealNumber = 0; view.stockCount = 0; view.canDrawStock = false; view.discardCount = 0; @@ -3025,44 +3118,88 @@ moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, player.playerId = roster_id; player.handCount = 0; view.players.push_back(std::move(player)); + moonbase::games::RummyStanding standing; + standing.playerId = roster_id; + standing.handsWon = 0; + view.standings.push_back(std::move(standing)); } return view; } - const rummy::GameState& state = entry.rummy(); - const bool ended = state.isOver(); - view.phase = RummyPhaseString(state); - if (!ended) { - view.currentPlayerId = CurrentTurnOf(*entry.state); - view.stage = state.getStage() == rummy::Stage::Draw ? "draw" : "play"; - } - view.stockCount = static_cast(state.getStock().size()); - view.canDrawStock = !ended && state.canDrawStock(); - view.discardCount = static_cast(state.getDiscard().size()); - if (!state.getDiscard().empty()) view.discardTop = WireCard(state.getDiscard().back()); - if (!ended && state.getTakenDiscard().has_value()) { - view.takenDiscard = WireCard(*state.getTakenDiscard()); - } - for (const rummy::Meld& meld : state.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 = state.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); - } - for (const rummy::Player& seat : state.getPlayers()) { + const rummy::TableState& table = entry.rummy(); + view.phase = RummyPhaseString(table); + view.dealNumber = table.getDealNumber(); + if (table.getDealNumber() > 0) view.variant = std::string(rummy::variantName(table.getVariant())); + for (std::size_t i = 0; i < table.getSeats().size(); ++i) { + moonbase::games::RummyStanding standing; + standing.playerId = table.getSeats()[i]; + standing.handsWon = table.getWins()[i]; + view.standings.push_back(std::move(standing)); + } + if (table.getPhase() == rummy::TablePhase::Choosing) { + moonbase::games::RummyChoosing choosing; + choosing.dealer = table.getSeats().at(table.getDealer()); + for (const rummy::Variant variant : + rummy::variantsFor(static_cast(table.getSeats().size()))) { + choosing.options.push_back(std::string(rummy::variantName(variant))); + } + view.choosing = std::move(choosing); + } + + 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(); + if (deal.has_value()) { + if (!deal->isOver()) { + 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) { + moonbase::games::RummyScore score; + score.playerId = deal->getPlayer(static_cast(i)).id; + score.deadwood = deal->deadwood(static_cast(i)); + result.scores.push_back(std::move(score)); + } + 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); + } + } + for (const std::string& seat_id : table.getSeats()) { moonbase::games::RummyPlayer player; - player.playerId = seat.id; - player.handCount = static_cast(seat.hand.size()); - // Own hand faces only, everyone's once the game ends. - if (ended || seat.id == viewer_id) player.hand = WireCards(seat.hand); + player.playerId = seat_id; + player.handCount = 0; + const int at = deal.has_value() ? deal->playerIndex(seat_id) : -1; + if (at >= 0) { + const std::vector& hand = deal->getPlayer(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); + } view.players.push_back(std::move(player)); } return view; @@ -3126,17 +3263,16 @@ void GolfHub::StageGameOverLocked(Room& room, const std::string& game_id, Outbox return; } if (game->second.kind == GameKind::kRummy) { - // Final views (every hand face up), then who went out and what every - // seat still held; an abandonment names no winner and scores nothing. - const rummy::GameState& state = game->second.rummy(); + // The table broke up: final views, then the evening's standings. Each + // deal's own result was its view's lastDeal as it ended. + const rummy::TableState& table = game->second.rummy(); moonbase::games::RummyGameEnded ended; - ended.winner = state.winner(); - ended.points = state.winnerPoints(); - for (std::size_t i = 0; i < state.getPlayers().size(); ++i) { - moonbase::games::RummyScore score; - score.playerId = state.getPlayer(static_cast(i)).id; - score.deadwood = state.deadwood(static_cast(i)); - ended.scores.push_back(std::move(score)); + ended.dealsPlayed = table.getDealNumber(); + for (std::size_t i = 0; i < table.getSeats().size(); ++i) { + moonbase::games::RummyStanding standing; + standing.playerId = table.getSeats()[i]; + standing.handsWon = table.getWins()[i]; + ended.standings.push_back(std::move(standing)); } StageGameViewsLocked(game_id, game->second, outbox); for (const std::string& recipient : game->second.roster) { @@ -3178,6 +3314,16 @@ void GolfHub::StageGameOverLocked(Room& room, const std::string& game_id, Outbox room.games.erase(game); } +void GolfHub::MirrorStatsLocked(Room& room, const std::vector& deltas) { + for (const HubStore::StatsDelta& delta : deltas) { + const auto member = room.members.find(delta.player_id); + if (member == room.members.end()) continue; + member->second.games_played += delta.played; + member->second.games_won += delta.won; + member->second.total_score += delta.score; + } +} + void GolfHub::FinalizeGameLocked(const std::string& room_id, Room& room, const std::string& game_id, Outbox& outbox) { const auto game = room.games.find(game_id); @@ -3186,14 +3332,7 @@ void GolfHub::FinalizeGameLocked(const std::string& room_id, Room& room, const s // Room-scoped running stats: every roster seat played, every winner // won. These same deltas rode the finish commit, or its fate is unknown // after a kUnavailable leave; either way this mirrors them locally. - for (const HubStore::StatsDelta& delta : - StatsDeltasOf(*game->second.state, game->second.roster)) { - const auto member = room.members.find(delta.player_id); - if (member == room.members.end()) continue; - member->second.games_played += delta.played; - member->second.games_won += delta.won; - member->second.total_score += delta.score; - } + MirrorStatsLocked(room, StatsDeltasOf(*game->second.state, game->second.roster)); StageGameOverLocked(room, game_id, outbox); // The terminal row is the durable handoff to other instances. It diff --git a/domains/games/apis/games_hub/golf_hub.h b/domains/games/apis/games_hub/golf_hub.h index 6ba98a2a..7cf0fdd3 100644 --- a/domains/games/apis/games_hub/golf_hub.h +++ b/domains/games/apis/games_hub/golf_hub.h @@ -35,7 +35,7 @@ #include "domains/games/libs/cards/castle/game_state.h" #include "domains/games/libs/cards/dealer.h" #include "domains/games/libs/cards/golf/game_state.h" -#include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/table.h" #include "domains/platform/libs/futility/otel/metrics.h" #include "domains/platform/libs/pg/listener.h" #include "moonbase/games/server.h" @@ -360,8 +360,8 @@ class GolfHub final { [[nodiscard]] const castle::GameState& castle() const { return std::get(*state); } - [[nodiscard]] const rummy::GameState& rummy() const { - return std::get(*state); + [[nodiscard]] const rummy::TableState& rummy() const { + return std::get(*state); } }; @@ -646,6 +646,9 @@ class GolfHub final { /// The local finisher: mirrors the finish commit's stat deltas into the /// local member rows (after a kUnavailable leave, with no known commit) /// and runs the ceremony. + /// Adds a finish's stat deltas to the room's members; the same numbers + /// rode the finish commit. + void MirrorStatsLocked(Room& room, const std::vector& deltas); void FinalizeGameLocked(const std::string& room_id, Room& room, const std::string& game_id, Outbox& outbox); diff --git a/domains/games/apis/games_hub/hosted_game.h b/domains/games/apis/games_hub/hosted_game.h index d521a433..d860cbe8 100644 --- a/domains/games/apis/games_hub/hosted_game.h +++ b/domains/games/apis/games_hub/hosted_game.h @@ -8,7 +8,7 @@ #include "domains/games/libs/cards/castle/game_state.h" #include "domains/games/libs/cards/golf/game_state.h" -#include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/table.h" namespace games_hub { @@ -19,7 +19,8 @@ namespace games_hub { enum class GameKind { kGolf, kCastle, kRummy }; /// The engine truth of a started table, whichever game it plays. -using HostedState = std::variant; +/// A rummy table is a dealer's-choice table (#1609), a deal at a time. +using HostedState = std::variant; inline std::string_view GameKindName(GameKind kind) { switch (kind) { @@ -42,7 +43,7 @@ inline std::optional ParseGameKind(std::string_view name) { inline GameKind KindOf(const HostedState& state) { if (std::holds_alternative(state)) return GameKind::kCastle; - if (std::holds_alternative(state)) return GameKind::kRummy; + if (std::holds_alternative(state)) return GameKind::kRummy; return GameKind::kGolf; } @@ -50,6 +51,19 @@ inline bool IsOver(const HostedState& state) { return std::visit([](const auto& engine) { return engine.isOver(); }, state); } +/// The word a game is recorded under (#1571): the kind's, except that a +/// rummy table's games are its deals, each under its own variant's word — +/// basic rummy keeps "rummy". +inline std::string_view VariantWordOf(const HostedState& state) { + if (const auto* table = std::get_if(&state)) { + switch (table->getVariant()) { + case rummy::Variant::Basic: + return "rummy"; + } + } + return GameKindName(KindOf(state)); +} + } // namespace games_hub #endif diff --git a/domains/games/apis/games_hub/hub_e2e_test.cc b/domains/games/apis/games_hub/hub_e2e_test.cc index caba952e..89a57c7d 100644 --- a/domains/games/apis/games_hub/hub_e2e_test.cc +++ b/domains/games/apis/games_hub/hub_e2e_test.cc @@ -2049,6 +2049,7 @@ TEST_F(GamesHubStreamFixture, BuildingAHandlerDeclaresEveryCounterSeriesAtZero) {"rummy_commands", {{"command", "joinGame"}}}, {"rummy_commands", {{"command", "startGame"}}}, {"rummy_commands", {{"command", "leaveGame"}}}, + {"rummy_commands", {{"command", "chooseVariant"}}}, {"rummy_commands", {{"command", "drawStock"}}}, {"rummy_commands", {{"command", "drawDiscard"}}}, {"rummy_commands", {{"command", "meld"}}}, diff --git a/domains/games/apis/games_hub/model/rummy.smithy b/domains/games/apis/games_hub/model/rummy.smithy index efe7e93c..b7e2ec4b 100644 --- a/domains/games/apis/games_hub/model/rummy.smithy +++ b/domains/games/apis/games_hub/model/rummy.smithy @@ -12,9 +12,12 @@ use moonbase.games#LeaveGame use moonbase.games#StartGame use moonbase.games#TurnChanged -// Rummy's vocabulary (#245): the basic game, the third on the room -// layer. It rides the room's Play stream as one `rummy` member per -// direction and reuses the shared lifecycle shapes, the way castle does. +// Rummy's vocabulary (#245): the third game on the room layer, played at +// a dealer's-choice table (#1609) — one deal after another, the dealer +// picking each deal's variant. It rides the room's Play stream as one +// `rummy` member per direction and reuses the shared lifecycle shapes, the +// way castle does; startGame seats the table, and the dealer's +// chooseVariant deals. // Shape names carry the game's name: codegen flattens every namespace // into one. @@ -34,6 +37,7 @@ union RummyMove { joinGame: JoinGame startGame: StartGame leaveGame: LeaveGame + chooseVariant: RummyChooseVariant drawStock: RummyDrawStock drawDiscard: RummyDrawDiscard meld: RummyMeld @@ -41,6 +45,15 @@ union RummyMove { discard: RummyDiscard } +/// 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 + @required + variant: String +} + /// Take the top of the stock. An empty stock is refilled from the /// discard pile, turned over under its top card. structure RummyDrawStock {} @@ -99,10 +112,36 @@ structure RummyGameState { view: RummyView } -/// How the game ended: the seat that went out and what it scored (every -/// other seat's cards left in hand), or neither for an abandoned table. -/// scores holds every seat at the table at the end, in seat order. +/// The table broke up — below two seats. Each deal's result was the view's +/// lastDeal as it ended; this is the evening's: the hands each seat won, +/// every seat still at the table in seat order. structure RummyGameEnded { + @required + standings: RummyStandings + + @required + dealsPlayed: Integer +} + +list RummyStandings { + member: RummyStanding +} + +structure RummyStanding { + @required + playerId: String + + @required + 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. +structure RummyDealResult { + @required + variant: String + winner: String @required @@ -112,6 +151,22 @@ structure RummyGameEnded { scores: RummyScores } +/// Between deals: who deals next and what they may deal. A dealer the +/// room shows as not connected (RoomState's PlayerInfo.connected) lets any +/// seat deal; the room, not this view, is what says so as it changes. +structure RummyChoosing { + @required + dealer: String + + /// The variants that fit the seats, in offer order. + @required + options: RummyVariants +} + +list RummyVariants { + member: String +} + list RummyScores { member: RummyScore } @@ -126,18 +181,37 @@ structure RummyScore { deadwood: Integer } -/// One player's redacted view: own hand faces, every other hand as a -/// count, the melds on the table, the discard pile's top, and the stock -/// as a count. Every hand is revealed once the game ends. An ended view -/// is always followed by gameEnded. +/// One player's redacted view of the table and its deal: own hand faces, +/// every other hand as a count, the melds on the table, the discard pile's +/// top, and the stock as a count. Every hand of a deal is revealed once +/// the deal ends — between deals, the last deal is what the table shows. +/// An ended view is always followed by gameEnded. structure RummyView { @required gameId: String - /// waiting | playing | ended + /// waiting | choosing | playing | ended @required phase: String + /// The deal in play's variant, or the last one's; absent before the + /// first deal. + variant: String + + /// Deals dealt so far. + @required + dealNumber: Integer + + /// Hands won, seat by seat. + @required + standings: RummyStandings + + /// Present between deals. + choosing: RummyChoosing + + /// The last deal's result, between deals and once the table ends. + lastDeal: RummyDealResult + @required players: RummyPlayers diff --git a/domains/games/apis/games_hub/pg_hub_store.cc b/domains/games/apis/games_hub/pg_hub_store.cc index af8c7ec9..4a748ca2 100644 --- a/domains/games/apis/games_hub/pg_hub_store.cc +++ b/domains/games/apis/games_hub/pg_hub_store.cc @@ -1,6 +1,7 @@ #include "domains/games/apis/games_hub/pg_hub_store.h" #include +#include #include #include "absl/log/log.h" @@ -8,7 +9,7 @@ #include "domains/games/apis/games_hub/hosted_game.h" #include "domains/games/libs/cards/castle/game_state_serde.h" #include "domains/games/libs/cards/golf/game_state_serde.h" -#include "domains/games/libs/cards/rummy/game_state_serde.h" +#include "domains/games/libs/cards/rummy/table_serde.h" namespace games_hub { namespace { @@ -116,7 +117,15 @@ std::string StatsJson(const std::vector& stats) { // kind column is what the loads read to pick the decoder back. std::string StateJson(const PgHubStore::GameRow& row) { if (!row.state.has_value()) return ""; - return std::visit([](const auto& state) { return serializeGameState(state); }, *row.state); + return std::visit( + [](const auto& state) -> std::string { + if constexpr (std::is_same_v, rummy::TableState>) { + return rummy::serializeTableState(state); + } else { + return serializeGameState(state); + } + }, + *row.state); } } // namespace @@ -375,7 +384,7 @@ absl::StatusOr PgHubStore::RowFromColumns( if (!state.ok()) return state.status(); row.state.emplace(*std::move(state)); } else if (row.kind == GameKind::kRummy) { - auto state = rummy::deserializeGameState(state_json); + auto state = rummy::deserializeTableState(state_json); if (!state.ok()) return state.status(); row.state.emplace(*std::move(state)); } else { diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc index 83884891..919e83e2 100644 --- a/domains/games/apis/games_hub/rummy_e2e_test.cc +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -1,10 +1,12 @@ // Rummy on the room stream (#245): the third game on the hub, end to end // through the generated client. // -// Two kinds of deal. The NoShuffleDealer deals the pristine deck one card -// a seat from the back, so every card is known: at two seats alice holds -// A♠ A♦ K♠ K♦ Q♠ Q♦ J♠ J♦ 10♠ 10♦, bob the hearts and clubs of the same -// ranks, 9♠ is turned up and 9♥ tops the stock. The SeededDealer shuffles +// Every table is dealer's choice (#1609): the creator deals first, and the +// seat after the dealer opens. Two kinds of deal. The NoShuffleDealer +// deals the pristine deck one card a seat from the back, so every card is +// known: at two seats alice (the opener) holds A♥ A♣ K♥ K♣ Q♥ Q♣ J♥ J♣ +// 10♥ 10♣, bob (the dealer) the spades and diamonds of the same ranks, 9♠ +// is turned up and 9♥ tops the stock. The SeededDealer shuffles // with a fixed seed for whole games, which a local engine mirror plays // alongside the hub: every turn the mirror picks a move by a simple // policy, the same command goes to the hub, and every seat's view must @@ -167,12 +169,14 @@ cards::Card DiscardFrom(const std::vector& hand, class RummyGameFixture : public GamesHubStreamFixture { protected: - // The same deal the hub made, as an engine value. - rummy::GameState MirrorDeal(const std::string& game_id, const std::vector& ids) { + // The same deal the hub made, as an engine value: `ids` in seat order, + // opened by the seat after the dealer — seat 1 on the first deal. + rummy::GameState MirrorDeal(const std::string& game_id, const std::vector& ids, + int opener = 1) { auto dealer = MakeDealer(); std::deque deck = dealer->DealNewUnshuffledDeck(); dealer->ShuffleDeck(deck); - auto state = rummy::dealRummyGame(game_id, ids, std::move(deck)); + auto state = rummy::dealRummyGame(game_id, ids, std::move(deck), opener); EXPECT_TRUE(state.ok()) << state.status(); return *state; } @@ -182,12 +186,31 @@ class RummyGameFixture : public GamesHubStreamFixture { void ExpectBoard(const RummyView& seen, const std::string& viewer, const rummy::GameState& mirror) { const bool ended = mirror.isOver(); - EXPECT_EQ(seen.phase, ended ? "ended" : "playing"); + // A deal won by play leaves the table choosing the next; one broken up + // by a leave closes it. + EXPECT_EQ(seen.phase, !ended ? "playing" + : mirror.getPhase() == rummy::Phase::Over ? "choosing" + : "ended"); + EXPECT_EQ(seen.variant.value_or(""), "basic"); if (ended) { EXPECT_FALSE(seen.currentPlayerId.has_value()); EXPECT_FALSE(seen.stage.has_value()); EXPECT_FALSE(seen.canDrawStock); + EXPECT_FALSE(seen.takenDiscard.has_value()); + // 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->winner, mirror.winner()); + EXPECT_EQ(seen.lastDeal->points, mirror.winnerPoints()); + ASSERT_EQ(seen.lastDeal->scores.size(), mirror.getPlayers().size()); + for (std::size_t i = 0; i < seen.lastDeal->scores.size(); ++i) { + EXPECT_EQ(seen.lastDeal->scores[i].playerId, mirror.getPlayer(static_cast(i)).id); + EXPECT_EQ(seen.lastDeal->scores[i].deadwood, mirror.deadwood(static_cast(i))); + } } else { + EXPECT_FALSE(seen.lastDeal.has_value()); + EXPECT_FALSE(seen.choosing.has_value()); EXPECT_EQ(seen.currentPlayerId.value_or(""), mirror.getPlayer(mirror.getWhoseTurn()).id); EXPECT_EQ(seen.stage.value_or(""), mirror.getStage() == rummy::Stage::Draw ? "draw" : "play"); EXPECT_EQ(seen.canDrawStock, mirror.canDrawStock()); @@ -198,7 +221,7 @@ class RummyGameFixture : public GamesHubStreamFixture { if (seen.discardTop.has_value()) { EXPECT_EQ(Face(*seen.discardTop), Face(Wire(mirror.getDiscard().back()))); } - ASSERT_EQ(seen.takenDiscard.has_value(), !ended && mirror.getTakenDiscard().has_value()); + if (!ended) ASSERT_EQ(seen.takenDiscard.has_value(), mirror.getTakenDiscard().has_value()); if (seen.takenDiscard.has_value()) { EXPECT_EQ(Face(*seen.takenDiscard), Face(Wire(*mirror.getTakenDiscard()))); } @@ -312,40 +335,37 @@ class RummyGameFixture : public GamesHubStreamFixture { } } - // The end, from every chair: final views with every hand face up and - // nobody to play, the result, then the room's stats crediting the seat - // that went out and nobody else. - void ExpectEnding(const std::vector& seats, const rummy::GameState& mirror) { + // A deal's end, from every chair: final views with every hand face up, + // nobody to play and the deal's result, the next dealer choosing; then + // the room's stats crediting the seat that went out with a game won and + // everyone with a game played. The table stays: dealer's choice deals on. + void ExpectDealEnding(const std::vector& seats, const rummy::GameState& mirror, + const std::string& next_dealer, int games_played = 1) { ASSERT_EQ(mirror.getPhase(), rummy::Phase::Over); const std::string winner = mirror.winner().value_or(""); ASSERT_FALSE(winner.empty()); for (Seat* seat : seats) { auto final_view = ReceiveRummy(seat->stream, "gameState"); ASSERT_TRUE(final_view.has_value()); - ExpectBoard(final_view->as_gameState_or_null()->view, seat->player_id, mirror); - auto ended = ReceiveRummy(seat->stream, "gameEnded"); - ASSERT_TRUE(ended.has_value()); - const auto& result = *ended->as_gameEnded_or_null(); - EXPECT_EQ(result.winner.value_or(""), winner); - EXPECT_EQ(result.points, mirror.winnerPoints()); - ASSERT_EQ(result.scores.size(), mirror.getPlayers().size()); - int total = 0; - for (std::size_t i = 0; i < result.scores.size(); ++i) { - EXPECT_EQ(result.scores[i].playerId, mirror.getPlayer(static_cast(i)).id); - EXPECT_EQ(result.scores[i].deadwood, mirror.deadwood(static_cast(i))); - total += result.scores[i].deadwood; + const RummyView& view = final_view->as_gameState_or_null()->view; + 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"}); + for (const auto& standing : view.standings) { + if (standing.playerId == winner) EXPECT_GE(standing.handsWon, 1); } - EXPECT_EQ(result.points, total); auto room = ReceiveCase(seat->stream, "roomState"); ASSERT_TRUE(room.has_value()); - EXPECT_TRUE(room->as_roomState_or_null()->games.empty()); + ASSERT_EQ(room->as_roomState_or_null()->games.size(), 1u); + EXPECT_EQ(room->as_roomState_or_null()->games[0].status, "choosing"); for (const auto& player : room->as_roomState_or_null()->players) { - EXPECT_EQ(player.gamesPlayed, 1); - EXPECT_EQ(player.gamesWon, player.playerId == winner ? 1 : 0); + EXPECT_EQ(player.gamesPlayed, games_played); + if (player.playerId == winner) EXPECT_GE(player.gamesWon, 1); // Rummy's points are the winner's, on a scale golf's running total - // does not share: they ride gameEnded and leave the total alone. + // does not share: they ride lastDeal and leave the total alone. EXPECT_EQ(player.totalScore, 0); - EXPECT_FALSE(player.table.has_value()); + EXPECT_TRUE(player.table.has_value()); } } } @@ -454,12 +474,12 @@ TEST_F(RummyGameFixture, OutOfTurnOrOutOfOrderIsRefusedInBand) { EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not your turn"); // Off turn, the turn is the answer, whatever card the move names: a // card that is not his is no reason to tell him he is out of sync. - ASSERT_TRUE(bob.stream.Send(Discard(Named("A", "♠"))).ok()); + ASSERT_TRUE(bob.stream.Send(Discard(Named("A", "♥"))).ok()); refused = ReceiveCase(bob.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not your turn"); - ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♠"))).ok()); + ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♥"))).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "draw a card first"); @@ -473,11 +493,11 @@ TEST_F(RummyGameFixture, OutOfTurnOrOutOfOrderIsRefusedInBand) { EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "you have already drawn"); // Two cards are no meld; a king does not fit the meld nobody laid. - ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♠"), Named("K", "♠")})).ok()); + ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♥"), Named("K", "♥")})).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "those cards are not a set or a run"); - ASSERT_TRUE(alice.stream.Send(LayOff(Named("K", "♠"), 0)).ok()); + ASSERT_TRUE(alice.stream.Send(LayOff(Named("K", "♥"), 0)).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "no such meld"); @@ -501,36 +521,36 @@ TEST_F(RummyGameFixture, ACardTheHandDoesNotHoldIsRefusedAsStaleAndNamed) { ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); - // A♥ is bob's. - ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♥"))).ok()); + // A♠ is bob's. + ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♠"))).ok()); auto refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); - EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not in your hand: A♥"); - ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♠"), Named("K", "♠"), Named("Q", "♥")})).ok()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not in your hand: A♠"); + ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♥"), Named("K", "♥"), Named("Q", "♠")})).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); - EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not in your hand: Q♥"); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not in your hand: Q♠"); // CardMapper's letters are not the wire's glyphs. ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "S"))).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "no such card: AS"); - ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♠"), Named("A", "♠"), Named("A", "♦")})).ok()); + ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♥"), Named("A", "♥"), Named("A", "♣")})).ok()); refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); - EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "named twice: A♠"); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "named twice: A♥"); EXPECT_EQ(metrics_->CounterTotal("hub_rejections", {{"kind", "state"}}), 2); EXPECT_EQ(metrics_->CounterTotal("hub_rejections", {{"kind", "invalid"}}), 2); EXPECT_EQ(metrics_->CounterTotal("hub_rejections", {{"kind", "rules"}}), 0); ExpectNoEvent(bob.stream); // The card she does hold goes, and it is the card she named. - ASSERT_TRUE(alice.stream.Send(Discard(Named("J", "♦"))).ok()); + ASSERT_TRUE(alice.stream.Send(Discard(Named("J", "♣"))).ok()); auto thrown = ReceiveRummy(bob.stream, "gameState"); ASSERT_TRUE(thrown.has_value()); const RummyView& view = thrown->as_gameState_or_null()->view; ASSERT_TRUE(view.discardTop.has_value()); - EXPECT_EQ(Face(*view.discardTop), "J♦"); + EXPECT_EQ(Face(*view.discardTop), "J♣"); EXPECT_EQ(view.currentPlayerId.value_or(""), bob.player_id); EXPECT_EQ(view.stage.value_or(""), "draw"); } @@ -549,14 +569,14 @@ TEST_F(RummyGameFixture, ADrawAndAMeldLeaveTheTurnUnannounced) { ASSERT_TRUE(alice.stream.Send(DrawDiscard()).ok()); ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); ExpectNoEvent(bob.stream); - ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♠"), Named("K", "♠"), Named("Q", "♠")})).ok()); + ASSERT_TRUE(alice.stream.Send(MeldOf({Named("A", "♥"), Named("K", "♥"), Named("Q", "♥")})).ok()); ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); ExpectNoEvent(bob.stream); - ASSERT_TRUE(alice.stream.Send(LayOff(Named("J", "♠"), 0)).ok()); + ASSERT_TRUE(alice.stream.Send(LayOff(Named("J", "♥"), 0)).ok()); ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); ExpectNoEvent(bob.stream); // Its twin: the discard does announce. - ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♦"))).ok()); + ASSERT_TRUE(alice.stream.Send(Discard(Named("A", "♣"))).ok()); ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); auto next = bob.stream.Receive(kReceiveBudget); ASSERT_TRUE(next.ok() && next->has_value()); diff --git a/domains/games/apis/games_hub/stream_test_fixture.h b/domains/games/apis/games_hub/stream_test_fixture.h index c2e21640..de265ad2 100644 --- a/domains/games/apis/games_hub/stream_test_fixture.h +++ b/domains/games/apis/games_hub/stream_test_fixture.h @@ -697,21 +697,50 @@ class GamesHubStreamFixture : public testing::Test { }); } - // A started rummy table. With the NoShuffleDealer the deck is dealt one - // card a seat from the back: at two seats alice holds A♠ A♦ K♠ K♦ Q♠ - // Q♦ J♠ J♦ 10♠ 10♦ and bob the hearts and clubs of the same ranks, 9♠ - // is turned up, and 9♥ tops the stock. - std::optional MultiSeatRummyTable(int count) { - return MultiSeatTableOf( + // A started rummy table (#1609), between deals: every seat has read the + // opening choosing view, and the first seat — the table's creator — is + // the dealer. + std::optional ChoosingRummyTable(int count) { + auto table = MultiSeatTableOf( count, Rummy, [](moonbase::games::PlayClientStream& stream, const std::string& wanted) { return ReceiveRummy(stream, wanted); }); + if (!table.has_value()) return std::nullopt; + for (Seat& seat : table->seats) { + if (!ReceiveRummy(seat.stream, "gameState").has_value()) return std::nullopt; + } + return table; + } + + // The same table with basic 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"; + if (!table->seats.front() + .stream.Send(Rummy(moonbase::games::RummyMove::FromChoosevariant(basic))) + .ok()) { + return std::nullopt; + } + return table; } // The castle and rummy tables' twins of SeatedTable: two seats in // alice-and-bob form. std::optional SeatedCastleTable() { return AliceAndBob(MultiSeatCastleTable(2)); } - std::optional
SeatedRummyTable() { return AliceAndBob(MultiSeatRummyTable(2)); } + // Rummy's alice is the seat that opens the deal — seat 1, after the + // dealer — and bob deals. At the pristine deck alice holds A♥ A♣ K♥ K♣ + // Q♥ Q♣ J♥ J♣ 10♥ 10♣, bob the spades and diamonds of the same ranks, + // 9♠ is turned up, and 9♥ tops the stock. + std::optional
SeatedRummyTable() { + auto table = MultiSeatRummyTable(2); + if (!table.has_value()) return std::nullopt; + return Table{std::move(table->seats[1]), std::move(table->seats[0]), table->room_id, + table->game_id}; + } static std::optional
AliceAndBob(std::optional table) { if (!table.has_value()) return std::nullopt; return Table{std::move(table->seats[0]), std::move(table->seats[1]), table->room_id, diff --git a/domains/games/libs/cards/rummy/BUILD.bazel b/domains/games/libs/cards/rummy/BUILD.bazel index b0877d11..c3b9191b 100644 --- a/domains/games/libs/cards/rummy/BUILD.bazel +++ b/domains/games/libs/cards/rummy/BUILD.bazel @@ -77,3 +77,64 @@ cc_test( "@nlohmann_json//:json", ], ) + +# The dealer's-choice table (#1609): one deal after another, the dealer +# picking each deal's variant. +cc_library( + name = "table", + srcs = ["table.cc"], + hdrs = ["table.h"], + visibility = ["//visibility:public"], + deps = [ + ":game_state", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_test( + name = "table_test", + size = "small", + srcs = ["table_test.cc"], + deps = [ + ":game_state", + ":table", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "@com_google_absl//absl/status", + "@googletest//:gtest_main", + ], +) + +# Versioned serde of the table (#1609); reads a v1 lone deal as a table. +cc_library( + name = "table_serde", + srcs = ["table_serde.cc"], + hdrs = ["table_serde.h"], + visibility = ["//visibility:public"], + deps = [ + ":game_state", + ":game_state_serde", + ":table", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@nlohmann_json//:json", + ], +) + +cc_test( + name = "table_serde_test", + size = "small", + srcs = ["table_serde_test.cc"], + deps = [ + ":game_state", + ":game_state_serde", + ":table", + ":table_serde", + "//domains/games/libs/cards", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + ], +) diff --git a/domains/games/libs/cards/rummy/game_state.cc b/domains/games/libs/cards/rummy/game_state.cc index 7e0eb274..82ce9705 100644 --- a/domains/games/libs/cards/rummy/game_state.cc +++ b/domains/games/libs/cards/rummy/game_state.cc @@ -41,11 +41,12 @@ vector withHand(const vector& roster, int seat, vector han } // namespace StatusOr dealRummyGame(const string& game_id, const vector& player_ids, - deque shuffled_deck) { + deque shuffled_deck, int opener) { 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"); const int handSize = GameState::handSizeFor(seats); if (static_cast(shuffled_deck.size()) < seats * handSize + 1) { return InvalidArgumentError("deck too small"); @@ -65,7 +66,7 @@ StatusOr dealRummyGame(const string& game_id, const vector& p std::move(discard), std::move(players), {}, - 0, + opener, Stage::Draw, Phase::Playing, std::nullopt, diff --git a/domains/games/libs/cards/rummy/game_state.h b/domains/games/libs/cards/rummy/game_state.h index 7454a583..0f96a88f 100644 --- a/domains/games/libs/cards/rummy/game_state.h +++ b/domains/games/libs/cards/rummy/game_state.h @@ -20,7 +20,8 @@ using std::string; /// The rules this engine plays: /// - 2-4 players, one deck. Two seats are dealt ten cards each, three /// or four seats seven. One card is turned up to start the discard -/// pile; the rest is the stock. Seat 0 moves first. +/// pile; the rest is the stock. The opener the table names moves +/// first (TableState: the seat after the dealer). /// - A turn opens with a draw: the top of the stock, or the top of the /// discard pile. A stock that has run out is refilled by turning the /// discard pile over, all but its top card; with nothing under the top @@ -84,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 seat 0's draw. +/// one card face up to the discard pile. Play opens at `opener`'s draw. [[nodiscard]] absl::StatusOr dealRummyGame(const string& game_id, const std::vector& player_ids, - std::deque shuffled_deck); + std::deque shuffled_deck, + int opener = 0); class GameState { public: diff --git a/domains/games/libs/cards/rummy/table.cc b/domains/games/libs/cards/rummy/table.cc new file mode 100644 index 00000000..ae126fe3 --- /dev/null +++ b/domains/games/libs/cards/rummy/table.cc @@ -0,0 +1,170 @@ +#include "domains/games/libs/cards/rummy/table.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/game_state.h" + +namespace rummy { +using absl::FailedPreconditionError; +using absl::InvalidArgumentError; +using absl::StatusOr; + +std::string_view variantName(Variant variant) { + switch (variant) { + case Variant::Basic: + return "basic"; + } + return "basic"; +} + +std::optional parseVariant(std::string_view name) { + if (name == "basic") return Variant::Basic; + return std::nullopt; +} + +std::vector variantsFor(int seats) { + if (seats < GameState::kMinPlayers || seats > GameState::kMaxPlayers) return {}; + return {Variant::Basic}; +} + +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) + : seats(std::move(_seats)), + wins(std::move(_wins)), + dealer(_dealer), + dealNumber(_dealNumber), + phase(_phase), + variant(_variant), + deal(std::move(_deal)), + gameId(std::move(_gameId)), + versionId(std::move(_versionId)) {} + +StatusOr TableState::open(const std::string& game_id, + const std::vector& seats) { + const int count = static_cast(seats.size()); + if (count < GameState::kMinPlayers || count > GameState::kMaxPlayers) { + return InvalidArgumentError("2 to 4 players"); + } + return TableState{seats, + std::vector(seats.size(), 0), + 0, + 0, + TablePhase::Choosing, + Variant::Basic, + std::nullopt, + game_id, + ""}; +} + +StatusOr TableState::chooseVariant(int seat, Variant chosen, + std::deque shuffled_deck, + bool dealerAway) const { + if (seat < 0 || seat >= static_cast(seats.size())) { + return InvalidArgumentError("no such player"); + } + if (phase != TablePhase::Choosing) return FailedPreconditionError("not between deals"); + if (seat != dealer && !dealerAway) return FailedPreconditionError("the dealer chooses"); + const std::vector offered = variantsFor(static_cast(seats.size())); + if (std::find(offered.begin(), offered.end(), chosen) == offered.end()) { + 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}; +} + +TableState TableState::afterDeal(GameState next) const { + if (!next.isOver()) { + 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()) { + const int at = playerIndex(*winner); + if (at >= 0) newWins.at(at)++; + } + const int nextDealer = (dealer + 1) % static_cast(seats.size()); + return TableState{seats, std::move(newWins), nextDealer, dealNumber, TablePhase::Choosing, + variant, std::move(next), gameId, versionId}; +} + +StatusOr TableState::removePlayer(int seat) const { + if (seat < 0 || seat >= static_cast(seats.size())) { + return InvalidArgumentError("no such player"); + } + if (isOver()) return FailedPreconditionError("table is closed"); + std::optional newDeal = deal; + if (phase == TablePhase::Playing && deal.has_value()) { + auto left = deal->removePlayer(seat); + if (!left.ok()) return left.status(); + newDeal.emplace(*std::move(left)); + } + std::vector newSeats = seats; + newSeats.erase(newSeats.begin() + seat); + std::vector newWins = wins; + newWins.erase(newWins.begin() + seat); + if (newSeats.size() < static_cast(GameState::kMinPlayers)) { + return TableState{std::move(newSeats), + std::move(newWins), + 0, + dealNumber, + TablePhase::Closed, + variant, + std::move(newDeal), + gameId, + versionId}; + } + // The dealer's chair follows its holder; a dealer who left passes it to + // the seat after them, wrapping. + int newDealer = dealer; + if (dealer == seat) { + newDealer = seat % static_cast(newSeats.size()); + } else if (dealer > seat) { + newDealer--; + } + return TableState{std::move(newSeats), + std::move(newWins), + newDealer, + dealNumber, + phase, + variant, + std::move(newDeal), + gameId, + versionId}; +} + +int TableState::playerIndex(const std::string& id) const { + for (size_t i = 0; i < seats.size(); i++) { + if (seats[i] == id) return static_cast(i); + } + return -1; +} + +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)); + return TableState{seats, wins, dealer, dealNumber, phase, variant, stamped, game_id, version_id}; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/table.h b/domains/games/libs/cards/rummy/table.h new file mode 100644 index 00000000..709632e3 --- /dev/null +++ b/domains/games/libs/cards/rummy/table.h @@ -0,0 +1,107 @@ +#ifndef CPP_CARDS_RUMMY_TABLE_H +#define CPP_CARDS_RUMMY_TABLE_H + +#include +#include +#include +#include +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/game_state.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 wire's and the stats pipeline's word for a variant. +[[nodiscard]] std::string_view variantName(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 rummy table (#1609): dealer's choice, one deal after another until +/// the table breaks up. +/// +/// - A table opens choosing, its first seat the dealer. Between deals +/// the dealer picks the next deal's variant from those that fit the +/// seats; a dealer who is away — the hub says so — lets any seat pick. +/// - The seat after the dealer opens the deal. When the deal ends the +/// dealer moves one seat on and the table is choosing again, the +/// finished deal kept to show its hands and result. +/// - A deal won by play is a hand won in the standings. A table below +/// two seats is closed, and so is a deal in progress with it. +/// +/// Seats are the deal's seats while one is in play; a leaver goes from +/// both, and the standings compact with them. +enum class TablePhase { Choosing, Playing, Closed }; + +class TableState { + public: + /// A table opening for these seats: choosing, seat 0 dealing. + [[nodiscard]] static absl::StatusOr open(const std::string& game_id, + 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); + + /// The next deal, from an already-shuffled deck. `seat` must be the + /// dealer's, or any seat's when the dealer is away. + [[nodiscard]] absl::StatusOr chooseVariant(int seat, Variant variant, + 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 + [[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); + if (!next.ok()) return next.status(); + return afterDeal(*std::move(next)); + } + + /// A seat leaving, from the table and any deal in play. + [[nodiscard]] absl::StatusOr removePlayer(int seat) const; + + [[nodiscard]] bool isOver() const { return phase == TablePhase::Closed; } + [[nodiscard]] TablePhase getPhase() const { return phase; } + [[nodiscard]] const std::vector& getSeats() const { return seats; } + /// Hands won, seat by seat. + [[nodiscard]] const std::vector& getWins() const { return wins; } + [[nodiscard]] int getDealer() const { return dealer; } + /// Deals dealt so far; zero before the first. + [[nodiscard]] int getDealNumber() const { return dealNumber; } + /// 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]] int playerIndex(const std::string& id) const; + [[nodiscard]] const std::string& getGameId() const { return gameId; } + [[nodiscard]] const std::string& getVersionId() const { return versionId; } + [[nodiscard]] TableState withIdAndVersion(const std::string& game_id, + const std::string& version_id) const; + + private: + [[nodiscard]] TableState afterDeal(GameState next) const; + + std::vector seats; + std::vector wins; + int dealer; + int dealNumber; + TablePhase phase; + Variant variant; + std::optional deal; + std::string gameId; + std::string versionId; +}; + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/table_serde.cc b/domains/games/libs/cards/rummy/table_serde.cc new file mode 100644 index 00000000..61b5f4ad --- /dev/null +++ b/domains/games/libs/cards/rummy/table_serde.cc @@ -0,0 +1,197 @@ +#include "domains/games/libs/cards/rummy/table_serde.h" + +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#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" + +namespace rummy { +namespace { + +using nlohmann::json; + +constexpr int kSchemaVersion = 2; + +// postgres jsonb rejects a NUL byte: U+FFFD, as the deal's serde does. +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; +} + +const char* phaseName(TablePhase phase) { + switch (phase) { + case TablePhase::Choosing: + return "choosing"; + case TablePhase::Playing: + return "playing"; + case TablePhase::Closed: + return "closed"; + } + return "closed"; +} + +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(); +} + +// A lone deal, from before the table: that deal at a table of its seats. +absl::StatusOr fromVersionOne(const std::string& serialized) { + auto deal = deserializeGameState(serialized); + if (!deal.ok()) return deal.status(); + std::vector seats; + for (const Player& player : deal->getPlayers()) seats.push_back(player.id); + const int count = static_cast(seats.size()); + const bool live = !deal->isOver(); + // The deal opened at the seat after its dealer's; mid-deal, the seat on + // turn is the best record of it there is. + const int dealer = live ? (deal->getWhoseTurn() + count - 1) % count : 0; + return TableState{seats, + std::vector(seats.size(), 0), + dealer, + 1, + live ? TablePhase::Playing : TablePhase::Closed, + Variant::Basic, + *std::move(deal), + "", + ""}; +} + +} // namespace + +std::string serializeTableState(const TableState& table) { + json seats = json::array(); + for (const std::string& seat : table.getSeats()) seats.push_back(sanitized(seat)); + json serialized{ + {"v", kSchemaVersion}, + {"phase", phaseName(table.getPhase())}, + {"seats", std::move(seats)}, + {"wins", table.getWins()}, + {"dealer", table.getDealer()}, + {"dealNumber", table.getDealNumber()}, + {"variant", std::string(variantName(table.getVariant()))}, + }; + if (const auto& deal = table.getDeal(); deal.has_value()) { + serialized["deal"] = json::parse(serializeGameState(*deal)); + } + return serialized.dump(/*indent=*/-1, /*indent_char=*/' ', /*ensure_ascii=*/false, + json::error_handler_t::replace); +} + +absl::StatusOr deserializeTableState(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"); + } + if (parsed.contains("v") && parsed["v"] == 1) return fromVersionOne(serialized); + auto version = readIntInRange(parsed, "v", kSchemaVersion, kSchemaVersion); + if (!version.ok()) { + return absl::InvalidArgumentError( + absl::StrCat("unknown schema version: ", version.status().message())); + } + + auto phase_name = readString(parsed, "phase"); + if (!phase_name.ok()) return phase_name.status(); + std::optional phase; + for (TablePhase candidate : {TablePhase::Choosing, TablePhase::Playing, TablePhase::Closed}) { + if (*phase_name == phaseName(candidate)) phase = candidate; + } + if (!phase.has_value()) return absl::InvalidArgumentError("unknown phase"); + + auto variant_name = readString(parsed, "variant"); + if (!variant_name.ok()) return variant_name.status(); + const std::optional variant = parseVariant(*variant_name); + if (!variant.has_value()) return absl::InvalidArgumentError("unknown variant"); + + if (!parsed.contains("seats") || !parsed["seats"].is_array()) { + return absl::InvalidArgumentError("expected array field 'seats'"); + } + std::vector seats; + for (const json& seat : parsed["seats"]) { + if (!seat.is_string()) return absl::InvalidArgumentError("seats hold player ids"); + seats.push_back(seat.get()); + } + const int count = static_cast(seats.size()); + if (count > GameState::kMaxPlayers) return absl::InvalidArgumentError("too many seats"); + // Only a closed table has fewer than two: that is what closes it. + if (*phase != TablePhase::Closed && count < GameState::kMinPlayers) { + return absl::InvalidArgumentError("an open table seats two"); + } + + if (!parsed.contains("wins") || !parsed["wins"].is_array() || + parsed["wins"].size() != seats.size()) { + return absl::InvalidArgumentError("expected one win count per seat"); + } + std::vector wins; + for (const json& won : parsed["wins"]) { + if (!won.is_number_integer() || won.get() < 0 || won.get() > 1'000'000) { + return absl::InvalidArgumentError("win counts are counts"); + } + wins.push_back(static_cast(won.get())); + } + auto dealer = readIntInRange(parsed, "dealer", 0, count > 0 ? count - 1 : 0); + if (!dealer.ok()) return dealer.status(); + auto deal_number = readIntInRange(parsed, "dealNumber", 0, 1'000'000); + if (!deal_number.ok()) return deal_number.status(); + + 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)); + } + // 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()) { + 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); + if (dealt != seats) return absl::InvalidArgumentError("the deal seats the table"); + } + if (*phase == TablePhase::Choosing && deal.has_value() && !deal->isOver()) { + return absl::InvalidArgumentError("between deals, the last deal is over"); + } + + return TableState{std::move(seats), + std::move(wins), + *dealer, + *deal_number, + *phase, + *variant, + std::move(deal), + "", + ""}; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/table_serde.h b/domains/games/libs/cards/rummy/table_serde.h new file mode 100644 index 00000000..50831dfd --- /dev/null +++ b/domains/games/libs/cards/rummy/table_serde.h @@ -0,0 +1,34 @@ +#ifndef CPP_CARDS_RUMMY_TABLE_SERDE_H +#define CPP_CARDS_RUMMY_TABLE_SERDE_H + +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/rummy/table.h" + +namespace rummy { + +/// Serde for a dealer's-choice table (#1609), the games row's state for a +/// rummy table: server-side only, like the deal's. +/// +/// 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", +/// "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, +/// or closed if the deal had ended, so a finished row stays finished. +/// +/// Deserialize rejects what the table could not play: wins not one per +/// seat, a dealer off the table, an open table of fewer than two seats, a +/// playing table without its deal in play of exactly its seats, a table +/// between deals whose deal is still going or whose deal count disagrees +/// with having one. Keys emit alphabetically, so a re-serialize reproduces +/// the bytes; seat ids get the deal's NUL treatment. +[[nodiscard]] std::string serializeTableState(const TableState& table); +[[nodiscard]] absl::StatusOr deserializeTableState(const std::string& serialized); + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/table_serde_test.cc b/domains/games/libs/cards/rummy/table_serde_test.cc new file mode 100644 index 00000000..3af71dc9 --- /dev/null +++ b/domains/games/libs/cards/rummy/table_serde_test.cc @@ -0,0 +1,206 @@ +#include "domains/games/libs/cards/rummy/table_serde.h" + +#include + +#include +#include +#include +#include + +#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/table.h" + +using namespace cards; +using namespace rummy; +using nlohmann::json; + +namespace { + +std::deque pristineDeck() { + std::deque deck; + for (int i = 0; i < 52; ++i) deck.emplace_back(i); + return deck; +} + +TableState opened() { + auto table = TableState::open("T", {"a", "b"}); + EXPECT_TRUE(table.ok()); + return *table; +} + +TableState playing() { + auto dealt = opened().chooseVariant(0, Variant::Basic, pristineDeck()); + EXPECT_TRUE(dealt.ok()) << dealt.status(); + return *dealt; +} + +// Between deals: the last deal over by play, b having gone out. +TableState between() { + GameState over{{Card{Suit::Clubs, Rank::Two}}, + {Card{Suit::Clubs, Rank::Three}}, + {{"a", {Card{Suit::Clubs, Rank::King}}}, {"b", {}}}, + {}, + GameState::kNoTurn, + Stage::Draw, + Phase::Over, + std::nullopt, + "", + ""}; + return TableState{{"a", "b"}, {0, 1}, 0, 1, TablePhase::Choosing, Variant::Basic, over, "", ""}; +} + +json payloadOf(const TableState& table) { return json::parse(serializeTableState(table)); } + +void expectRejected(const json& payload) { + const auto restored = deserializeTableState(payload.dump()); + ASSERT_FALSE(restored.ok()) << "accepted: " << payload.dump(); + EXPECT_EQ(restored.status().code(), absl::StatusCode::kInvalidArgument); +} + +void expectRoundTrips(const TableState& table) { + const std::string serialized = serializeTableState(table); + const auto restored = deserializeTableState(serialized); + ASSERT_TRUE(restored.ok()) << restored.status() << "\n" << serialized; + EXPECT_EQ(restored->getSeats(), table.getSeats()); + EXPECT_EQ(restored->getWins(), table.getWins()); + EXPECT_EQ(restored->getDealer(), table.getDealer()); + EXPECT_EQ(restored->getDealNumber(), table.getDealNumber()); + 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())); + } + EXPECT_EQ(serializeTableState(*restored), serialized); +} + +} // namespace + +TEST(TableSerde, EveryPhaseRoundTrips) { + expectRoundTrips(opened()); + expectRoundTrips(playing()); + expectRoundTrips(between()); + auto closed = playing().removePlayer(0); + ASSERT_TRUE(closed.ok()); + expectRoundTrips(*closed); +} + +// 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. +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]})"; + EXPECT_EQ(serializeTableState(opened()), kRow); + const auto restored = deserializeTableState(kRow); + ASSERT_TRUE(restored.ok()) << restored.status(); + EXPECT_EQ(restored->getPhase(), TablePhase::Choosing); + // 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"); + EXPECT_EQ(dealt["deal"]["v"], 1); + EXPECT_EQ(dealt["dealNumber"], 1); +} + +// 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 +// finished. +TEST(TableSerde, AVersionOneRowIsOneDeal) { + auto deal = dealRummyGame("", {"a", "b", "c"}, pristineDeck()); + ASSERT_TRUE(deal.ok()); + auto drew = deal->drawStock(0); + ASSERT_TRUE(drew.ok()); + auto table = deserializeTableState(serializeGameState(*drew)); + ASSERT_TRUE(table.ok()) << table.status(); + EXPECT_EQ(table->getPhase(), TablePhase::Playing); + EXPECT_EQ(table->getSeats(), (std::vector{"a", "b", "c"})); + 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(serializeGameState(*table->getDeal()), serializeGameState(*drew)); + + auto gone = drew->removePlayer(1); + ASSERT_TRUE(gone.ok()); + auto two = gone->removePlayer(1); + ASSERT_TRUE(two.ok()); + ASSERT_TRUE(two->isOver()); + auto closed = deserializeTableState(serializeGameState(*two)); + ASSERT_TRUE(closed.ok()) << closed.status(); + EXPECT_TRUE(closed->isOver()); +} + +TEST(TableSerde, RejectsATableTheEngineCouldNotPlay) { + json payload = payloadOf(opened()); + payload["v"] = 3; + expectRejected(payload); + + for (const char* key : {"phase", "seats", "wins", "dealer", "dealNumber", "variant"}) { + payload = payloadOf(opened()); + payload.erase(key); + expectRejected(payload); + } + payload = payloadOf(opened()); + payload["variant"] = "canasta"; + expectRejected(payload); + payload = payloadOf(opened()); + payload["phase"] = "dealing"; + expectRejected(payload); + payload = payloadOf(opened()); + payload["wins"] = json::array({0}); // one short of the seats + expectRejected(payload); + payload = payloadOf(opened()); + payload["wins"] = json::array({0, -1}); + expectRejected(payload); + payload = payloadOf(opened()); + payload["dealer"] = 2; + expectRejected(payload); + payload = payloadOf(opened()); + payload["seats"] = json::array({"a"}); + payload["wins"] = json::array({0}); + expectRejected(payload); // an open table seats two + + // In play: a deal, in play, of exactly the table's seats. + payload = payloadOf(playing()); + payload.erase("deal"); + expectRejected(payload); + payload = payloadOf(playing()); + payload["seats"] = json::array({"a", "z"}); + expectRejected(payload); + payload = payloadOf(playing()); + payload["deal"]["phase"] = "over"; + payload["deal"]["whoseTurn"] = -1; + expectRejected(payload); + payload = payloadOf(playing()); + payload["deal"] = 7; + expectRejected(payload); + + // Between deals: the last deal, ended; none before the first. + payload = payloadOf(between()); + payload["deal"]["phase"] = "playing"; + payload["deal"]["whoseTurn"] = 0; + payload["deal"]["players"][0]["hand"] = json::array({3}); + expectRejected(payload); + payload = payloadOf(opened()); + payload["dealNumber"] = 1; + expectRejected(payload); // dealt once, and no deal to show + payload = payloadOf(between()); + payload["dealNumber"] = 0; + expectRejected(payload); // a deal, and none dealt + + for (const char* input : {"", "[]", "not json", R"({"v":2})"}) { + EXPECT_FALSE(deserializeTableState(input).ok()) << input; + } +} + +TEST(TableSerde, NulInASeatIdIsReplaced) { + const std::string nul_id("a\0b", 3); + auto table = TableState::open("T", {nul_id, "b"}); + ASSERT_TRUE(table.ok()); + const std::string serialized = serializeTableState(*table); + EXPECT_EQ(serialized.find('\0'), std::string::npos); + EXPECT_TRUE(deserializeTableState(serialized).ok()); +} diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc new file mode 100644 index 00000000..66446024 --- /dev/null +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -0,0 +1,226 @@ +#include "domains/games/libs/cards/rummy/table.h" + +#include + +#include +#include +#include + +#include "absl/status/status.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/dealer.h" +#include "domains/games/libs/cards/rummy/game_state.h" + +using namespace cards; +using namespace rummy; +using std::string; +using std::vector; + +namespace { + +std::deque deck() { return NoShuffleDealer().DealNewUnshuffledDeck(); } + +TableState opened(vector seats = {"a", "b", "c"}) { + auto table = TableState::open("T1", seats); + EXPECT_TRUE(table.ok()) << table.status(); + return *table; +} + +// 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, + std::move(deal), table.getGameId(), table.getVersionId()}; +} + +} // namespace + +TEST(Variants, NamesRoundTripAndFitTheSeats) { + EXPECT_EQ(variantName(Variant::Basic), "basic"); + EXPECT_EQ(parseVariant("basic"), Variant::Basic); + EXPECT_FALSE(parseVariant("gin").has_value()); + EXPECT_FALSE(parseVariant("").has_value()); + for (int seats : {2, 3, 4}) EXPECT_EQ(variantsFor(seats), vector{Variant::Basic}); + EXPECT_TRUE(variantsFor(1).empty()); + EXPECT_TRUE(variantsFor(5).empty()); +} + +TEST(Table, OpensChoosingWithTheFirstSeatDealingAndNoDeal) { + const TableState table = opened(); + EXPECT_EQ(table.getPhase(), TablePhase::Choosing); + EXPECT_EQ(table.getDealer(), 0); + EXPECT_EQ(table.getDealNumber(), 0); + EXPECT_FALSE(table.getDeal().has_value()); + EXPECT_EQ(table.getWins(), (vector{0, 0, 0})); + EXPECT_FALSE(table.isOver()); + EXPECT_EQ(table.getGameId(), "T1"); +} + +TEST(Table, OpensForTwoToFourSeats) { + EXPECT_FALSE(TableState::open("T", {"a"}).ok()); + EXPECT_FALSE(TableState::open("T", {"a", "b", "c", "d", "e"}).ok()); +} + +TEST(Table, TheDealerChoosesAndTheSeatAfterOpens) { + auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); + ASSERT_TRUE(dealt.ok()) << dealt.status(); + EXPECT_EQ(dealt->getPhase(), TablePhase::Playing); + 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"); +} + +TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { + const TableState table = opened(); + EXPECT_EQ(table.chooseVariant(1, Variant::Basic, deck()).status().code(), + absl::StatusCode::kFailedPrecondition); + auto away = table.chooseVariant(2, Variant::Basic, 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->getDeal()->getWhoseTurn(), 1); + EXPECT_EQ(table.chooseVariant(7, Variant::Basic, deck(), true).status().code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(Table, NoChoosingWhileADealIsInPlay) { + auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); + ASSERT_TRUE(dealt.ok()); + EXPECT_EQ(dealt->chooseVariant(0, Variant::Basic, deck()).status().code(), + absl::StatusCode::kFailedPrecondition); +} + +TEST(Table, DealMovesGoToTheDealAndNowhereElse) { + const TableState table = opened(); + 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); }); + ASSERT_TRUE(drew.ok()) << drew.status(); + EXPECT_EQ(drew->getDeal()->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); +} + +TEST(Table, ADealWonByPlayScoresTheHandAndTheDealPassesOn) { + const TableState table = opened(); + // b holds one card after drawing; discarding it goes out. + GameState deal{{Card{Suit::Clubs, Rank::Two}}, + {Card{Suit::Clubs, Rank::Three}}, + {{"a", {Card{Suit::Clubs, Rank::Four}}}, + {"b", {Card{Suit::Clubs, Rank::Five}}}, + {"c", {Card{Suit::Clubs, Rank::Six}}}}, + {}, + 1, + Stage::Play, + Phase::Playing, + std::nullopt, + "T1", + ""}; + auto out = withDeal(table, deal).inDeal([](const GameState& d) { + return d.discard(1, Card{Suit::Clubs, Rank::Five}); + }); + ASSERT_TRUE(out.ok()) << out.status(); + EXPECT_EQ(out->getPhase(), TablePhase::Choosing); + EXPECT_EQ(out->getWins(), (vector{0, 1, 0})); + 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_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->getWins(), (vector{0, 1, 0})); +} + +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()); + ASSERT_TRUE(dealt.ok()); + EXPECT_EQ(dealt->getDeal()->getWhoseTurn(), 0); +} + +TEST(Table, ALeaveWhileChoosingCompactsTheSeatsAndTheDealerFollows) { + const TableState choosing{{"a", "b", "c"}, {2, 1, 0}, 2, 3, TablePhase::Choosing, + Variant::Basic, std::nullopt, "T", ""}; + auto left = choosing.removePlayer(0); + ASSERT_TRUE(left.ok()) << left.status(); + EXPECT_EQ(left->getSeats(), (vector{"b", "c"})); + EXPECT_EQ(left->getWins(), (vector{1, 0})); + EXPECT_EQ(left->getSeats().at(left->getDealer()), "c"); + EXPECT_EQ(left->getPhase(), TablePhase::Choosing); +} + +TEST(Table, TheDealerLeavingPassesTheChoiceOn) { + const TableState choosing{{"a", "b", "c"}, {0, 0, 0}, 2, 1, TablePhase::Choosing, + Variant::Basic, std::nullopt, "T", ""}; + auto left = choosing.removePlayer(2); + ASSERT_TRUE(left.ok()); + // The seat after the leaver, wrapping. + EXPECT_EQ(left->getSeats().at(left->getDealer()), "a"); +} + +TEST(Table, ADealerMidTableLeavingPassesTheChoiceToTheSeatAfter) { + const TableState choosing{{"a", "b", "c", "d"}, {0, 0, 0, 0}, 1, 1, TablePhase::Choosing, + Variant::Basic, 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()); + ASSERT_TRUE(dealt.ok()); + auto left = dealt->removePlayer(2); + 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->getWins().size(), 2u); +} + +TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { + auto dealt = opened({"a", "b"}).chooseVariant(0, Variant::Basic, deck()); + ASSERT_TRUE(dealt.ok()); + auto left = dealt->removePlayer(0); + ASSERT_TRUE(left.ok()); + EXPECT_TRUE(left->isOver()); + EXPECT_EQ(left->getDeal()->getPhase(), Phase::Abandoned); + auto choosing_left = opened({"a", "b"}).removePlayer(1); + ASSERT_TRUE(choosing_left.ok()); + EXPECT_TRUE(choosing_left->isOver()); + // Closed is closed. + EXPECT_FALSE(left->removePlayer(0).ok()); + EXPECT_FALSE(left->chooseVariant(0, Variant::Basic, 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()); + ASSERT_TRUE(dealt.ok()); + const TableState stamped = dealt->withIdAndVersion("G", "V"); + EXPECT_EQ(stamped.getGameId(), "G"); + EXPECT_EQ(stamped.getVersionId(), "V"); + EXPECT_EQ(stamped.getDeal()->getGameId(), "G"); +} + +TEST(Deal, OpensAtTheSeatNamed) { + NoShuffleDealer dealer; + auto game = dealRummyGame("g", {"a", "b", "c"}, dealer.DealNewUnshuffledDeck(), 2); + ASSERT_TRUE(game.ok()); + EXPECT_EQ(game->getWhoseTurn(), 2); + EXPECT_FALSE(dealRummyGame("g", {"a", "b"}, dealer.DealNewUnshuffledDeck(), 2).ok()); +} From 7e31bca3c7fba8342c27e5cc3e15b907fa180d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 22:33:51 +0000 Subject: [PATCH 2/4] rummy: seven-card deals, dealer's-choice tests, room status on deal --- domains/games/apis/games_hub/BUILD.bazel | 2 +- domains/games/apis/games_hub/README.md | 18 +- domains/games/apis/games_hub/golf_hub.cc | 3 +- .../games/apis/games_hub/pg_hub_e2e_test.cc | 25 +- .../games/apis/games_hub/pg_hub_store_test.cc | 26 +- .../games/apis/games_hub/rummy_e2e_test.cc | 312 ++++++++++++----- .../games/apis/games_hub/rummy_wire_test.cc | 319 ++++++++++++------ .../apis/games_hub/stream_test_fixture.h | 4 +- domains/games/libs/cards/rummy/README.md | 5 +- domains/games/libs/cards/rummy/game_state.cc | 5 +- domains/games/libs/cards/rummy/game_state.h | 9 +- .../libs/cards/rummy/game_state_serde_test.cc | 48 +-- .../games/libs/cards/rummy/game_state_test.cc | 19 +- 13 files changed, 532 insertions(+), 263 deletions(-) diff --git a/domains/games/apis/games_hub/BUILD.bazel b/domains/games/apis/games_hub/BUILD.bazel index db13113c..7a5cd97d 100644 --- a/domains/games/apis/games_hub/BUILD.bazel +++ b/domains/games/apis/games_hub/BUILD.bazel @@ -865,8 +865,8 @@ cc_test( "//domains/games/libs/cards", "//domains/games/libs/cards:dealer", "//domains/games/libs/cards/rummy:game_state", - "//domains/games/libs/cards/rummy:table", "//domains/games/libs/cards/rummy:meld", + "//domains/games/libs/cards/rummy:table", "//domains/platform/libs/futility/otel:capturing_metrics_recorder", "//domains/platform/libs/futility/otel:metrics", "@googletest//:gtest_main", diff --git a/domains/games/apis/games_hub/README.md b/domains/games/apis/games_hub/README.md index 8271b093..2db6dca4 100644 --- a/domains/games/apis/games_hub/README.md +++ b/domains/games/apis/games_hub/README.md @@ -351,17 +351,23 @@ engine is `libs/cards/golf`'s immutable `GameState`, which also carries `libs/cards/castle`'s and rummy's `libs/cards/rummy`'s, stated on each engine's `GameState`. -A rummy finish credits the seat that went out with a win and every seat -with a game played; its points (what the others still held) ride -`gameEnded` and stay out of the room's running total, which is golf's -lower-is-better scale. +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 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. +The table ends only below two seats, and its `gameEnded` carries the hands +each seat won. ## Redaction A castle table redacts by `CastleViewLocked`: own hand faces (everyone's once the game ends), every face-up row, face-down rows as counts. A rummy -table by `RummyViewLocked`: own hand faces (everyone's once the game -ends), other hands as counts, the stock as a count; the melds, the +table by `RummyViewLocked`: own hand faces (everyone's once the deal +ends, and between deals), other hands as counts, the stock as a count; the melds, the discard's top and the card taken from it this turn are public, and a stock draw's `lastMove` names no card. Golf's rules, below, are `ViewLocked`'s. diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index 3b7cea89..5fba247d 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -2494,8 +2494,9 @@ void GolfHub::TableEngineMove(const std::string& player_id, GameKind kind, if (deal_ended) { // The hand is the room's too: its stats moved. MirrorStatsLocked(*ref->room, *deltas); - StageRoomStateLocked(ref->room_id, outbox); } + // Either way the table's status in the room's listing moved. + if (deal_ended || deal_started) StageRoomStateLocked(ref->room_id, outbox); // Between deals nobody is on turn, and that is no turn to announce. const std::string current_turn = CurrentTurnOf(*ref->entry->state); if (current_turn != previous_turn && !current_turn.empty()) { 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 39efa4d7..c6332a7c 100644 --- a/domains/games/apis/games_hub/pg_hub_e2e_test.cc +++ b/domains/games/apis/games_hub/pg_hub_e2e_test.cc @@ -737,7 +737,7 @@ TEST_F(PgGamesHubFixture, RummyTableSurvivesARestartMidTurn) { .ok()); ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); moonbase::games::RummyMeld meld; - meld.cards = {Named("A", "♠"), Named("K", "♠"), Named("Q", "♠")}; + meld.cards = {Named("A", "♥"), Named("K", "♥"), Named("Q", "♥")}; ASSERT_TRUE(alice.stream.Send(Rummy(RummyMove::FromMeld(meld))).ok()); auto before_update = ReceiveRummy(alice.stream, "gameState"); ASSERT_TRUE(before_update.has_value()); @@ -768,22 +768,25 @@ TEST_F(PgGamesHubFixture, RummyTableSurvivesARestartMidTurn) { EXPECT_EQ(after.stockCount, before.stockCount); EXPECT_EQ(after.discardCount, 0); ASSERT_TRUE(after.takenDiscard.has_value()); - EXPECT_EQ(after.takenDiscard->rank + after.takenDiscard->suit, "9♠"); + EXPECT_EQ(after.takenDiscard->rank + after.takenDiscard->suit, "J♦"); ASSERT_EQ(after.melds.size(), 1u); EXPECT_EQ(after.melds[0].owner, alice.player_id); ASSERT_EQ(after.melds[0].cards.size(), 3u); - EXPECT_EQ(after.melds[0].cards[0].rank + after.melds[0].cards[0].suit, "Q♠"); + EXPECT_EQ(after.melds[0].cards[0].rank + after.melds[0].cards[0].suit, "Q♥"); ASSERT_TRUE(after.lastMove.has_value()); EXPECT_EQ(after.lastMove->move, "meld"); EXPECT_EQ(after.lastMove->meldIndex.value_or(-1), 0); + // Alice opens from seat 1; bob, seat 0, dealt. ASSERT_EQ(after.players.size(), 2u); - ASSERT_EQ(after.players[0].hand.size(), before.players[0].hand.size()); - for (std::size_t i = 0; i < after.players[0].hand.size(); ++i) { - EXPECT_EQ(after.players[0].hand[i].rank, before.players[0].hand[i].rank); - EXPECT_EQ(after.players[0].hand[i].suit, before.players[0].hand[i].suit); + ASSERT_EQ(after.players[1].hand.size(), before.players[1].hand.size()); + for (std::size_t i = 0; i < after.players[1].hand.size(); ++i) { + EXPECT_EQ(after.players[1].hand[i].rank, before.players[1].hand[i].rank); + EXPECT_EQ(after.players[1].hand[i].suit, before.players[1].hand[i].suit); } - EXPECT_TRUE(after.players[1].hand.empty()); - EXPECT_EQ(after.players[1].handCount, 10); + 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"); auto bob_back = OpenSeat(bob_token); ASSERT_TRUE(bob_back.has_value()); @@ -792,14 +795,14 @@ TEST_F(PgGamesHubFixture, RummyTableSurvivesARestartMidTurn) { // The rule the taken card carries came back with it. moonbase::games::RummyDiscard straight_back; - straight_back.card = Named("9", "♠"); + straight_back.card = Named("J", "♦"); ASSERT_TRUE(alice_back->stream.Send(Rummy(RummyMove::FromDiscard(straight_back))).ok()); auto refused = ReceiveCase(alice_back->stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "you took that card from the discard pile this turn"); moonbase::games::RummyDiscard other; - other.card = Named("A", "♦"); + other.card = Named("A", "♣"); ASSERT_TRUE(alice_back->stream.Send(Rummy(RummyMove::FromDiscard(other))).ok()); auto next_turn = ReceiveRummy(bob_back->stream, "turnChanged"); ASSERT_TRUE(next_turn.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 4e0b2608..0cac1a7c 100644 --- a/domains/games/apis/games_hub/pg_hub_store_test.cc +++ b/domains/games/apis/games_hub/pg_hub_store_test.cc @@ -21,7 +21,8 @@ #include "domains/games/libs/cards/golf/game_state.h" #include "domains/games/libs/cards/golf/game_state_serde.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/table.h" +#include "domains/games/libs/cards/rummy/table_serde.h" #include "domains/platform/libs/pg/listener.h" #include "domains/platform/libs/pg/pg.h" #include "gtest/gtest.h" @@ -151,8 +152,9 @@ TEST_F(PgHubStoreTest, CastleRowsKeepTheirKindAndDecodeWithCastleSerde) { } // A rummy table (#245) is the third engine behind the same rows: its kind -// is stored, and a mid-turn state — melds, the card taken from the -// discard, the last move — decodes with rummy's serde byte for byte. +// is stored, and a table mid-deal (#1609) — melds, the card taken from +// the discard, the last move — decodes with the table's serde byte for +// byte. TEST_F(PgHubStoreTest, RummyRowsKeepTheirKindAndDecodeWithRummySerde) { store_->Enqueue({PgHubStore::UpsertRoom{"R1"}}); store_->Flush(); @@ -167,12 +169,10 @@ 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(); - PgHubStore::GameRow started{"R1", - "M2", - {"alice", "bob"}, - games_hub::HostedState(*melded), - 1, - games_hub::GameKind::kRummy}; + const rummy::TableState table{{"alice", "bob"}, {0, 0}, 1, 1, rummy::TablePhase::Playing, + rummy::Variant::Basic, *melded, "", ""}; + PgHubStore::GameRow started{ + "R1", "M2", {"alice", "bob"}, games_hub::HostedState(table), 1, games_hub::GameKind::kRummy}; ASSERT_TRUE(*store_->CommitGameSave(started, "")); auto rows = store_->LoadRoom("R1"); @@ -185,12 +185,12 @@ TEST_F(PgHubStoreTest, RummyRowsKeepTheirKindAndDecodeWithRummySerde) { continue; } ASSERT_TRUE(game.state.has_value()); - ASSERT_TRUE(std::holds_alternative(*game.state)); - EXPECT_EQ(rummy::serializeGameState(std::get(*game.state)), - rummy::serializeGameState(*melded)); + ASSERT_TRUE(std::holds_alternative(*game.state)); + EXPECT_EQ(rummy::serializeTableState(std::get(*game.state)), + rummy::serializeTableState(table)); } // A started row's column follows its state, whatever the row said. - PgHubStore::GameRow mislabeled{"R1", "M3", {"alice", "bob"}, games_hub::HostedState(*melded), 1}; + PgHubStore::GameRow mislabeled{"R1", "M3", {"alice", "bob"}, games_hub::HostedState(table), 1}; ASSERT_TRUE(*store_->CommitGameSave(mislabeled, "")); auto relabeled = store_->LoadGame("R1", "M3"); ASSERT_TRUE(relabeled.ok() && relabeled->has_value()); diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc index 919e83e2..dd89aadf 100644 --- a/domains/games/apis/games_hub/rummy_e2e_test.cc +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -4,13 +4,11 @@ // Every table is dealer's choice (#1609): the creator deals first, and the // seat after the dealer opens. Two kinds of deal. The NoShuffleDealer // deals the pristine deck one card a seat from the back, so every card is -// known: at two seats alice (the opener) holds A♥ A♣ K♥ K♣ Q♥ Q♣ J♥ J♣ -// 10♥ 10♣, bob (the dealer) the spades and diamonds of the same ranks, 9♠ -// is turned up and 9♥ tops the stock. The SeededDealer shuffles -// with a fixed seed for whole games, which a local engine mirror plays -// alongside the hub: every turn the mirror picks a move by a simple -// policy, the same command goes to the hub, and every seat's view must -// agree with the mirror. +// known: at two seats alice (the opener) holds A♥ A♣ K♥ K♣ Q♥ Q♣ J♥, bob +// (the dealer) A♠ A♦ K♠ K♦ Q♠ Q♦ J♠; J♦ is turned up and J♣ tops the +// stock. The SeededDealer shuffles with a fixed seed for whole games, which a local engine mirror +// plays alongside the hub: every turn the mirror picks a move by a simple policy, the same command +// goes to the hub, and every seat's view must agree with the mirror. #include @@ -188,7 +186,7 @@ class RummyGameFixture : public GamesHubStreamFixture { const bool ended = mirror.isOver(); // A deal won by play leaves the table choosing the next; one broken up // by a leave closes it. - EXPECT_EQ(seen.phase, !ended ? "playing" + EXPECT_EQ(seen.phase, !ended ? "playing" : mirror.getPhase() == rummy::Phase::Over ? "choosing" : "ended"); EXPECT_EQ(seen.variant.value_or(""), "basic"); @@ -372,61 +370,60 @@ class RummyGameFixture : public GamesHubStreamFixture { }; // The deal from each chair, then the quickest win the pristine deck -// allows: alice takes the turned-up 9♠ and lays down her whole hand as -// two runs — no discard needed to go out. +// allows: alice draws J♣ and lays her whole hand down as two runs, J to +// A in hearts and in clubs — no discard needed to go out. TEST_F(RummyGameFixture, TheDealIsRedactedPerSeatAndLayingDownEverythingWins) { auto table = SeatedRummyTable(); ASSERT_TRUE(table.has_value()); auto& alice = table->alice; auto& bob = table->bob; std::optional mirror( - MirrorDeal(table->game_id, {alice.player_id, bob.player_id})); + MirrorDeal(table->game_id, {bob.player_id, alice.player_id})); // The dealt view, in rummy's envelope, from each chair. ASSERT_NO_FATAL_FAILURE(ExpectViews({&alice, &bob}, *mirror, "the deal")); - ASSERT_TRUE(alice.stream.Send(DrawDiscard()).ok()); - auto drew = mirror->drawDiscard(0); + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + auto drew = mirror->drawStock(1); ASSERT_TRUE(drew.ok()); mirror.emplace(*drew); - ASSERT_NO_FATAL_FAILURE(ExpectViews({&alice, &bob}, *mirror, "drawDiscard")); + ASSERT_NO_FATAL_FAILURE(ExpectViews({&alice, &bob}, *mirror, "drawStock")); - ASSERT_TRUE(alice.stream - .Send(MeldOf({Named("A", "♠"), Named("9", "♠"), Named("K", "♠"), Named("J", "♠"), - Named("10", "♠"), Named("Q", "♠")})) - .ok()); - auto spades = mirror->meld(0, {cards::Card{cards::Suit::Spades, cards::Rank::Ace}, - cards::Card{cards::Suit::Spades, cards::Rank::Nine}, - cards::Card{cards::Suit::Spades, cards::Rank::King}, - cards::Card{cards::Suit::Spades, cards::Rank::Jack}, - cards::Card{cards::Suit::Spades, cards::Rank::Ten}, - cards::Card{cards::Suit::Spades, cards::Rank::Queen}}); - ASSERT_TRUE(spades.ok()) << spades.status(); - mirror.emplace(*spades); - auto after_spades = ReceiveRummy(bob.stream, "gameState"); - ASSERT_TRUE(after_spades.has_value()); - ExpectBoard(after_spades->as_gameState_or_null()->view, bob.player_id, *mirror); + ASSERT_TRUE( + alice.stream + .Send(MeldOf({Named("A", "♥"), Named("J", "♥"), Named("K", "♥"), Named("Q", "♥")})) + .ok()); + auto hearts = mirror->meld(1, {cards::Card{cards::Suit::Hearts, cards::Rank::Ace}, + cards::Card{cards::Suit::Hearts, cards::Rank::Jack}, + cards::Card{cards::Suit::Hearts, cards::Rank::King}, + cards::Card{cards::Suit::Hearts, cards::Rank::Queen}}); + ASSERT_TRUE(hearts.ok()) << hearts.status(); + mirror.emplace(*hearts); + auto after_hearts = ReceiveRummy(bob.stream, "gameState"); + ASSERT_TRUE(after_hearts.has_value()); + ExpectBoard(after_hearts->as_gameState_or_null()->view, bob.player_id, *mirror); // Laid low to high, whatever order it was named in. - EXPECT_EQ(Faces(after_spades->as_gameState_or_null()->view.melds.at(0).cards), - (std::vector{"9♠", "10♠", "J♠", "Q♠", "K♠", "A♠"})); + EXPECT_EQ(Faces(after_hearts->as_gameState_or_null()->view.melds.at(0).cards), + (std::vector{"J♥", "Q♥", "K♥", "A♥"})); ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); - ASSERT_TRUE(alice.stream - .Send(MeldOf({Named("10", "♦"), Named("J", "♦"), Named("Q", "♦"), Named("K", "♦"), - Named("A", "♦")})) - .ok()); - auto diamonds = mirror->meld(0, {cards::Card{cards::Suit::Diamonds, cards::Rank::Ten}, - cards::Card{cards::Suit::Diamonds, cards::Rank::Jack}, - cards::Card{cards::Suit::Diamonds, cards::Rank::Queen}, - cards::Card{cards::Suit::Diamonds, cards::Rank::King}, - cards::Card{cards::Suit::Diamonds, cards::Rank::Ace}}); - ASSERT_TRUE(diamonds.ok()) << diamonds.status(); - mirror.emplace(*diamonds); - ASSERT_NO_FATAL_FAILURE(ExpectEnding({&alice, &bob}, *mirror)); - // Bob held A♥ A♣ and the hearts and clubs from ten to king: 1+1+80. - EXPECT_EQ(mirror->winnerPoints(), 82); + ASSERT_TRUE( + alice.stream + .Send(MeldOf({Named("J", "♣"), Named("Q", "♣"), Named("K", "♣"), Named("A", "♣")})) + .ok()); + auto clubs = mirror->meld(1, {cards::Card{cards::Suit::Clubs, cards::Rank::Jack}, + cards::Card{cards::Suit::Clubs, cards::Rank::Queen}, + cards::Card{cards::Suit::Clubs, cards::Rank::King}, + cards::Card{cards::Suit::Clubs, cards::Rank::Ace}}); + ASSERT_TRUE(clubs.ok()) << clubs.status(); + mirror.emplace(*clubs); + // The deal passes to alice, the seat after bob. + ASSERT_NO_FATAL_FAILURE(ExpectDealEnding({&alice, &bob}, *mirror, alice.player_id)); + // Bob held A♠ A♦ and five tens' worth of court cards: 1+1+50. + EXPECT_EQ(mirror->winnerPoints(), 52); EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "meld"}}), 2); - EXPECT_EQ(metrics_->CounterTotal("rummy_events", {{"event", "gameEnded"}}), 2); + // A deal's end is the view's lastDeal; gameEnded is the table's. + EXPECT_EQ(metrics_->CounterTotal("rummy_events", {{"event", "gameEnded"}}), 0); } // A stock draw is private: the drawer sees the card, everyone else a @@ -445,10 +442,11 @@ TEST_F(RummyGameFixture, AStockDrawShowsTheCardOnlyToItsDrawer) { ASSERT_TRUE(hers.has_value() && his.has_value()); const RummyView& her_view = hers->as_gameState_or_null()->view; const RummyView& his_view = his->as_gameState_or_null()->view; - EXPECT_EQ(Face(her_view.players[0].hand.back()), "9♥"); - EXPECT_TRUE(his_view.players[0].hand.empty()); - EXPECT_EQ(his_view.players[0].handCount, 11); - EXPECT_EQ(his_view.stockCount, 30); + // Alice sits in seat 1, after the dealer. + EXPECT_EQ(Face(her_view.players[1].hand.back()), "J♣"); + EXPECT_TRUE(his_view.players[1].hand.empty()); + EXPECT_EQ(his_view.players[1].handCount, 8); + EXPECT_EQ(his_view.stockCount, 36); ASSERT_TRUE(his_view.lastMove.has_value()); EXPECT_EQ(his_view.lastMove->move, "drawStock"); EXPECT_TRUE(his_view.lastMove->cards.empty()); @@ -600,13 +598,13 @@ TEST_F(RummyGameFixture, TheCardTakenFromTheDiscardIsPublicAndCannotGoStraightBa ASSERT_TRUE(his.has_value()); const RummyView& view = his->as_gameState_or_null()->view; ASSERT_TRUE(view.takenDiscard.has_value()); - EXPECT_EQ(Face(*view.takenDiscard), "9♠"); + EXPECT_EQ(Face(*view.takenDiscard), "J♦"); EXPECT_EQ(view.lastMove->move, "drawDiscard"); - EXPECT_EQ(Faces(view.lastMove->cards), std::vector{"9♠"}); + EXPECT_EQ(Faces(view.lastMove->cards), std::vector{"J♦"}); EXPECT_FALSE(view.discardTop.has_value()); EXPECT_EQ(view.discardCount, 0); - ASSERT_TRUE(alice.stream.Send(Discard(Named("9", "♠"))).ok()); + ASSERT_TRUE(alice.stream.Send(Discard(Named("J", "♦"))).ok()); auto refused = ReceiveCase(alice.stream, "commandRejected"); ASSERT_TRUE(refused.has_value()); EXPECT_EQ(refused->as_commandRejected_or_null()->reason, @@ -619,9 +617,7 @@ TEST_F(RummyGameFixture, TheCardTakenFromTheDiscardIsPublicAndCannotGoStraightBa // end — every view checked against the engine on the way. class SeededRummyFixture : public RummyGameFixture, public ::testing::WithParamInterface { protected: - std::shared_ptr MakeDealer() override { - return std::make_shared(1234); - } + std::shared_ptr MakeDealer() override { return std::make_shared(7); } }; TEST_P(SeededRummyFixture, AWholeGameAgreesWithTheEngine) { @@ -635,7 +631,7 @@ TEST_P(SeededRummyFixture, AWholeGameAgreesWithTheEngine) { PlayedOut played; ASSERT_NO_FATAL_FAILURE(PlayToEnd(seats, mirror, played)); - ASSERT_NO_FATAL_FAILURE(ExpectEnding(seats, *mirror)); + ASSERT_NO_FATAL_FAILURE(ExpectDealEnding(seats, *mirror, table->ids()[1])); // The policy's game touched every move, so each was checked above. EXPECT_GT(played.melds, 0); EXPECT_GT(played.lay_offs, 0); @@ -649,15 +645,16 @@ TEST_P(SeededRummyFixture, AWholeGameAgreesWithTheEngine) { INSTANTIATE_TEST_SUITE_P(TwoThreeAndFourSeats, SeededRummyFixture, ::testing::Values(2, 3, 4)); // A stock that ran out is the discard pile turned over. The pristine -// deck at two seats leaves 31 in the stock: alice and bob each draw and +// deck at two seats leaves 37 in the stock: alice and bob each draw and // throw back what they drew until it is gone, and the next draw refills // it from under the top card, the table seeing the counts move. TEST_F(RummyGameFixture, AnEmptyStockIsRefilledFromTheDiscardPile) { auto table = SeatedRummyTable(); ASSERT_TRUE(table.has_value()); - std::vector seats{&table->alice, &table->bob}; + // In seat order: bob deals, alice opens. + std::vector seats{&table->bob, &table->alice}; std::optional mirror( - MirrorDeal(table->game_id, {table->alice.player_id, table->bob.player_id})); + MirrorDeal(table->game_id, {table->bob.player_id, table->alice.player_id})); ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "the deal")); while (!mirror->getStock().empty()) { const int seat = mirror->getWhoseTurn(); @@ -674,7 +671,7 @@ TEST_F(RummyGameFixture, AnEmptyStockIsRefilledFromTheDiscardPile) { mirror.emplace(*threw); ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "discard")); } - ASSERT_EQ(mirror->getDiscard().size(), 32u); + ASSERT_EQ(mirror->getDiscard().size(), 38u); const cards::Card top = mirror->getDiscard().back(); const cards::Card bottom = mirror->getDiscard().front(); const int seat = mirror->getWhoseTurn(); @@ -690,14 +687,15 @@ TEST_F(RummyGameFixture, AnEmptyStockIsRefilledFromTheDiscardPile) { ASSERT_TRUE(update.has_value()); const RummyView& view = update->as_gameState_or_null()->view; ExpectBoard(view, hearer->player_id, *mirror); - EXPECT_EQ(view.stockCount, 30); + EXPECT_EQ(view.stockCount, 36); EXPECT_EQ(view.discardCount, 1); EXPECT_EQ(Face(*view.discardTop), Face(Wire(top))); } } -// Two seats, one leaves: the engine abandons rather than plays on. The -// final view is over and the ending names no winner and scores nothing. +// Two seats, one leaves mid-deal: the deal is abandoned and the table, +// below two seats, closes. The last deal names no winner and scores +// nothing; the table's end is its standings. TEST_F(RummyGameFixture, LeavingMidGameAbandonsItWithNoWinner) { auto table = SeatedRummyTable(); ASSERT_TRUE(table.has_value()); @@ -718,12 +716,18 @@ TEST_F(RummyGameFixture, LeavingMidGameAbandonsItWithNoWinner) { EXPECT_FALSE(view.stage.has_value()); ASSERT_EQ(view.players.size(), 1u); EXPECT_EQ(view.players[0].playerId, bob.player_id); - EXPECT_EQ(view.players[0].hand.size(), 10u); + EXPECT_EQ(view.players[0].hand.size(), 7u); + ASSERT_TRUE(view.lastDeal.has_value()); + EXPECT_FALSE(view.lastDeal->winner.has_value()); + EXPECT_EQ(view.lastDeal->points, 0); + EXPECT_FALSE(view.choosing.has_value()); } auto ended = ReceiveRummy(bob.stream, "gameEnded"); ASSERT_TRUE(ended.has_value()); - EXPECT_FALSE(ended->as_gameEnded_or_null()->winner.has_value()); - EXPECT_EQ(ended->as_gameEnded_or_null()->points, 0); + EXPECT_EQ(ended->as_gameEnded_or_null()->dealsPlayed, 1); + ASSERT_EQ(ended->as_gameEnded_or_null()->standings.size(), 1u); + EXPECT_EQ(ended->as_gameEnded_or_null()->standings[0].playerId, bob.player_id); + EXPECT_EQ(ended->as_gameEnded_or_null()->standings[0].handsWon, 0); auto room = ReceiveCase(bob.stream, "roomState"); ASSERT_TRUE(room.has_value()); EXPECT_TRUE(room->as_roomState_or_null()->games.empty()); @@ -742,25 +746,27 @@ TEST_F(RummyGameFixture, ALeaveOnTurnPassesTheDrawToTheNextSeat) { for (Seat& seat : table->seats) seats.push_back(&seat); for (Seat* seat : seats) ASSERT_TRUE(ReceiveRummy(seat->stream, "gameState").has_value()); - Seat& first = table->seats[0]; - ASSERT_TRUE(first.stream.Send(DrawStock()).ok()); + // Seat 1 opens, after the dealer; leaving mid-turn hands the draw to + // seat 2. + Seat& opener = table->seats[1]; + ASSERT_TRUE(opener.stream.Send(DrawStock()).ok()); for (Seat* seat : seats) ASSERT_TRUE(ReceiveRummy(seat->stream, "gameState").has_value()); ASSERT_TRUE( - first.stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))).ok()); - for (Seat* seat : {&table->seats[1], &table->seats[2]}) { + opener.stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))).ok()); + for (Seat* seat : {&table->seats[0], &table->seats[2]}) { auto view = ReceiveRummy(seat->stream, "gameState"); ASSERT_TRUE(view.has_value()); EXPECT_EQ(view->as_gameState_or_null()->view.players.size(), 2u); EXPECT_EQ(view->as_gameState_or_null()->view.phase, "playing"); EXPECT_EQ(view->as_gameState_or_null()->view.currentPlayerId.value_or(""), - table->seats[1].player_id); + table->seats[2].player_id); EXPECT_EQ(view->as_gameState_or_null()->view.stage.value_or(""), "draw"); auto turn = ReceiveRummy(seat->stream, "turnChanged"); ASSERT_TRUE(turn.has_value()); - EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, table->seats[1].player_id); + EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, table->seats[2].player_id); } - ASSERT_TRUE(table->seats[1].stream.Send(DrawStock()).ok()); - ASSERT_TRUE(ReceiveRummy(table->seats[1].stream, "gameState").has_value()); + ASSERT_TRUE(table->seats[2].stream.Send(DrawStock()).ok()); + ASSERT_TRUE(ReceiveRummy(table->seats[2].stream, "gameState").has_value()); } // Tables of every game share a room; the room says which is which, and a @@ -840,18 +846,164 @@ TEST_F(RummyGameFixture, AResumedRummySeatGetsItsOwnViewBack) { EXPECT_EQ(view.currentPlayerId.value_or(""), table->alice.player_id); EXPECT_EQ(view.stage.value_or(""), "play"); ASSERT_EQ(view.players.size(), 2u); - EXPECT_EQ(view.players[0].hand.size(), 11u); - EXPECT_EQ(Face(view.players[0].hand.back()), "9♥"); - EXPECT_TRUE(view.players[1].hand.empty()); - EXPECT_EQ(view.players[1].handCount, 10); + EXPECT_EQ(view.players[1].hand.size(), 8u); + EXPECT_EQ(Face(view.players[1].hand.back()), "J♣"); + EXPECT_TRUE(view.players[0].hand.empty()); + EXPECT_EQ(view.players[0].handCount, 7); // The turn is still hers to finish. - ASSERT_TRUE(resumed->stream.Send(Discard(Named("9", "♥"))).ok()); + ASSERT_TRUE(resumed->stream.Send(Discard(Named("J", "♣"))).ok()); auto thrown = ReceiveRummy(table->bob.stream, "gameState"); ASSERT_TRUE(thrown.has_value()); EXPECT_EQ(thrown->as_gameState_or_null()->view.currentPlayerId.value_or(""), table->bob.player_id); } +GameCommands Choose(const std::string& variant) { + moonbase::games::RummyChooseVariant choice; + choice.variant = variant; + return Rummy(RummyMove::FromChoosevariant(choice)); +} + +// The deal passes to the seat after the dealer, and that seat alone picks +// what comes next; the seat after it opens. Standings and the room's +// stats carry across deals. +TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + for (auto* seat : {&alice, &bob}) { + ASSERT_TRUE(ReceiveRummy(seat->stream, "gameState").has_value()); + } + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + ASSERT_TRUE( + alice.stream + .Send(MeldOf({Named("J", "♥"), Named("Q", "♥"), Named("K", "♥"), Named("A", "♥")})) + .ok()); + ASSERT_TRUE( + alice.stream + .Send(MeldOf({Named("J", "♣"), Named("Q", "♣"), Named("K", "♣"), Named("A", "♣")})) + .ok()); + // The quickest win (see the first test): every chair reads to the deal's + // end, the choosing view and the room's listing. + for (auto* seat : {&alice, &bob}) { + ASSERT_TRUE(AwaitRummyView( + seat->stream, [](const RummyView& view) { return view.phase == "choosing"; }, + "the deal's end") + .has_value()); + ASSERT_TRUE(ReceiveCase(seat->stream, "roomState").has_value()); + } + + // Bob dealt the first; alice deals the second. + ASSERT_TRUE(bob.stream.Send(Choose("basic")).ok()); + auto refused = ReceiveCase(bob.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "the dealer chooses"); + // Between deals there is nothing to draw. + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + refused = ReceiveCase(alice.stream, "commandRejected"); + 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()); + for (auto* seat : {&alice, &bob}) { + auto dealt = ReceiveRummy(seat->stream, "gameState"); + ASSERT_TRUE(dealt.has_value()); + const RummyView& view = dealt->as_gameState_or_null()->view; + EXPECT_EQ(view.phase, "playing"); + EXPECT_EQ(view.dealNumber, 2); + EXPECT_EQ(view.currentPlayerId.value_or(""), bob.player_id); + EXPECT_FALSE(view.lastDeal.has_value()); + EXPECT_FALSE(view.choosing.has_value()); + EXPECT_TRUE(view.melds.empty()); + ASSERT_EQ(view.standings.size(), 2u); + EXPECT_EQ(view.standings[0].handsWon, 0); + EXPECT_EQ(view.standings[1].handsWon, 1); + for (const auto& player : view.players) EXPECT_EQ(player.handCount, 7); + auto room = ReceiveCase(seat->stream, "roomState"); + ASSERT_TRUE(room.has_value()); + EXPECT_EQ(room->as_roomState_or_null()->games[0].status, "playing"); + auto turn = ReceiveRummy(seat->stream, "turnChanged"); + ASSERT_TRUE(turn.has_value()); + EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, bob.player_id); + } + EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "chooseVariant"}}), 3); +} + +// A dealer the room shows as gone does not stall the table: any seat may +// deal in their place, and the deal still opens after the dealer's seat. +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()); + auto refused = ReceiveCase(table->seats[2].stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "the dealer chooses"); + + dealer.stream.Close(); + auto away = AwaitRoomState( + table->seats[2].stream, + [&](const moonbase::games::RoomState& state) { + for (const auto& player : state.players) { + if (player.playerId == dealer.player_id) return !player.connected; + } + return false; + }, + "the dealer shown away"); + ASSERT_TRUE(away.has_value()); + ASSERT_TRUE(table->seats[2].stream.Send(Choose("basic")).ok()); + auto dealt = AwaitRummyView( + table->seats[2].stream, [](const RummyView& view) { return view.phase == "playing"; }, + "the deal"); + ASSERT_TRUE(dealt.has_value()); + EXPECT_EQ(dealt->currentPlayerId.value_or(""), table->seats[1].player_id); + EXPECT_EQ(dealt->players.size(), 3u); +} + +// The dealer's chair follows the table: a dealer who leaves between deals +// passes it to the seat after them, and the table deals on while two +// remain; the last but one leaving closes it with its standings. +TEST_F(RummyGameFixture, ADealerLeavingBetweenDealsPassesTheChair) { + auto table = ChoosingRummyTable(3); + ASSERT_TRUE(table.has_value()); + ASSERT_TRUE(table->seats[0] + .stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))) + .ok()); + for (Seat* seat : {&table->seats[1], &table->seats[2]}) { + auto view = AwaitRummyView( + seat->stream, [](const RummyView& view) { return view.players.size() == 2; }, + "the table without its dealer"); + ASSERT_TRUE(view.has_value()); + EXPECT_EQ(view->phase, "choosing"); + ASSERT_TRUE(view->choosing.has_value()); + EXPECT_EQ(view->choosing->dealer, table->seats[1].player_id); + EXPECT_EQ(view->standings.size(), 2u); + } + + ASSERT_TRUE(table->seats[1] + .stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))) + .ok()); + auto closed = AwaitRummyView( + table->seats[2].stream, [](const RummyView& view) { return view.phase == "ended"; }, + "the table closed"); + ASSERT_TRUE(closed.has_value()); + EXPECT_FALSE(closed->choosing.has_value()); + EXPECT_FALSE(closed->lastDeal.has_value()); + auto ended = ReceiveRummy(table->seats[2].stream, "gameEnded"); + ASSERT_TRUE(ended.has_value()); + EXPECT_EQ(ended->as_gameEnded_or_null()->dealsPlayed, 0); + ASSERT_EQ(ended->as_gameEnded_or_null()->standings.size(), 1u); + EXPECT_EQ(ended->as_gameEnded_or_null()->standings[0].playerId, table->seats[2].player_id); + // Nothing was dealt, so nothing was played: the room's stats stand. + auto room = AwaitRoomState( + table->seats[2].stream, + [](const moonbase::games::RoomState& state) { return state.games.empty(); }, + "the table gone from the room"); + ASSERT_TRUE(room.has_value()); + for (const auto& player : room->players) EXPECT_EQ(player.gamesPlayed, 0); +} + // Before the deal a table is a roster: no cards anywhere, no turn. TEST_F(RummyGameFixture, AWaitingTableIsARosterWithNoCards) { auto room = SeatedRoom(1); diff --git a/domains/games/apis/games_hub/rummy_wire_test.cc b/domains/games/apis/games_hub/rummy_wire_test.cc index 77ecb801..19fe2d1f 100644 --- a/domains/games/apis/games_hub/rummy_wire_test.cc +++ b/domains/games/apis/games_hub/rummy_wire_test.cc @@ -2,14 +2,16 @@ // castle_wire_test pins castle's: raw eventstream frames and exact payload // bytes, because the typed-client suites regenerate both sides together // and cannot see a rename. The pinned surface: the rummy command envelope -// ({"move":{...}} inside the `rummy` command) with every in-game move's -// spelling, the update envelope ({"update":{...}} inside the `rummy` -// event), the dealt RummyView's full key set from each chair, the view -// mid-turn (stage, takenDiscard, melds, lastMove), and gameEnded. +// ({"move":{...}} inside the `rummy` command) with every move's spelling, +// the update envelope ({"update":{...}} inside the `rummy` event), the +// dealer's-choice table (#1609) between deals and dealt, the dealt +// RummyView's full key set from each chair, the view mid-turn (stage, +// takenDiscard, melds, lastMove), a deal's end, and the table's. // // The NoShuffleDealer deals one card a seat from the back of the pristine -// deck: player-1 holds A♠ A♦ K♠ K♦ Q♠ Q♦ J♠ J♦ 10♠ 10♦, player-2 the -// hearts and clubs, 9♠ is turned up and 9♥ tops the stock. +// deck: player-1 (the creator, who deals first) holds A♠ A♦ K♠ K♦ Q♠ Q♦ +// J♠, player-2 (who opens) A♥ A♣ K♥ K♣ Q♥ Q♣ J♥; J♦ is turned up and J♣ +// tops the stock. #include @@ -34,10 +36,10 @@ class RummyWireTest : public HubWireFixture { return HubWireFixture::DialReady(kPlayPath, session); } - // Two seats at a dealt rummy table; every frame up to the dealt views - // read, the creator's dealt view returned. - std::string DealtTable(std::shared_ptr& creator, - std::shared_ptr& joiner) { + // Two seats at a started table, between deals: every frame up to both + // chairs' choosing views and the room's listing of them read. + void ChoosingTable(std::shared_ptr& creator, + std::shared_ptr& joiner) { json creator_session; creator = DialReady(creator_session); EXPECT_TRUE(creator->Send(CommandFrame("createRoom", "{}")).ok()); @@ -54,17 +56,42 @@ class RummyWireTest : public HubWireFixture { (void)EventPayload(NextFrame(*creator), "roomState"); EXPECT_TRUE( joiner->Send(CommandFrame("rummy", R"({"move":{"joinGame":{"gameId":"GAME01"}}})")).ok()); - (void)EventPayload(NextFrame(*joiner), "rummy"); // gameJoined - (void)EventPayload(NextFrame(*creator), "rummy"); // gameState + (void)EventPayload(NextFrame(*joiner), "rummy"); // gameJoined (void)EventPayload(NextFrame(*joiner), "roomState"); + (void)EventPayload(NextFrame(*creator), "rummy"); // gameState (void)EventPayload(NextFrame(*creator), "roomState"); EXPECT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"startGame":{}}})")).ok()); - EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), R"({"update":{"gameStarted":{}}})"); + for (auto* socket : {creator.get(), joiner.get()}) { + EXPECT_EQ(EventPayload(NextFrame(*socket), "rummy"), R"({"update":{"gameStarted":{}}})"); + (void)EventPayload(NextFrame(*socket), "rummy"); // the choosing view + (void)EventPayload(NextFrame(*socket), "roomState"); + } + } + + // The same table with basic 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"}}})")) + .ok()); const std::string dealt = EventPayload(NextFrame(*creator), "rummy"); (void)EventPayload(NextFrame(*creator), "roomState"); - (void)EventPayload(NextFrame(*joiner), "rummy"); // gameStarted + EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), + R"({"update":{"turnChanged":{"playerId":"player-2"}}})"); return dealt; } + + // DealtTable, with the joiner's dealt frames read too. + void DealtAndRead(std::shared_ptr& creator, + std::shared_ptr& joiner) { + (void)DealtTable(creator, joiner); + (void)EventPayload(NextFrame(*joiner), "rummy"); // the dealt view + (void)EventPayload(NextFrame(*joiner), "roomState"); + (void)EventPayload(NextFrame(*joiner), "rummy"); // turnChanged + } }; TEST_F(RummyWireTest, CreateAndJoinPinTheWaitingViewAndTheLobbysWord) { @@ -77,9 +104,10 @@ 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,"discardCount":0,)" - R"("gameId":"GAME01","melds":[],"phase":"waiting","players":[{"hand":[],)" - R"("handCount":0,"playerId":"player-1"}],"stockCount":0}}}})"); + R"({"update":{"gameJoined":{"view":{"canDrawStock":false,"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}}}})"); EXPECT_EQ(EventPayload(NextFrame(*creator), "roomState"), R"({"games":[{"game":"rummy","gameId":"GAME01","playerCount":1,"status":"waiting"}],)" R"("geometry":{"plane":{}},"players":[{"connected":true,"gamesPlayed":0,"gamesWon":0,)" @@ -87,152 +115,223 @@ TEST_F(RummyWireTest, CreateAndJoinPinTheWaitingViewAndTheLobbysWord) { R"("totalScore":0}],"roomId":"room-1"})"); } +// Started, the table is between deals: the dealer and the variants on +// offer, no cards, and the room lists it as choosing. Only the dealer +// deals, and only a variant the table offers. +TEST_F(RummyWireTest, AStartedTableWaitsOnTheDealersChoice) { + json creator_session; + auto creator = DialReady(creator_session); + // ChoosingTable reads the choosing frames; this test pins them, so it + // walks the same way and stops short. + ASSERT_TRUE(creator->Send(CommandFrame("createRoom", "{}")).ok()); + (void)EventPayload(NextFrame(*creator), "roomState"); + ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"createGame":{}}})")).ok()); + for (int i = 0; i < 3; ++i) (void)NextFrame(*creator); + json joiner_session; + auto joiner = DialReady(joiner_session); + ASSERT_TRUE(joiner->Send(CommandFrame("joinRoom", R"({"roomId":"room-1"})")).ok()); + for (int i = 0; i < 2; ++i) (void)NextFrame(*joiner); + (void)NextFrame(*creator); + ASSERT_TRUE( + joiner->Send(CommandFrame("rummy", R"({"move":{"joinGame":{"gameId":"GAME01"}}})")).ok()); + for (int i = 0; i < 2; ++i) (void)NextFrame(*joiner); + for (int i = 0; i < 2; ++i) (void)NextFrame(*creator); + + 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"("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":)" + R"("player-2"}],"stockCount":0}}}})"); + EXPECT_EQ(EventPayload(NextFrame(*creator), "roomState"), + R"({"games":[{"game":"rummy","gameId":"GAME01","playerCount":2,"status":"choosing"}],)" + R"("geometry":{"plane":{}},"players":[{"connected":true,"gamesPlayed":0,"gamesWon":0,)" + R"("playerId":"player-1","table":{"game":"rummy","gameId":"GAME01"},"totalScore":0},)" + R"({"connected":true,"gamesPlayed":0,"gamesWon":0,"playerId":"player-2","table":)" + R"({"game":"rummy","gameId":"GAME01"},"totalScore":0}],"roomId":"room-1"})"); + for (int i = 0; i < 3; ++i) (void)NextFrame(*joiner); + + ASSERT_TRUE( + joiner->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"basic"}}})")) + .ok()); + EXPECT_EQ(EventPayload(NextFrame(*joiner), "commandRejected"), + R"({"reason":"the dealer chooses"})"); + ASSERT_TRUE( + creator->Send(CommandFrame("rummy", R"({"move":{"chooseVariant":{"variant":"gin"}}})")).ok()); + EXPECT_EQ(EventPayload(NextFrame(*creator), "commandRejected"), + R"({"reason":"no such game: gin"})"); +} + TEST_F(RummyWireTest, TheDealtViewFromEachChair) { std::shared_ptr creator; std::shared_ptr joiner; const std::string dealt = DealtTable(creator, joiner); EXPECT_EQ( dealt, - R"({"update":{"gameState":{"view":{"canDrawStock":true,"currentPlayerId":"player-1",)" - R"("discardCount":1,"discardTop":{"rank":"9","suit":"♠"},"gameId":"GAME01","melds":[],)" - R"("phase":"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":"♠"},{"rank":"J","suit":"♦"},)" - R"({"rank":"10","suit":"♠"},{"rank":"10","suit":"♦"}],"handCount":10,)" - R"("playerId":"player-1"},{"hand":[],"handCount":10,"playerId":"player-2"}],)" - R"("stage":"draw","stockCount":31}}}})"); + R"({"update":{"gameState":{"view":{"canDrawStock":true,"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"}}}})"); const json view = json::parse(dealt)["update"]["gameState"]["view"]; EXPECT_EQ(KeysOf(view), - (std::set{"canDrawStock", "currentPlayerId", "discardCount", "discardTop", - "gameId", "melds", "phase", "players", "stage", "stockCount"})); + (std::set{"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 (takenDiscard, lastMove) are omitted keys, not nulls. - EXPECT_FALSE(view.contains("takenDiscard")); - EXPECT_FALSE(view.contains("lastMove")); + // Absent optionals are omitted keys, not nulls: nothing taken, no move + // yet, no deal before this one, nobody choosing. + for (const char* absent : {"takenDiscard", "lastMove", "lastDeal", "choosing"}) { + EXPECT_FALSE(view.contains(absent)) << absent; + } - // The other chair: its own faces, the creator's as a count. + // The other chair: its own faces, the dealer's as a count. const json theirs = json::parse(EventPayload(NextFrame(*joiner), "rummy"))["update"]["gameState"]["view"]; EXPECT_EQ(theirs["players"][0]["hand"], json::array()); - EXPECT_EQ(theirs["players"][0]["handCount"], 10); + EXPECT_EQ(theirs["players"][0]["handCount"], 7); EXPECT_EQ(theirs["players"][1]["hand"][0], (json{{"rank", "A"}, {"suit", "♥"}})); + // The room lists the table in play. + EXPECT_EQ(json::parse(EventPayload(NextFrame(*joiner), "roomState"))["games"][0]["status"], + "playing"); } // Every in-game move's spelling, and what the table sees of each, down to -// the quickest win the deck allows and its ending. -TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheEndingBytes) { +// the quickest discard-draw win the deck allows and the deal's end. +TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheDealsEndBytes) { std::shared_ptr creator; std::shared_ptr joiner; - (void)DealtTable(creator, joiner); - (void)EventPayload(NextFrame(*joiner), "rummy"); // the joiner's dealt view - (void)EventPayload(NextFrame(*joiner), "roomState"); + DealtAndRead(creator, joiner); - ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"drawDiscard":{}}})")).ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); - EXPECT_EQ(EventPayload(NextFrame(*joiner), "rummy"), - R"({"update":{"gameState":{"view":{"canDrawStock":true,"currentPlayerId":"player-1",)" - R"("discardCount":0,"gameId":"GAME01","lastMove":{"cards":[{"rank":"9","suit":"♠"}],)" - R"("move":"drawDiscard","playerId":"player-1"},"melds":[],"phase":"playing",)" - R"("players":[{"hand":[],"handCount":11,"playerId":"player-1"},)" - R"({"hand":[{"rank":"A","suit":"♥"},{"rank":"A","suit":"♣"},{"rank":"K","suit":"♥"},)" - R"({"rank":"K","suit":"♣"},{"rank":"Q","suit":"♥"},{"rank":"Q","suit":"♣"},)" - R"({"rank":"J","suit":"♥"},{"rank":"J","suit":"♣"},{"rank":"10","suit":"♥"},)" - R"({"rank":"10","suit":"♣"}],"handCount":10,"playerId":"player-2"}],)" - R"("stage":"play","stockCount":31,"takenDiscard":{"rank":"9","suit":"♠"}}}}})"); + 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"}}}})"); ASSERT_TRUE( - creator + joiner ->Send(CommandFrame( "rummy", - R"({"move":{"meld":{"cards":[{"rank":"K","suit":"♠"},{"rank":"Q","suit":"♠"},)" - R"({"rank":"J","suit":"♠"}]}}})")) + R"({"move":{"meld":{"cards":[{"rank":"K","suit":"♥"},{"rank":"Q","suit":"♥"},)" + R"({"rank":"J","suit":"♥"}]}}})")) .ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); - const json melded = json::parse(EventPayload(NextFrame(*joiner), "rummy")); + (void)EventPayload(NextFrame(*joiner), "rummy"); + const json melded = json::parse(EventPayload(NextFrame(*creator), "rummy")); EXPECT_EQ(melded["update"]["gameState"]["view"]["melds"].dump(), - R"([{"cards":[{"rank":"J","suit":"♠"},{"rank":"Q","suit":"♠"},)" - R"({"rank":"K","suit":"♠"}],"owner":"player-1"}])"); + R"([{"cards":[{"rank":"J","suit":"♥"},{"rank":"Q","suit":"♥"},)" + R"({"rank":"K","suit":"♥"}],"owner":"player-2"}])"); EXPECT_EQ(melded["update"]["gameState"]["view"]["lastMove"].dump(), - R"({"cards":[{"rank":"J","suit":"♠"},{"rank":"Q","suit":"♠"},{"rank":"K","suit":"♠"}],)" - R"("meldIndex":0,"move":"meld","playerId":"player-1"})"); - - for (const char* card : {R"({"rank":"A","suit":"♠"})", R"({"rank":"10","suit":"♠"})", - R"({"rank":"9","suit":"♠"})"}) { - ASSERT_TRUE(creator - ->Send(CommandFrame("rummy", std::string(R"({"move":{"layOff":{"card":)") + - card + R"(,"meldIndex":0}}})")) - .ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); - const json laid = json::parse(EventPayload(NextFrame(*joiner), "rummy")); - EXPECT_EQ(laid["update"]["gameState"]["view"]["lastMove"].dump(), - std::string(R"({"cards":[)") + card + - R"(],"meldIndex":0,"move":"layOff","playerId":"player-1"})"); - } + R"({"cards":[{"rank":"J","suit":"♥"},{"rank":"Q","suit":"♥"},{"rank":"K","suit":"♥"}],)" + R"("meldIndex":0,"move":"meld","playerId":"player-2"})"); - // The diamonds go down as one run and her hand is empty: the final views - // show every hand, then the ending. ASSERT_TRUE( - creator + joiner + ->Send(CommandFrame( + "rummy", R"({"move":{"layOff":{"card":{"rank":"A","suit":"♥"},"meldIndex":0}}})")) + .ok()); + (void)EventPayload(NextFrame(*joiner), "rummy"); + EXPECT_EQ(json::parse(EventPayload(NextFrame(*creator), + "rummy"))["update"]["gameState"]["view"]["lastMove"] + .dump(), + R"({"cards":[{"rank":"A","suit":"♥"}],"meldIndex":0,"move":"layOff","playerId":)" + R"("player-2"})"); + + ASSERT_TRUE( + joiner ->Send(CommandFrame( "rummy", - R"({"move":{"meld":{"cards":[{"rank":"A","suit":"♦"},{"rank":"K","suit":"♦"},)" - R"({"rank":"Q","suit":"♦"},{"rank":"J","suit":"♦"},{"rank":"10","suit":"♦"}]}}})")) + R"({"move":{"meld":{"cards":[{"rank":"A","suit":"♣"},{"rank":"K","suit":"♣"},)" + R"({"rank":"Q","suit":"♣"}]}}})")) + .ok()); + (void)EventPayload(NextFrame(*joiner), "rummy"); + (void)EventPayload(NextFrame(*creator), "rummy"); + + // J♦ is all she holds: the card she took may go back as her last, and + // the deal is hers. The table turns to the next deal, player-2 dealing; + // the deal's result rides the view, and nothing ends. + ASSERT_TRUE( + joiner + ->Send(CommandFrame("rummy", R"({"move":{"discard":{"card":{"rank":"J","suit":"♦"}}}})")) .ok()); - const json final_view = + const json between = json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; - EXPECT_EQ(final_view["phase"], "ended"); - EXPECT_FALSE(final_view.contains("currentPlayerId")); - EXPECT_FALSE(final_view.contains("stage")); - EXPECT_FALSE(final_view.contains("takenDiscard")); - EXPECT_EQ(final_view["canDrawStock"], false); - EXPECT_EQ(final_view["players"][1]["hand"].size(), 10u); - EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"gameEnded":{"points":82,"scores":[{"deadwood":0,"playerId":"player-1"},)" - R"({"deadwood":82,"playerId":"player-2"}],"winner":"player-1"}}})"); + EXPECT_EQ(between["phase"], "choosing"); + EXPECT_EQ(between["choosing"].dump(), R"({"dealer":"player-2","options":["basic"]})"); + 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"})"); + 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["dealNumber"], 1); + EXPECT_EQ(between["players"][0]["hand"].size(), 7u); + const json room = json::parse(EventPayload(NextFrame(*creator), "roomState")); + EXPECT_EQ(room["games"][0]["status"], "choosing"); + EXPECT_EQ(room["players"][1]["gamesWon"], 1); } -// A table that broke up names no winner: the key is omitted, never null, -// which is what the UI reads as nobody having gone out. -TEST_F(RummyWireTest, AnAbandonedTableEndsWithNoWinnerKey) { +// Below two seats the table closes: the last deal, broken up, names no +// winner — the key is omitted, never null, which is what the UI reads as +// nobody having gone out — and gameEnded carries the table's standings. +TEST_F(RummyWireTest, ALeaveMidDealClosesTheTableWithItsStandings) { std::shared_ptr creator; std::shared_ptr joiner; - (void)DealtTable(creator, joiner); - (void)EventPayload(NextFrame(*joiner), "rummy"); - (void)EventPayload(NextFrame(*joiner), "roomState"); + DealtAndRead(creator, joiner); ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"leaveGame":{}}})")).ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); // the final view - EXPECT_EQ( - EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"gameEnded":{"points":0,"scores":[{"deadwood":82,"playerId":"player-1"}]}}})"); + const json closed = + json::parse(EventPayload(NextFrame(*creator), "rummy"))["update"]["gameState"]["view"]; + 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"})"); + EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), + R"({"update":{"gameEnded":{"dealsPlayed":1,"standings":[{"handsWon":0,"playerId":)" + R"("player-1"}]}}})"); } TEST_F(RummyWireTest, StockDrawAndDiscardSpellings) { std::shared_ptr creator; std::shared_ptr joiner; - (void)DealtTable(creator, joiner); - (void)EventPayload(NextFrame(*joiner), "rummy"); - (void)EventPayload(NextFrame(*joiner), "roomState"); + DealtAndRead(creator, joiner); - ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"drawStock":{}}})")).ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); - const json drew = json::parse(EventPayload(NextFrame(*joiner), "rummy")); + ASSERT_TRUE(joiner->Send(CommandFrame("rummy", R"({"move":{"drawStock":{}}})")).ok()); + (void)EventPayload(NextFrame(*joiner), "rummy"); + const json drew = json::parse(EventPayload(NextFrame(*creator), "rummy")); // Nobody else sees a stock card: the move names none and no meld. EXPECT_EQ(drew["update"]["gameState"]["view"]["lastMove"].dump(), - R"({"cards":[],"move":"drawStock","playerId":"player-1"})"); + R"({"cards":[],"move":"drawStock","playerId":"player-2"})"); ASSERT_TRUE( - creator - ->Send(CommandFrame("rummy", R"({"move":{"discard":{"card":{"rank":"9","suit":"♥"}}}})")) + joiner + ->Send(CommandFrame("rummy", R"({"move":{"discard":{"card":{"rank":"J","suit":"♣"}}}})")) .ok()); - (void)EventPayload(NextFrame(*creator), "rummy"); - const json threw = json::parse(EventPayload(NextFrame(*joiner), "rummy")); + (void)EventPayload(NextFrame(*joiner), "rummy"); + const json threw = json::parse(EventPayload(NextFrame(*creator), "rummy")); EXPECT_EQ(threw["update"]["gameState"]["view"]["lastMove"].dump(), - R"({"cards":[{"rank":"9","suit":"♥"}],"move":"discard","playerId":"player-1"})"); + R"({"cards":[{"rank":"J","suit":"♣"}],"move":"discard","playerId":"player-2"})"); EXPECT_EQ(threw["update"]["gameState"]["view"]["discardTop"].dump(), - R"({"rank":"9","suit":"♥"})"); + R"({"rank":"J","suit":"♣"})"); EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), - R"({"update":{"turnChanged":{"playerId":"player-2"}}})"); + R"({"update":{"turnChanged":{"playerId":"player-1"}}})"); } } // namespace diff --git a/domains/games/apis/games_hub/stream_test_fixture.h b/domains/games/apis/games_hub/stream_test_fixture.h index de265ad2..d0b6710b 100644 --- a/domains/games/apis/games_hub/stream_test_fixture.h +++ b/domains/games/apis/games_hub/stream_test_fixture.h @@ -733,8 +733,8 @@ class GamesHubStreamFixture : public testing::Test { std::optional
SeatedCastleTable() { return AliceAndBob(MultiSeatCastleTable(2)); } // Rummy's alice is the seat that opens the deal — seat 1, after the // dealer — and bob deals. At the pristine deck alice holds A♥ A♣ K♥ K♣ - // Q♥ Q♣ J♥ J♣ 10♥ 10♣, bob the spades and diamonds of the same ranks, - // 9♠ is turned up, and 9♥ tops the stock. + // Q♥ Q♣ J♥, bob A♠ A♦ K♠ K♦ Q♠ Q♦ J♠; J♦ is turned up, and J♣ tops the + // stock with the tens under it. std::optional
SeatedRummyTable() { auto table = MultiSeatRummyTable(2); if (!table.has_value()) return std::nullopt; diff --git a/domains/games/libs/cards/rummy/README.md b/domains/games/libs/cards/rummy/README.md index 912487f2..f46f71a6 100644 --- a/domains/games/libs/cards/rummy/README.md +++ b/domains/games/libs/cards/rummy/README.md @@ -4,9 +4,8 @@ The rules engine for basic Rummy (#245): an immutable `GameState` value type in the style of [`../castle`](../castle), for the games hub. The rules the engine plays are the contract, stated on `GameState` in -`game_state.h` and pinned one by one in `game_state_test.cc`: ten cards a -seat at two, seven at three or four; 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 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 diff --git a/domains/games/libs/cards/rummy/game_state.cc b/domains/games/libs/cards/rummy/game_state.cc index 82ce9705..b6cf1746 100644 --- a/domains/games/libs/cards/rummy/game_state.cc +++ b/domains/games/libs/cards/rummy/game_state.cc @@ -47,14 +47,13 @@ StatusOr dealRummyGame(const string& game_id, const vector& p return InvalidArgumentError("2 to 4 players"); } if (opener < 0 || opener >= seats) return InvalidArgumentError("no such opener"); - const int handSize = GameState::handSizeFor(seats); - if (static_cast(shuffled_deck.size()) < seats * handSize + 1) { + if (static_cast(shuffled_deck.size()) < seats * GameState::kHandSize + 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 < handSize; round++) { + for (int round = 0; round < GameState::kHandSize; 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 0f96a88f..5e497f98 100644 --- a/domains/games/libs/cards/rummy/game_state.h +++ b/domains/games/libs/cards/rummy/game_state.h @@ -18,10 +18,9 @@ using std::string; /// by laying them down in melds. /// /// The rules this engine plays: -/// - 2-4 players, one deck. Two seats are dealt ten cards each, three -/// or four seats seven. One card is turned up to start the discard -/// pile; the rest is the stock. The opener the table names moves -/// first (TableState: the seat after the dealer). +/// - 2-4 players, one deck, seven cards a seat. One card is turned up +/// to start the discard pile; the rest is the stock. The opener the +/// table names moves first (TableState: the seat after the dealer). /// - A turn opens with a draw: the top of the stock, or the top of the /// discard pile. A stock that has run out is refilled by turning the /// discard pile over, all but its top card; with nothing under the top @@ -99,7 +98,7 @@ class GameState { static constexpr int kNoTurn = -1; /// The hand each seat is dealt at a table of this size. - [[nodiscard]] static int handSizeFor(int seats) { return seats == 2 ? 10 : 7; } + static constexpr int kHandSize = 7; GameState(std::deque _stock, std::vector _discard, std::vector _players, std::vector _melds, int _whoseTurn, Stage _stage, Phase _phase, diff --git a/domains/games/libs/cards/rummy/game_state_serde_test.cc b/domains/games/libs/cards/rummy/game_state_serde_test.cc index d3eaef8f..b82a1875 100644 --- a/domains/games/libs/cards/rummy/game_state_serde_test.cc +++ b/domains/games/libs/cards/rummy/game_state_serde_test.cc @@ -38,7 +38,7 @@ json dealtPayload() { return json::parse(serializeGameState(dealt())); } // Mid-turn, with a meld down and the discard's top in hand: every // optional field present. GameState midTurn() { - auto drew = dealt().drawDiscard(0); // 9♠ onto A♠ ... 10♠ + auto drew = dealt().drawDiscard(0); // J♦ onto A♦ K♦ Q♦ EXPECT_TRUE(drew.ok()) << drew.status(); auto melded = drew->meld(0, {Card{Suit::Spades, Rank::Ace}, Card{Suit::Spades, Rank::King}, Card{Suit::Spades, Rank::Queen}}); @@ -102,17 +102,12 @@ TEST(RummySerde, EveryMoveAndPhaseRoundTrips) { ASSERT_EQ(abandoned->getPhase(), Phase::Abandoned); expectRoundTrips(*abandoned); - // Alice's deal is two runs short of the discard's 9♠: taking it, she - // lays her whole hand down and wins. - auto run = laid->meld(0, {Card{Suit::Spades, Rank::Ten}, Card{Suit::Spades, Rank::Nine}}); - EXPECT_FALSE(run.ok()); - auto ten = laid->layOff(0, Card{Suit::Spades, Rank::Ten}, 0); - ASSERT_TRUE(ten.ok()) << ten.status(); - auto nine = ten->layOff(0, Card{Suit::Spades, Rank::Nine}, 0); - ASSERT_TRUE(nine.ok()) << nine.status(); - auto over = nine->meld(0, {Card{Suit::Diamonds, Rank::Ace}, Card{Suit::Diamonds, Rank::King}, - Card{Suit::Diamonds, Rank::Queen}, Card{Suit::Diamonds, Rank::Jack}, - Card{Suit::Diamonds, Rank::Ten}}); + // Alice's deal is the turned-up J♦ short of two runs: with it, a + // lay-off and one more meld empty her hand. + auto pair = laid->meld(0, {Card{Suit::Diamonds, Rank::Ace}, Card{Suit::Diamonds, Rank::King}}); + EXPECT_FALSE(pair.ok()); + auto over = laid->meld(0, {Card{Suit::Diamonds, Rank::Ace}, Card{Suit::Diamonds, Rank::King}, + Card{Suit::Diamonds, Rank::Queen}, Card{Suit::Diamonds, Rank::Jack}}); ASSERT_TRUE(over.ok()) << over.status(); ASSERT_EQ(over->getPhase(), Phase::Over); expectRoundTrips(*over); @@ -122,16 +117,31 @@ TEST(RummySerde, EveryMoveAndPhaseRoundTrips) { // here is a schema change and means a version bump, not an edit to this // literal. TEST(RummySerde, FrozenPayload) { + constexpr const char* kRow = + R"({"discard":[37],"melds":[],"phase":"playing",)" + R"("players":[{"hand":[51,49,47,45,43,41,39],"id":"a"},)" + R"({"hand":[50,48,46,44,42,40,38],"id":"b"}],"stage":"draw",)" + 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,29,30,)" + R"(31,32,33,34,35,36],"v":1,"whoseTurn":0})"; + EXPECT_EQ(serializeGameState(dealt()), kRow); + const auto restored = deserializeGameState(kRow); + ASSERT_TRUE(restored.ok()) << restored.status(); + expectStatesEqual(dealt(), *restored); +} + +// A deal stored when two seats were dealt ten still loads and plays: hand +// sizes are the deal's, not the schema's. +TEST(RummySerde, ARowDealtTenASeatStillLoads) { constexpr const char* kRow = R"({"discard":[31],"melds":[],"phase":"playing",)" R"("players":[{"hand":[51,49,47,45,43,41,39,37,35,33],"id":"a"},)" R"({"hand":[50,48,46,44,42,40,38,36,34,32],"id":"b"}],"stage":"draw",)" 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,29,30],)" R"("v":1,"whoseTurn":0})"; - EXPECT_EQ(serializeGameState(dealt()), kRow); const auto restored = deserializeGameState(kRow); ASSERT_TRUE(restored.ok()) << restored.status(); - expectStatesEqual(dealt(), *restored); + EXPECT_EQ(restored->getPlayer(0).hand.size(), 10u); + EXPECT_TRUE(restored->drawStock(0).ok()); } // The bytes of a row mid-turn: the optional fields and the other stage's @@ -140,10 +150,10 @@ TEST(RummySerde, FrozenMidTurnPayload) { constexpr const char* kRow = R"({"discard":[],"lastMove":{"cards":[43,47,51],"kind":"meld","meld":0,"player":"a"},)" R"("melds":[{"cards":[43,47,51],"owner":"a"}],"phase":"playing",)" - R"("players":[{"hand":[49,45,41,39,37,35,33,31],"id":"a"},)" - R"({"hand":[50,48,46,44,42,40,38,36,34,32],"id":"b"}],"stage":"play",)" - 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,29,30],)" - R"("takenDiscard":31,"v":1,"whoseTurn":0})"; + R"("players":[{"hand":[49,45,41,39,37],"id":"a"},)" + R"({"hand":[50,48,46,44,42,40,38],"id":"b"}],"stage":"play",)" + 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,29,30,)" + R"(31,32,33,34,35,36],"takenDiscard":37,"v":1,"whoseTurn":0})"; EXPECT_EQ(serializeGameState(midTurn()), kRow); for (const auto& [kind, want] : std::vector>{{"drawStock", MoveKind::DrawStock}, @@ -152,7 +162,7 @@ TEST(RummySerde, FrozenMidTurnPayload) { {"discard", MoveKind::Discard}}) { json payload = json::parse(kRow); payload["lastMove"]["kind"] = kind; - payload["lastMove"]["cards"] = want == MoveKind::DrawStock ? json::array() : json::array({31}); + payload["lastMove"]["cards"] = want == MoveKind::DrawStock ? json::array() : json::array({37}); payload["lastMove"]["meld"] = want == MoveKind::LayOff ? 0 : -1; const auto restored = deserializeGameState(payload.dump()); ASSERT_TRUE(restored.ok()) << kind; diff --git a/domains/games/libs/cards/rummy/game_state_test.cc b/domains/games/libs/cards/rummy/game_state_test.cc index 89b47019..b5f46b0f 100644 --- a/domains/games/libs/cards/rummy/game_state_test.cc +++ b/domains/games/libs/cards/rummy/game_state_test.cc @@ -53,7 +53,7 @@ GameState alicePlaying() { // --- The deal --- -TEST(Deal, TwoSeatsGetTenOneCardIsTurnedUpAndTheRestIsStock) { +TEST(Deal, TwoSeatsGetSevenOneCardIsTurnedUpAndTheRestIsStock) { NoShuffleDealer dealer; auto game = dealRummyGame("g1", {"a", "b"}, dealer.DealNewUnshuffledDeck()); ASSERT_TRUE(game.ok()) << game.status(); @@ -62,10 +62,10 @@ TEST(Deal, TwoSeatsGetTenOneCardIsTurnedUpAndTheRestIsStock) { EXPECT_EQ(game->getWhoseTurn(), 0); EXPECT_EQ(game->getGameId(), "g1"); ASSERT_EQ(game->getPlayers().size(), 2u); - EXPECT_EQ(game->getPlayer(0).hand.size(), 10u); - EXPECT_EQ(game->getPlayer(1).hand.size(), 10u); + EXPECT_EQ(game->getPlayer(0).hand.size(), 7u); + EXPECT_EQ(game->getPlayer(1).hand.size(), 7u); EXPECT_EQ(game->getDiscard().size(), 1u); - EXPECT_EQ(game->getStock().size(), 52u - 20u - 1u); + EXPECT_EQ(game->getStock().size(), 52u - 14u - 1u); EXPECT_TRUE(game->getMelds().empty()); EXPECT_FALSE(game->getLastMove().has_value()); EXPECT_FALSE(game->getTakenDiscard().has_value()); @@ -83,9 +83,10 @@ TEST(Deal, DealsOneCardASeatAroundTheTableFromTheBack) { EXPECT_EQ(b.at(0), c(Rank::Ace, Suit::Hearts)); EXPECT_EQ(a.at(1), c(Rank::Ace, Suit::Diamonds)); EXPECT_EQ(b.at(1), c(Rank::Ace, Suit::Clubs)); - EXPECT_EQ(a.at(9), c(Rank::Ten, Suit::Diamonds)); - EXPECT_EQ(game->getDiscard().back(), c(Rank::Nine, Suit::Spades)); - EXPECT_EQ(game->getStock().back(), c(Rank::Nine, Suit::Hearts)); + EXPECT_EQ(a.at(6), c(Rank::Jack, Suit::Spades)); + EXPECT_EQ(b.at(6), c(Rank::Jack, Suit::Hearts)); + EXPECT_EQ(game->getDiscard().back(), c(Rank::Jack, Suit::Diamonds)); + EXPECT_EQ(game->getStock().back(), c(Rank::Jack, Suit::Clubs)); } TEST(Deal, ThreeOrFourSeatsGetSeven) { @@ -108,9 +109,9 @@ TEST(Deal, TakesTwoToFourSeats) { TEST(Deal, RefusesADeckTooSmallForTheHandsAndTheTurnedCard) { deque deck; - for (int i = 0; i < 20; i++) deck.emplace_back(i); + for (int i = 0; i < 14; i++) deck.emplace_back(i); EXPECT_FALSE(dealRummyGame("g", {"a", "b"}, deck).ok()); - deck.emplace_back(20); + deck.emplace_back(14); EXPECT_TRUE(dealRummyGame("g", {"a", "b"}, deck).ok()); } From 7cdf755265bc2b592d97afe533b50717ce6875e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:16:35 +0000 Subject: [PATCH 3/4] rummy: review fixes: dealer leaving mid-deal, presence keeps stats --- domains/games/apis/games_hub/golf_hub.cc | 2 +- domains/games/apis/games_hub/golf_hub.h | 6 +- domains/games/apis/games_hub/hosted_game.h | 8 +-- domains/games/apis/games_hub/hub_e2e_test.cc | 46 +++++++++++++++ domains/games/apis/games_hub/pg_hub_store.cc | 8 ++- .../games/apis/games_hub/pg_hub_store_test.cc | 32 ++++++++++- .../games/apis/games_hub/rummy_e2e_test.cc | 2 + domains/games/libs/cards/rummy/game_state.h | 2 +- domains/games/libs/cards/rummy/table.cc | 18 +++++- domains/games/libs/cards/rummy/table.h | 5 +- domains/games/libs/cards/rummy/table_test.cc | 57 +++++++++++++++++++ 11 files changed, 166 insertions(+), 20 deletions(-) diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index 5fba247d..fdd5f5dd 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -3150,7 +3150,7 @@ moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, 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() && deal->isOver(); if (deal.has_value()) { if (!deal->isOver()) { view.currentPlayerId = CurrentTurnOf(*entry.state); diff --git a/domains/games/apis/games_hub/golf_hub.h b/domains/games/apis/games_hub/golf_hub.h index 7cf0fdd3..a2fe5ecd 100644 --- a/domains/games/apis/games_hub/golf_hub.h +++ b/domains/games/apis/games_hub/golf_hub.h @@ -643,12 +643,12 @@ class GolfHub final { const absl::Time now = absl::Now(); event_writer_(now, build(now)); } - /// The local finisher: mirrors the finish commit's stat deltas into the - /// local member rows (after a kUnavailable leave, with no known commit) - /// and runs the ceremony. /// Adds a finish's stat deltas to the room's members; the same numbers /// rode the finish commit. void MirrorStatsLocked(Room& room, const std::vector& deltas); + /// The local finisher: mirrors the finish commit's stat deltas into the + /// local member rows (after a kUnavailable leave, with no known commit) + /// and runs the ceremony. void FinalizeGameLocked(const std::string& room_id, Room& room, const std::string& game_id, Outbox& outbox); diff --git a/domains/games/apis/games_hub/hosted_game.h b/domains/games/apis/games_hub/hosted_game.h index d860cbe8..46254449 100644 --- a/domains/games/apis/games_hub/hosted_game.h +++ b/domains/games/apis/games_hub/hosted_game.h @@ -52,14 +52,10 @@ inline bool IsOver(const HostedState& state) { } /// The word a game is recorded under (#1571): the kind's, except that a -/// rummy table's games are its deals, each under its own variant's word — -/// basic rummy keeps "rummy". +/// rummy table's games are its deals, each under its variant's word. inline std::string_view VariantWordOf(const HostedState& state) { if (const auto* table = std::get_if(&state)) { - switch (table->getVariant()) { - case rummy::Variant::Basic: - return "rummy"; - } + return rummy::recordedName(table->getVariant()); } return GameKindName(KindOf(state)); } diff --git a/domains/games/apis/games_hub/hub_e2e_test.cc b/domains/games/apis/games_hub/hub_e2e_test.cc index 89a57c7d..9c21d608 100644 --- a/domains/games/apis/games_hub/hub_e2e_test.cc +++ b/domains/games/apis/games_hub/hub_e2e_test.cc @@ -2729,6 +2729,52 @@ TEST_F(GameEventFixture, ABiggerTableIsRecordedAtTheSizeItWasDealt) { EXPECT_THAT(events_[3], ::testing::HasSubstr(R"("players":3)")); } +// 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. +TEST_F(GameEventFixture, ARummyTableRecordsEachDealAsAGame) { + using moonbase::games::RummyMove; + auto table = SeatedRummyTable(); // the first deal dealt by bob + ASSERT_TRUE(table.has_value()); + Seat& alice = table->alice; + ASSERT_TRUE( + AwaitRummyView( + alice.stream, [](const auto& view) { return view.phase == "playing"; }, "the first deal") + .has_value()); + ASSERT_THAT(Names(), ::testing::ElementsAre("room_created", "room_joined", "game_started")); + EXPECT_THAT(events_[2], ::testing::HasSubstr(R"("variant":"rummy")")); + EXPECT_THAT(events_[2], ::testing::HasSubstr(R"("players":2)")); + + // Alice's quickest win: J♣ from the stock, then two runs. + ASSERT_TRUE( + alice.stream.Send(Rummy(RummyMove::FromDrawstock(moonbase::games::RummyDrawStock{}))).ok()); + for (const char* suit : {"♥", "♣"}) { + moonbase::games::RummyMeld meld; + meld.cards = {Named("J", suit), Named("Q", suit), Named("K", suit), Named("A", suit)}; + ASSERT_TRUE(alice.stream.Send(Rummy(RummyMove::FromMeld(meld))).ok()); + } + ASSERT_TRUE( + AwaitRummyView( + alice.stream, [](const auto& view) { return view.phase == "choosing"; }, "the deal's end") + .has_value()); + ASSERT_THAT(Names(), ::testing::ElementsAre("room_created", "room_joined", "game_started", + "game_finished")); + EXPECT_THAT(events_[3], ::testing::HasSubstr(R"("variant":"rummy")")); + EXPECT_THAT(events_[3], ::testing::HasSubstr(R"("outcome":"completed")")); + + // Alice deals the second: another game. + moonbase::games::RummyChooseVariant basic; + basic.variant = "basic"; + ASSERT_TRUE(alice.stream.Send(Rummy(RummyMove::FromChoosevariant(basic))).ok()); + ASSERT_TRUE( + AwaitRummyView( + alice.stream, [](const auto& view) { return view.dealNumber == 2; }, "the second deal") + .has_value()); + EXPECT_THAT(Names(), ::testing::ElementsAre("room_created", "room_joined", "game_started", + "game_finished", "game_started")); +} + TEST_F(GameEventFixture, ARefusedJoinIsNoEvent) { auto alice = OpenSeat(); ASSERT_TRUE(alice.has_value()); diff --git a/domains/games/apis/games_hub/pg_hub_store.cc b/domains/games/apis/games_hub/pg_hub_store.cc index 4a748ca2..af943254 100644 --- a/domains/games/apis/games_hub/pg_hub_store.cc +++ b/domains/games/apis/games_hub/pg_hub_store.cc @@ -21,12 +21,14 @@ constexpr char kUpsertRoom[] = R"sql( ON CONFLICT (room_id) DO NOTHING)sql"; constexpr char kSetRoomSurface[] = "UPDATE rooms SET geometry = $2::jsonb WHERE room_id = $1"; constexpr char kDeleteRoom[] = "DELETE FROM rooms WHERE room_id = $1"; +// A member's stats are written once, when the row is made; after that +// only a finish's increments move them (CommitGameFinish). A later upsert +// is presence, from an instance whose copy of the stats may predate a +// finish another instance committed. constexpr char kUpsertMember[] = R"sql( INSERT INTO room_members (room_id, player_id, connected, games_played, games_won, total_score) VALUES ($1, $2, $3::boolean, $4::integer, $5::integer, $6::integer) - ON CONFLICT (room_id, player_id) DO UPDATE - SET connected = EXCLUDED.connected, games_played = EXCLUDED.games_played, - games_won = EXCLUDED.games_won, total_score = EXCLUDED.total_score)sql"; + ON CONFLICT (room_id, player_id) DO UPDATE SET connected = EXCLUDED.connected)sql"; constexpr char kDeleteMember[] = "DELETE FROM room_members WHERE room_id = $1 AND player_id = $2"; constexpr char kDeleteGame[] = "DELETE FROM games WHERE room_id = $1 AND game_id = $2"; // Stamps a room active after a write that names it. Its own statement, 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 0cac1a7c..3be7d52a 100644 --- a/domains/games/apis/games_hub/pg_hub_store_test.cc +++ b/domains/games/apis/games_hub/pg_hub_store_test.cc @@ -254,7 +254,9 @@ TEST_F(PgHubStoreTest, OpsRoundTripThroughSnapshot) { } } - // Upserts converge on the latest value; deletes remove exactly their row. + // Upserts converge on the latest presence and leave the stats the row + // was made with (only a finish's increments move those); deletes remove + // exactly their row. alice.total_score = 12; alice.connected = false; store_->Enqueue({PgHubStore::UpsertMember{alice}, PgHubStore::DeleteMember{"R1", "bob"}, @@ -263,7 +265,7 @@ TEST_F(PgHubStoreTest, OpsRoundTripThroughSnapshot) { snapshot = store_->LoadSnapshot(); ASSERT_TRUE(snapshot.ok()); ASSERT_EQ(snapshot->members.size(), 1u); - EXPECT_EQ(snapshot->members[0].total_score, 12); + EXPECT_EQ(snapshot->members[0].total_score, 9); EXPECT_FALSE(snapshot->members[0].connected); ASSERT_EQ(snapshot->games.size(), 1u); EXPECT_EQ(snapshot->games[0].game_id, "G2"); @@ -381,6 +383,32 @@ TEST_F(PgHubStoreTest, FinishCommitAppliesStatsExactlyOnce) { expect_stats(); } +// Stats move only by a finish's increments. A presence write from an +// instance that has not yet heard of a finish carries stale stats, and a +// rummy table finishes a deal every hand (#1609): the write updates +// presence and leaves the counts to the increments that own them. +TEST_F(PgHubStoreTest, PresenceWritesLeaveStatsToTheirIncrements) { + store_->Enqueue( + {PgHubStore::UpsertRoom{"R1"}, PgHubStore::UpsertMember{{"R1", "alice", true, 3, 1, 10}}}); + store_->Flush(); + auto started = store_->CommitGameSave({"R1", "G1", {"alice"}, std::nullopt, 1}, "start"); + ASSERT_TRUE(started.ok() && *started); + auto landed = store_->CommitGameFinish({"R1", "G1", {"alice"}, DealtState(), 2}, + {{"alice", 1, 1, 4}}, "over"); + ASSERT_TRUE(landed.ok() && *landed) << landed.status(); + + // The sibling's stale view of alice: her stats from before the finish. + store_->Enqueue({PgHubStore::UpsertMember{{"R1", "alice", false, 3, 1, 10}}}); + store_->Flush(); + auto room = store_->LoadRoom("R1"); + ASSERT_TRUE(room.ok()) << room.status(); + ASSERT_EQ(room->members.size(), 1u); + EXPECT_FALSE(room->members[0].connected); + EXPECT_EQ(room->members[0].games_played, 4); + EXPECT_EQ(room->members[0].games_won, 2); + EXPECT_EQ(room->members[0].total_score, 14); +} + // The surface a room stands on rides its row (#1554): stored in the // wire's spelling, set by the create, kept against a second create, // changed by SetRoomSurface (a no-op for a room that is not), and read diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc index dd89aadf..5b19ffc1 100644 --- a/domains/games/apis/games_hub/rummy_e2e_test.cc +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -893,6 +893,8 @@ TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { .has_value()); ASSERT_TRUE(ReceiveCase(seat->stream, "roomState").has_value()); } + // Between deals nobody is on turn, and that is no turn to announce. + ExpectNoEvent(bob.stream); // Bob dealt the first; alice deals the second. ASSERT_TRUE(bob.stream.Send(Choose("basic")).ok()); diff --git a/domains/games/libs/cards/rummy/game_state.h b/domains/games/libs/cards/rummy/game_state.h index 5e497f98..9b85d1cc 100644 --- a/domains/games/libs/cards/rummy/game_state.h +++ b/domains/games/libs/cards/rummy/game_state.h @@ -97,7 +97,7 @@ class GameState { /// whoseTurn once the game is over. static constexpr int kNoTurn = -1; - /// The hand each seat is dealt at a table of this size. + /// The hand each seat is dealt. static constexpr int kHandSize = 7; GameState(std::deque _stock, std::vector _discard, std::vector _players, diff --git a/domains/games/libs/cards/rummy/table.cc b/domains/games/libs/cards/rummy/table.cc index ae126fe3..8dd23746 100644 --- a/domains/games/libs/cards/rummy/table.cc +++ b/domains/games/libs/cards/rummy/table.cc @@ -26,6 +26,14 @@ std::string_view variantName(Variant variant) { return "basic"; } +std::string_view recordedName(Variant variant) { + switch (variant) { + case Variant::Basic: + return "rummy"; + } + return "rummy"; +} + std::optional parseVariant(std::string_view name) { if (name == "basic") return Variant::Basic; return std::nullopt; @@ -134,11 +142,15 @@ StatusOr TableState::removePlayer(int seat) const { gameId, versionId}; } - // The dealer's chair follows its holder; a dealer who left passes it to - // the seat after them, wrapping. + // The dealer's chair follows its holder. A dealer who left passes the + // next deal to the seat after them, wrapping: between deals that seat is + // the dealer now; mid-deal the deal's end advances the chair one seat, + // so it sits one seat back until then. + const int count = static_cast(newSeats.size()); int newDealer = dealer; if (dealer == seat) { - newDealer = seat % static_cast(newSeats.size()); + newDealer = seat % count; + if (phase == TablePhase::Playing) newDealer = (newDealer + count - 1) % count; } else if (dealer > seat) { newDealer--; } diff --git a/domains/games/libs/cards/rummy/table.h b/domains/games/libs/cards/rummy/table.h index 709632e3..855641b2 100644 --- a/domains/games/libs/cards/rummy/table.h +++ b/domains/games/libs/cards/rummy/table.h @@ -17,8 +17,11 @@ namespace rummy { /// gin (#1610) and rummy 500 (#1611) join here. enum class Variant { Basic }; -/// The wire's and the stats pipeline's word for a variant. +/// 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. +[[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); diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 66446024..9e7cb7c3 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -41,6 +41,8 @@ TEST(Variants, NamesRoundTripAndFitTheSeats) { EXPECT_FALSE(parseVariant("gin").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"); EXPECT_TRUE(variantsFor(1).empty()); EXPECT_TRUE(variantsFor(5).empty()); } @@ -190,6 +192,61 @@ TEST(Table, ALeaveMidDealLeavesTheDealToo) { EXPECT_EQ(left->getWins().size(), 2u); } +// Mid-deal the dealer is the seat that dealt the deal in play; its end +// passes the deal to the seat after. A dealer who leaves mid-deal still +// passes it to the seat after them, not the one after that. +TEST(Table, TheDealerLeavingMidDealPassesTheNextDealToTheSeatAfter) { + // a dealt; b is on turn holding one card after drawing. + GameState deal{{Card{Suit::Clubs, Rank::Two}}, + {Card{Suit::Clubs, Rank::Three}}, + {{"a", {Card{Suit::Clubs, Rank::Four}}}, + {"b", {Card{Suit::Clubs, Rank::Five}}}, + {"c", {Card{Suit::Clubs, Rank::Six}}}}, + {}, + 1, + Stage::Play, + Phase::Playing, + std::nullopt, + "T1", + ""}; + 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}); }); + ASSERT_TRUE(out.ok()) << out.status(); + ASSERT_EQ(out->getPhase(), TablePhase::Choosing); + EXPECT_EQ(out->getSeats().at(out->getDealer()), "b"); + + // The last seat dealing and leaving: the deal passes round to the first. + const TableState last_dealt{{"a", "b", "c"}, + {0, 0, 0}, + 2, + 1, + TablePhase::Playing, + Variant::Basic, + GameState{{Card{Suit::Clubs, Rank::Two}}, + {Card{Suit::Clubs, Rank::Three}}, + {{"a", {Card{Suit::Clubs, Rank::Four}}}, + {"b", {Card{Suit::Clubs, Rank::Five}}}, + {"c", {Card{Suit::Clubs, Rank::Six}}}}, + {}, + 0, + Stage::Play, + Phase::Playing, + std::nullopt, + "T1", + ""}, + "T1", + ""}; + 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}); }); + ASSERT_TRUE(ended.ok()) << ended.status(); + EXPECT_EQ(ended->getSeats().at(ended->getDealer()), "a"); +} + TEST(Table, BelowTwoSeatsTheTableClosesWithAnyDealInPlay) { auto dealt = opened({"a", "b"}).chooseVariant(0, Variant::Basic, deck()); ASSERT_TRUE(dealt.ok()); From f922348bfea1e341c94c3418101066ec27fe0292 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:05:14 +0000 Subject: [PATCH 4/4] rummy: pin mid-deal choice refusal, close standings, between-deals keys --- domains/games/apis/games_hub/rummy_e2e_test.cc | 17 ++++++++++++++++- domains/games/apis/games_hub/rummy_wire_test.cc | 7 +++++++ domains/games/libs/cards/rummy/table_test.cc | 5 +++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc index 5b19ffc1..45b6a760 100644 --- a/domains/games/apis/games_hub/rummy_e2e_test.cc +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -929,7 +929,22 @@ TEST_F(RummyGameFixture, TheNextDealIsTheNextDealersChoice) { ASSERT_TRUE(turn.has_value()); EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, bob.player_id); } - EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "chooseVariant"}}), 3); + // Mid-deal there is nothing to choose. + ASSERT_TRUE(alice.stream.Send(Choose("basic")).ok()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "not between deals"); + EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "chooseVariant"}}), 4); + + // Bob leaving mid-deal closes the table; its standings keep the hand + // alice won, and both deals count. + ASSERT_TRUE(bob.stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))).ok()); + auto ended = ReceiveRummy(alice.stream, "gameEnded"); + ASSERT_TRUE(ended.has_value()); + EXPECT_EQ(ended->as_gameEnded_or_null()->dealsPlayed, 2); + ASSERT_EQ(ended->as_gameEnded_or_null()->standings.size(), 1u); + EXPECT_EQ(ended->as_gameEnded_or_null()->standings[0].playerId, alice.player_id); + EXPECT_EQ(ended->as_gameEnded_or_null()->standings[0].handsWon, 1); } // A dealer the room shows as gone does not stall the table: any seat may diff --git a/domains/games/apis/games_hub/rummy_wire_test.cc b/domains/games/apis/games_hub/rummy_wire_test.cc index 19fe2d1f..ffe07f47 100644 --- a/domains/games/apis/games_hub/rummy_wire_test.cc +++ b/domains/games/apis/games_hub/rummy_wire_test.cc @@ -286,6 +286,13 @@ TEST_F(RummyWireTest, TurnMovesPinTheirSpellingAndTheDealsEndBytes) { const json room = json::parse(EventPayload(NextFrame(*creator), "roomState")); EXPECT_EQ(room["games"][0]["status"], "choosing"); 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"); } // Below two seats the table closes: the last deal, broken up, names no diff --git a/domains/games/libs/cards/rummy/table_test.cc b/domains/games/libs/cards/rummy/table_test.cc index 9e7cb7c3..58dcb5bc 100644 --- a/domains/games/libs/cards/rummy/table_test.cc +++ b/domains/games/libs/cards/rummy/table_test.cc @@ -92,8 +92,9 @@ TEST(Table, OnlyTheDealerChoosesUnlessTheDealerIsAway) { TEST(Table, NoChoosingWhileADealIsInPlay) { auto dealt = opened().chooseVariant(0, Variant::Basic, deck()); ASSERT_TRUE(dealt.ok()); - EXPECT_EQ(dealt->chooseVariant(0, Variant::Basic, deck()).status().code(), - absl::StatusCode::kFailedPrecondition); + const absl::Status refused = dealt->chooseVariant(0, Variant::Basic, deck()).status(); + EXPECT_EQ(refused.code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(refused.message(), "not between deals"); } TEST(Table, DealMovesGoToTheDealAndNowhereElse) {