diff --git a/domains/ai/apis/deja/src/hub.rs b/domains/ai/apis/deja/src/hub.rs index 2c3c11afd..aa1d381de 100644 --- a/domains/ai/apis/deja/src/hub.rs +++ b/domains/ai/apis/deja/src/hub.rs @@ -54,7 +54,7 @@ const EVENTS: [&str; 7] = [ "game_started", "game_finished", ]; -const VARIANTS: [&str; 2] = ["golf", "castle"]; +const VARIANTS: [&str; 3] = ["golf", "castle", "rummy"]; const SURFACES: [&str; 3] = ["plane", "sphere", "glasshouse"]; const OUTCOMES: [&str; 2] = ["completed", "abandoned"]; /// The seats the engine deals a table, from golf_hub's kMaxSeats. diff --git a/domains/games/apis/games_hub/BUILD.bazel b/domains/games/apis/games_hub/BUILD.bazel index 1f4df823d..6e9e740bb 100644 --- a/domains/games/apis/games_hub/BUILD.bazel +++ b/domains/games/apis/games_hub/BUILD.bazel @@ -3,7 +3,7 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") load("//bazel/rules:oci.bzl", "linux_amd64_oci_binary") # The games hub's model (#79): the room layer (games.smithy) and one -# envelope per tenant — lobby, voice, golf, castle. +# envelope per tenant — lobby, voice, golf, castle, rummy. smithy_cpp_server_library( name = "games_hub_smithy_server", srcs = [ @@ -11,6 +11,7 @@ smithy_cpp_server_library( "model/games.smithy", "model/golf.smithy", "model/lobby.smithy", + "model/rummy.smithy", "model/voice.smithy", ], namespace = "moonbase::games", @@ -24,6 +25,7 @@ smithy_cpp_client_library( "model/games.smithy", "model/golf.smithy", "model/lobby.smithy", + "model/rummy.smithy", "model/voice.smithy", ], namespace = "moonbase::games", @@ -39,6 +41,7 @@ cc_library( deps = [ "//domains/games/libs/cards/castle:game_state", "//domains/games/libs/cards/golf:game_state", + "//domains/games/libs/cards/rummy:game_state", ], ) @@ -103,6 +106,7 @@ cc_library( ":hosted_game", "//domains/games/libs/cards/castle:game_state", "//domains/games/libs/cards/golf:game_state", + "//domains/games/libs/cards/rummy:game_state", "@com_google_absl//absl/strings:str_format", "@com_google_absl//absl/time", ], @@ -122,6 +126,7 @@ cc_test( "//domains/games/libs/cards/castle:player", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/golf:player", + "//domains/games/libs/cards/rummy:game_state", "@com_google_absl//absl/status:statusor", "@com_google_absl//absl/time", "@googletest//:gtest_main", @@ -343,6 +348,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/platform/libs/pg", "@com_google_absl//absl/log", "@com_google_absl//absl/status", @@ -419,6 +425,8 @@ cc_test( "//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", + "//domains/games/libs/cards/rummy:game_state_serde", "//domains/platform/libs/pg", "//domains/platform/libs/pg:listener", "@googletest//:gtest_main", @@ -633,6 +641,7 @@ cc_library( "//domains/games/libs/cards/castle:player", "//domains/games/libs/cards/golf:game_state", "//domains/games/libs/cards/golf:player", + "//domains/games/libs/cards/rummy:game_state", "//domains/games/libs/mithril_cpp:client", "//domains/platform/libs/futility/otel:metrics", "//domains/platform/libs/pg:listener", @@ -736,6 +745,33 @@ cc_test( ], ) +# Rummy's wire goldens (#245): the rummy envelope's command and update +# bytes, every in-game move's spelling, and the view from each chair. +cc_test( + name = "rummy_wire_test", + size = "small", + srcs = [ + "rummy_wire_test.cc", + "wire_test_fixture.h", + ], + deps = [ + ":games_hub_handler", + ":games_hub_smithy_server", + ":golf_hub", + ":id_generator", + ":rate_limiter", + ":ticket_vault", + "//domains/games/libs/cards:dealer", + "//domains/platform/libs/futility/otel:capturing_metrics_recorder", + "//domains/platform/libs/futility/otel:metrics", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + "@opal_cpp//runtime:core", + "@opal_cpp//runtime:eventstream", + "@opal_cpp//runtime:http", + ], +) + # Beyoncé Rule wire-contract tests (consumer tier): the raw HTTP bytes and # eventstream frames the golf web client depends on, pinned as golden # fixtures driven through the generated server WITHOUT the generated @@ -803,6 +839,38 @@ cc_test( ], ) +# Rummy on the room stream (#245): whole games against an engine mirror +# at every table size, redaction, refusals, the refill, abandonment and +# resume. +cc_test( + name = "rummy_e2e_test", + size = "small", + srcs = [ + "rummy_e2e_test.cc", + "stream_test_fixture.h", + ], + deps = [ + ":games_hub_handler", + ":games_hub_smithy_client", + ":games_hub_smithy_server", + ":golf_hub", + ":hub_store", + ":id_generator", + ":ticket_vault", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "//domains/games/libs/cards/rummy:game_state", + "//domains/games/libs/cards/rummy:meld", + "//domains/platform/libs/futility/otel:capturing_metrics_recorder", + "//domains/platform/libs/futility/otel:metrics", + "@googletest//:gtest_main", + "@opal_cpp//runtime:client", + "@opal_cpp//runtime:core", + "@opal_cpp//runtime:http", + "@opal_cpp//runtime:http_beast", + ], +) + cc_test( name = "hub_e2e_test", size = "small", @@ -810,14 +878,15 @@ cc_test( "hub_e2e_test.cc", "stream_test_fixture.h", ], - # StreamSeriesMatchTheModelUnions and CastleSeriesMatchTheModelUnions - # read the models to pin the declared golf_*/castle_* rosters against + # StreamSeriesMatchTheModelUnions and the per-envelope pins read the + # models to pin the declared golf_*/castle_*/rummy_* rosters against # their union cases. data = [ "model/castle.smithy", "model/games.smithy", "model/golf.smithy", "model/lobby.smithy", + "model/rummy.smithy", "model/voice.smithy", ], deps = [ diff --git a/domains/games/apis/games_hub/README.md b/domains/games/apis/games_hub/README.md index b77e87ce4..8271b0934 100644 --- a/domains/games/apis/games_hub/README.md +++ b/domains/games/apis/games_hub/README.md @@ -1,19 +1,19 @@ # games_hub — the games hub on opal-cpp event streams -The backend behind muchq.com/games — the lobby (#1490), golf, and castle -(#77) — and its /golf and /thoughts pages (#79), on opal-cpp's +The backend behind muchq.com/games — the lobby (#1490), golf, castle +(#77) and rummy (#245) — and its /golf and /thoughts pages (#79), on opal-cpp's streaming stack: a modeled protocol with generated async handlers (ADR-0021), `SessionRegistry` fan-out with reconnect grace (ADR-0017/0020/0022), the JSON-text browser wire (ADR-0018), and ticket auth ahead of the 101. One session identity opens the one stream. -## The model (five namespaces) +## The model (six namespaces) - `model/games.smithy` — `moonbase.games`: the service, session identity (`POST /games/v2/session`), the two terminal stream errors, the one stream — `Play` at `/games/v2/play`, its `GameCommands`/`GameEvents` unions carrying the room layer's own cases plus one envelope member - per tenant (`lobby`, `voice`, `golf`, `castle`) — and the game-agnostic room + per tenant (`lobby`, `voice`, `golf`, `castle`, `rummy`) — and the game-agnostic room layer — rooms, chat, player info with room-scoped stats and the member's table (`PlayerInfo.table`: which game, which table, pending or in play, absent while idle — how the lobby tells who @@ -23,13 +23,18 @@ auth ahead of the 101. One session identity opens the one stream. - `model/golf.smithy` — `moonbase.golf`: golf's vocabulary, nested under the `golf` member of each streaming union. - `model/castle.smithy` — `moonbase.castle`: castle's vocabulary (#77), - the `castle` member of the same unions. A room hosts tables of either + the `castle` member of the same unions. A room hosts tables of any game (`GameSummary.game` says which); the shared lifecycle shapes (create/join/start/leave and their announcements) are reused, and each game's join is refused on the other game's table, so nobody is seated at a table whose vocabulary they do not speak. The room-wide `gameCreated` is the one event that crosses: a room hears every table in that table's own envelope. +- `model/rummy.smithy` — `moonbase.rummy`: rummy's vocabulary (#245), + the `rummy` member, on castle's terms: the shared lifecycle shapes, a + join refused at another game's table, and moves that name their cards. + Its shapes are all prefixed `Rummy`, since codegen flattens the + namespaces into one. - `model/lobby.smithy` — `moonbase.lobby`: the world's shapes — the `lobby` member of the room stream (`LobbyAction`, `LobbyUpdate`) and what they carry. @@ -38,9 +43,9 @@ auth ahead of the 101. One session identity opens the one stream. and the WebRTC signals between its members. A new game is one new model file and one more envelope member on the -room stream's unions, the way castle and the lobby joined. Codegen +room stream's unions, the way castle, rummy and the lobby joined. Codegen flattens every namespace into `moonbase::games`, so shape names must be -unique across the five files (a collision gets the foreign namespace's +unique across the six files (a collision gets the foreign namespace's name appended, which nothing here wants). ## The lobby's world @@ -342,13 +347,24 @@ public), a knock gives every other player one final turn, an exhausted draw pile ends the game, three of a kind scores exactly one card, and non-knocker ties are shared wins (the knocker takes ties alone). The engine is `libs/cards/golf`'s immutable `GameState`, which also carries -`hideCards` and `removePlayer` for abandoned seats. +`hideCards` and `removePlayer` for abandoned seats. Castle's rules are +`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. ## 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. Golf's -rules, below, are `ViewLocked`'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 +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. Every game broadcast is per-recipient (`ViewLocked`): own card faces only at the viewer's peeked indexes, the drawn card only to its holder, other @@ -373,13 +389,13 @@ lobby-safe summaries only. stream side counts admissions, live sessions, disconnects, grace expiries, and the command/event flow (`hub_*` for the room layer — sessions, seats, refusals, its own commands and events — `golf_*`, - `castle_*`, `lobby_*` and `voice_*` for each tenant's envelope, `chat_*` for + `castle_*`, `rummy_*`, `lobby_*` and `voice_*` for each tenant's envelope, `chat_*` for chat). The tape rides the lobby's prefix: `lobby_tape_polls{result}`, `lobby_tape_splats`, and the `lobby_tape_poller_active` gauge, which is 1 exactly while a glasshouse is occupied. - `ALLOWED_ORIGINS` unset admits all origins (local dev); production sets the allowlist. - Deployed behind Caddy at `/games/v2/*` (`deploy/consolidated`); the - muchq.com games, golf, castle and thoughts UIs' only backend. No game + muchq.com games, golf, castle, rummy and thoughts UIs' only backend. No game has a route of its own: all ride the one play stream, and the origin gate is per connection, not per game. diff --git a/domains/games/apis/games_hub/game_events.cc b/domains/games/apis/games_hub/game_events.cc index 3067a51c2..faa244269 100644 --- a/domains/games/apis/games_hub/game_events.cc +++ b/domains/games/apis/games_hub/game_events.cc @@ -11,6 +11,7 @@ #include "domains/games/apis/games_hub/hosted_game.h" #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" namespace games_hub { namespace { @@ -48,6 +49,10 @@ 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 (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 29bb6cafe..81be73f53 100644 --- a/domains/games/apis/games_hub/game_events.h +++ b/domains/games/apis/games_hub/game_events.h @@ -58,7 +58,7 @@ inline constexpr std::string_view kOutcomeAbandoned = "abandoned"; /// A game that ended, as the event describes it. struct GameFinished { - /// "golf" or "castle" — GameKindName's spelling, which is also the + /// "golf", "castle" or "rummy" — GameKindName's spelling, which is also the /// wire's and the stored row's. std::string_view variant; /// kOutcomeCompleted — the engine played it out — or kOutcomeAbandoned: too few diff --git a/domains/games/apis/games_hub/game_events_test.cc b/domains/games/apis/games_hub/game_events_test.cc index c40ed64fb..19ba80309 100644 --- a/domains/games/apis/games_hub/game_events_test.cc +++ b/domains/games/apis/games_hub/game_events_test.cc @@ -19,6 +19,7 @@ #include "domains/games/libs/cards/castle/player.h" #include "domains/games/libs/cards/golf/game_state.h" #include "domains/games/libs/cards/golf/player.h" +#include "domains/games/libs/cards/rummy/game_state.h" #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -58,6 +59,22 @@ 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{ + {Card{Suit::Clubs, Rank::Two}}, + {Card{Suit::Clubs, Rank::Three}}, + {{"andy", {Card{Suit::Clubs, Rank::Five}}}, {"mercy", {Card{Suit::Clubs, Rank::Six}}}}, + {}, + 0, + rummy::Stage::Play, + rummy::Phase::Playing, + std::nullopt, + "game", + "v0"}; +} + TEST(GameEvents, AGolfGamePlayedOutIsCompleted) { const golf::GameState knocked = Golf(0); ASSERT_TRUE(knocked.isOver()); @@ -104,6 +121,28 @@ TEST(GameEvents, ACastleGameLeftBelowTwoSeatsIsAbandoned) { 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); + + const GameFinished finished = FinishedOf(HostedState(*over), 2); + 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); + ASSERT_TRUE(gone.ok()) << gone.status(); + ASSERT_EQ(gone->getPhase(), rummy::Phase::Abandoned); + + const GameFinished finished = FinishedOf(HostedState(*gone), 1); + EXPECT_EQ(finished.variant, "rummy"); + EXPECT_EQ(finished.outcome, "abandoned"); + EXPECT_EQ(finished.players, 1u); +} + // 2026-09-21T13:40:00Z, so a reader can see the stamp is epoch millis // and in UTC rather than take the format string's word for it. constexpr int64_t kWhenMillis = 1789998000000; @@ -175,18 +214,23 @@ TEST(GameEvents, EveryEventsLineIsTextWithNothingToEscape) { lines.emplace_back(ChatMessageLine(when, room, 3), 12); lines.emplace_back(GameStartedLine(when, room, "golf", 2), 16); lines.emplace_back(GameStartedLine(when, room, "castle", 4), 16); + lines.emplace_back(GameStartedLine(when, room, "rummy", 3), 16); std::vector endings; endings.emplace_back(Golf(0)); 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(*Rummy().removePlayer(1)); for (const HostedState& state : endings) { for (std::size_t players = 1; players <= 4; ++players) { const GameFinished finished = FinishedOf(state, players); EXPECT_TRUE(finished.outcome == kOutcomeCompleted || finished.outcome == kOutcomeAbandoned) << finished.outcome; - EXPECT_TRUE(finished.variant == "golf" || finished.variant == "castle") << finished.variant; + EXPECT_TRUE(finished.variant == "golf" || finished.variant == "castle" || + finished.variant == "rummy") + << finished.variant; lines.emplace_back(GameFinishedLine(when, room, finished), 20); } } diff --git a/domains/games/apis/games_hub/golf_hub.cc b/domains/games/apis/games_hub/golf_hub.cc index c579f1c25..2a118dd74 100644 --- a/domains/games/apis/games_hub/golf_hub.cc +++ b/domains/games/apis/games_hub/golf_hub.cc @@ -26,6 +26,7 @@ #include "domains/games/apis/games_hub/wire_cards.h" #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" namespace games_hub { @@ -35,6 +36,8 @@ using moonbase::games::GameCommands; using moonbase::games::GameEvents; using moonbase::games::GolfMove; using moonbase::games::GolfUpdate; +using moonbase::games::RummyMove; +using moonbase::games::RummyUpdate; std::unordered_set WinnersAmong(const golf::GameState& state, const std::vector& roster) { @@ -102,23 +105,40 @@ GameEvents CastleUpdateEvent(CastleUpdate update) { return GameEvents::FromCastle(std::move(event)); } -// The shared lifecycle announcements, in the table's own envelope. +GameEvents RummyUpdateEvent(RummyUpdate update) { + moonbase::games::RummyEvent event; + event.update = std::move(update); + return GameEvents::FromRummy(std::move(event)); +} + +// The shared lifecycle announcements, in the table's own envelope. Each +// game's update union carries the same case for them, so one generic +// build serves all three: `make` picks the case off whichever union. +template +GameEvents InTableEnvelope(GameKind kind, const Make& make) { + switch (kind) { + case GameKind::kCastle: + return CastleUpdateEvent(make(CastleUpdate{})); + case GameKind::kRummy: + return RummyUpdateEvent(make(RummyUpdate{})); + case GameKind::kGolf: + break; + } + return GolfUpdateEvent(make(GolfUpdate{})); +} GameEvents CreatedEvent(GameKind kind, moonbase::games::GameCreated created) { - return kind == GameKind::kCastle ? CastleUpdateEvent(CastleUpdate::FromGamecreated(created)) - : GolfUpdateEvent(GolfUpdate::FromGamecreated(created)); + return InTableEnvelope(kind, [&](auto tag) { return decltype(tag)::FromGamecreated(created); }); } GameEvents StartedEvent(GameKind kind) { - return kind == GameKind::kCastle - ? CastleUpdateEvent(CastleUpdate::FromGamestarted(moonbase::games::GameStarted{})) - : GolfUpdateEvent(GolfUpdate::FromGamestarted(moonbase::games::GameStarted{})); + return InTableEnvelope(kind, [](auto tag) { + return decltype(tag)::FromGamestarted(moonbase::games::GameStarted{}); + }); } GameEvents TurnEvent(GameKind kind, moonbase::games::TurnChanged turn) { - return kind == GameKind::kCastle ? CastleUpdateEvent(CastleUpdate::FromTurnchanged(turn)) - : GolfUpdateEvent(GolfUpdate::FromTurnchanged(turn)); + return InTableEnvelope(kind, [&](auto tag) { return decltype(tag)::FromTurnchanged(turn); }); } GameEvents LeftEvent(GameKind kind, moonbase::games::GameLeft left) { - return kind == GameKind::kCastle ? CastleUpdateEvent(CastleUpdate::FromGameleft(left)) - : GolfUpdateEvent(GolfUpdate::FromGameleft(left)); + return InTableEnvelope(kind, [&](auto tag) { return decltype(tag)::FromGameleft(left); }); } // Bounded rebase-retry for the conditional commits: each miss adopts the @@ -144,11 +164,54 @@ std::string CastlePlayerIdAt(const castle::GameState& state, int seat) { return state.getPlayer(seat).getId(); } +// The stale-view refusal for a rummy move: the first named card the hand +// does not hold, spelled the way the player sees it. +absl::Status HeldInHand(const std::vector& hand, + const std::vector& named) { + for (const cards::Card& card : named) { + if (std::find(hand.begin(), hand.end(), card) == hand.end()) { + const moonbase::games::Card wire = WireCard(card); + return absl::NotFoundError(absl::StrCat("not in your hand: ", wire.rank, wire.suit)); + } + } + return absl::OkStatus(); +} + +std::string RummyMoveName(rummy::MoveKind kind) { + switch (kind) { + case rummy::MoveKind::DrawStock: + return "drawStock"; + case rummy::MoveKind::DrawDiscard: + return "drawDiscard"; + case rummy::MoveKind::Meld: + return "meld"; + case rummy::MoveKind::LayOff: + return "layOff"; + case rummy::MoveKind::Discard: + return "discard"; + } + return "drawStock"; +} + +std::vector WireCards(const std::vector& cards) { + std::vector wire; + wire.reserve(cards.size()); + for (const cards::Card& card : cards) wire.push_back(WireCard(card)); + return wire; +} + +std::string RummyPhaseString(const rummy::GameState& state) { + return state.isOver() ? "ended" : "playing"; +} + // The table's phase for the room's lobby summary, whichever game it plays. 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); + } return CastlePhaseString(std::get(state)); } @@ -157,6 +220,10 @@ 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; + } const auto& castle_state = std::get(state); return CastlePlayerIdAt(castle_state, castle_state.getWhoseTurn()); } @@ -207,17 +274,38 @@ 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. +std::vector RummyStatsDeltas(const rummy::GameState& state) { + const std::string winner = state.winner().value_or(""); + std::vector deltas; + for (const rummy::Player& seat : state.getPlayers()) { + games_hub::HubStore::StatsDelta delta; + delta.player_id = seat.id; + delta.played = 1; + delta.won = seat.id == winner ? 1 : 0; + deltas.push_back(std::move(delta)); + } + return deltas; +} + std::vector StatsDeltasOf(const HostedState& state, const std::vector& roster) { 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); + } return CastleStatsDeltas(std::get(state)); } // The seat a leaver vacates, in whichever engine: compacted while seats // remain, or the game resolved (golf keeps every seat for the scorecard; -// castle abandons below two seats). +// castle and rummy abandon below two seats). std::optional WithoutSeat(const HostedState& state, const std::string& player_id) { return std::visit( [&](const auto& engine) -> std::optional { @@ -305,6 +393,22 @@ const std::vector& GolfHub::DeclaredCounterSeries() { {"castle_events", {{"event", "turnChanged"}}}, {"castle_events", {{"event", "gameEnded"}}}, {"castle_events", {{"event", "gameLeft"}}}, + {"rummy_commands", {{"command", "createGame"}}}, + {"rummy_commands", {{"command", "joinGame"}}}, + {"rummy_commands", {{"command", "startGame"}}}, + {"rummy_commands", {{"command", "leaveGame"}}}, + {"rummy_commands", {{"command", "drawStock"}}}, + {"rummy_commands", {{"command", "drawDiscard"}}}, + {"rummy_commands", {{"command", "meld"}}}, + {"rummy_commands", {{"command", "layOff"}}}, + {"rummy_commands", {{"command", "discard"}}}, + {"rummy_events", {{"event", "gameJoined"}}}, + {"rummy_events", {{"event", "gameState"}}}, + {"rummy_events", {{"event", "gameCreated"}}}, + {"rummy_events", {{"event", "gameStarted"}}}, + {"rummy_events", {{"event", "turnChanged"}}}, + {"rummy_events", {{"event", "gameEnded"}}}, + {"rummy_events", {{"event", "gameLeft"}}}, {"chat_appends", {{"result", "stored"}}}, {"chat_appends", {{"result", "rejected"}}}, {"chat_appends", {{"result", "unavailable"}}}, @@ -1468,6 +1572,11 @@ void GolfHub::HandleCommand(const std::string& player_id, const GameCommands& co return; } + if (const auto* rummy_command = command.as_rummy_or_null()) { + HandleRummyMove(player_id, rummy_command->move); + return; + } + if (const auto* lobby = command.as_lobby_or_null()) { HandleLobby(player_id, lobby->action); return; @@ -1718,18 +1827,19 @@ void GolfHub::HandleMove(const std::string& player_id, const GolfMove& move) { Reject(player_id, RejectKind::kUnknown, "unknown move"); } -void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& move) { +template +bool GolfHub::LifecycleMove(const std::string& player_id, const Move& move, GameKind kind) { if (move.as_createGame_or_null() != nullptr) { - CreateGameMove(player_id, GameKind::kCastle); - return; + CreateGameMove(player_id, kind); + return true; } if (const auto* join = move.as_joinGame_or_null()) { - JoinGameMove(player_id, join->gameId, GameKind::kCastle); - return; + JoinGameMove(player_id, join->gameId, kind); + return true; } if (move.as_startGame_or_null() != nullptr) { StartGameMove(player_id); - return; + return true; } if (move.as_leaveGame_or_null() != nullptr) { Outbox outbox; @@ -1746,8 +1856,13 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m } else { Reject(player_id, RejectKind::kState, "not in a game"); } - return; + return true; } + return false; +} + +void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& move) { + if (LifecycleMove(player_id, move, GameKind::kCastle)) return; if (const auto* swap = move.as_swapForSetup_or_null()) { const auto named = CardsFromWire({swap->handCard, swap->faceUpCard}); @@ -1757,21 +1872,23 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m } const cards::Card hand_card = named->front(); const cards::Card face_up_card = named->back(); - CastleEngineMove(player_id, - [hand_card, face_up_card](const castle::GameState& state, - int seat) -> absl::StatusOr { - const castle::Player& player = state.getPlayer(seat); - const auto hand = RowIndexesOf(player.getHand(), {hand_card}); - if (!hand.ok()) return hand.status(); - const auto face_up = RowIndexesOf(player.getFaceUp(), {face_up_card}); - if (!face_up.ok()) return face_up.status(); - return state.swapForSetup(seat, hand->front(), face_up->front()); - }); + TableEngineMove( + player_id, GameKind::kCastle, + [hand_card, face_up_card](const castle::GameState& state, + int seat) -> absl::StatusOr { + const castle::Player& player = state.getPlayer(seat); + const auto hand = RowIndexesOf(player.getHand(), {hand_card}); + if (!hand.ok()) return hand.status(); + const auto face_up = RowIndexesOf(player.getFaceUp(), {face_up_card}); + if (!face_up.ok()) return face_up.status(); + return state.swapForSetup(seat, hand->front(), face_up->front()); + }); return; } if (move.as_ready_or_null() != nullptr) { - CastleEngineMove(player_id, - [](const castle::GameState& state, int seat) { return state.ready(seat); }); + TableEngineMove( + player_id, GameKind::kCastle, + [](const castle::GameState& state, int seat) { return state.ready(seat); }); return; } // A play names its cards; the row it names them in says which slots the @@ -1786,8 +1903,8 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m return; } const std::vector cards = *named; - CastleEngineMove( - player_id, + TableEngineMove( + player_id, GameKind::kCastle, [cards](const castle::GameState& state, int seat) -> absl::StatusOr { const std::vector& row = state.getPlayer(seat).getHand(); if (row.empty()) return state.playFromHand(seat, {}); @@ -1804,8 +1921,8 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m return; } const std::vector cards = *named; - CastleEngineMove( - player_id, + TableEngineMove( + player_id, GameKind::kCastle, [cards](const castle::GameState& state, int seat) -> absl::StatusOr { const std::vector& row = state.getPlayer(seat).getFaceUp(); if (row.empty()) return state.playFaceUp(seat, {}); @@ -1817,14 +1934,80 @@ void GolfHub::HandleCastleMove(const std::string& player_id, const CastleMove& m } if (const auto* play = move.as_playFaceDown_or_null()) { const int index = play->index; - CastleEngineMove(player_id, [index](const castle::GameState& state, int seat) { - return state.playFaceDown(seat, index); - }); + TableEngineMove(player_id, GameKind::kCastle, + [index](const castle::GameState& state, int seat) { + return state.playFaceDown(seat, index); + }); return; } if (move.as_pickUp_or_null() != nullptr) { - CastleEngineMove(player_id, - [](const castle::GameState& state, int seat) { return state.pickUp(seat); }); + TableEngineMove( + player_id, GameKind::kCastle, + [](const castle::GameState& state, int seat) { return state.pickUp(seat); }); + return; + } + + Reject(player_id, RejectKind::kUnknown, "unknown move"); +} + +// Rummy's moves. The ones that put cards down name them: a spelling no +// card has never reaches the table, and a card the hand does not hold is +// the engine's NotFound — the same stale-view refusal castle's rows give — +// 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)>; + 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; + }); + }; + + if (move.as_drawStock_or_null() != nullptr) { + TableEngineMove( + player_id, GameKind::kRummy, + [](const rummy::GameState& state, int seat) { return state.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); }); + 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); }); + 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, + const std::vector& cards) { + return state.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()); + }); return; } @@ -1991,7 +2174,14 @@ void GolfHub::StartGameMove(const std::string& player_id) { std::deque deck = dealer_->DealNewUnshuffledDeck(); dealer_->ShuffleDeck(deck); std::optional dealt; - if (ref->entry->kind == GameKind::kCastle) { + 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())}; + break; + } + dealt.emplace(*std::move(rummy_deal)); + } else if (ref->entry->kind == GameKind::kCastle) { auto castle_deal = castle::dealCastleGame(ref->game_id, ref->entry->roster, std::move(deck)); if (!castle_deal.ok()) { @@ -2063,7 +2253,8 @@ void GolfHub::EngineMove(const std::string& player_id, const MoveFn& move, MoveE // rebase adopts whatever the store holds under this code. for (int attempt = 0; attempt < kMaxCommitAttempts && !refusal.has_value(); ++attempt) { if (ref->entry->kind != GameKind::kGolf) { - refusal = Refusal{RejectKind::kState, "that table plays castle"}; + refusal = Refusal{RejectKind::kState, + absl::StrCat("that table plays ", GameKindName(ref->entry->kind))}; break; } const golf::GameState& state = ref->entry->golf(); @@ -2150,7 +2341,9 @@ void GolfHub::EngineMove(const std::string& player_id, const MoveFn& move, MoveE } } -void GolfHub::CastleEngineMove(const std::string& player_id, const CastleMoveFn& move) { +template +void GolfHub::TableEngineMove(const std::string& player_id, GameKind kind, + const TableMoveFn& move) { Outbox outbox; Writes writes; std::optional refusal; @@ -2164,11 +2357,12 @@ void GolfHub::CastleEngineMove(const std::string& player_id, const CastleMoveFn& } else { bool landed = false; for (int attempt = 0; attempt < kMaxCommitAttempts && !refusal.has_value(); ++attempt) { - if (ref->entry->kind != GameKind::kCastle) { - refusal = Refusal{RejectKind::kState, "that table plays golf"}; + if (ref->entry->kind != kind) { + refusal = Refusal{RejectKind::kState, + absl::StrCat("that table plays ", GameKindName(ref->entry->kind))}; break; } - const castle::GameState& state = ref->entry->castle(); + const Engine& state = std::get(*ref->entry->state); const int seat = state.playerIndex(player_id); if (seat < 0) { refusal = Refusal{RejectKind::kState, "not seated in this game"}; @@ -2176,8 +2370,8 @@ void GolfHub::CastleEngineMove(const std::string& player_id, const CastleMoveFn& } auto next = move(state, seat); if (!next.ok()) { - // A card the seat's row does not hold (absl::NotFound, the one - // code the engine never returns) is a client acting on a view + // A card the seat's row does not hold (absl::NotFound: castle's + // RowIndexesOf, rummy's engine) is a client acting on a view // the table has moved past, not a rules refusal. const RejectKind kind = next.status().code() == absl::StatusCode::kNotFound ? RejectKind::kState @@ -2189,11 +2383,12 @@ void GolfHub::CastleEngineMove(const std::string& player_id, const CastleMoveFn& // opening turn (setup done) reads as a change from nobody. const std::string previous_turn = CurrentTurnOf(*ref->entry->state); const bool over = next->isOver(); + HostedState next_state(*std::move(next)); std::vector deltas; - if (over) deltas = CastleStatsDeltas(*next); + if (over) deltas = StatsDeltasOf(next_state, ref->entry->roster); const Commit commit = CommitEntryLocked(ref->room_id, ref->game_id, *ref->entry, ref->entry->roster, - HostedState(*std::move(next)), over ? &deltas : nullptr); + std::move(next_state), over ? &deltas : nullptr); if (commit == Commit::kRebased) continue; if (commit == Commit::kGone) { DropGameLocked(*ref); @@ -2215,7 +2410,7 @@ void GolfHub::CastleEngineMove(const std::string& player_id, const CastleMoveFn& moonbase::games::TurnChanged turn; turn.playerId = current_turn; for (const std::string& recipient : ref->entry->roster) { - outbox.To(recipient, TurnEvent(GameKind::kCastle, turn)); + outbox.To(recipient, TurnEvent(kind, turn)); } } } @@ -2354,7 +2549,8 @@ void GolfHub::LeaveGameLocked(const std::string& player_id, Outbox& outbox, Writ // With seats to spare the game continues compacted; otherwise the // engine ends it — golf keeps every seat so the departed hand still - // reaches the scorecard (#1236), castle abandons with no loser. The + // reaches the scorecard (#1236), castle and rummy abandon with nobody + // losing or winning. The // shrunken roster is what records who left — and who can still win. const std::optional state = entry.started() ? WithoutSeat(*entry.state, player_id) : std::nullopt; @@ -2591,7 +2787,7 @@ void GolfHub::TrackActive(int delta) { // The room layer's own cases count on hub_commands; each tenant's // envelope counts its inner case on its own series (golf_*, castle_*, -// lobby_*, voice_*). +// rummy_*, lobby_*, voice_*). void GolfHub::CountCommand(const GameCommands& command) { if (!metrics_) return; if (const auto* golf = command.as_golf_or_null()) { @@ -2599,6 +2795,9 @@ void GolfHub::CountCommand(const GameCommands& command) { } else if (const auto* castle = command.as_castle_or_null()) { metrics_->RecordCounter("castle_commands", 1, {{"command", std::string(castle->move.case_name())}}); + } else if (const auto* rummy = command.as_rummy_or_null()) { + metrics_->RecordCounter("rummy_commands", 1, + {{"command", std::string(rummy->move.case_name())}}); } else if (const auto* lobby = command.as_lobby_or_null()) { metrics_->RecordCounter("lobby_commands", 1, {{"command", std::string(lobby->action.case_name())}}); @@ -2618,6 +2817,9 @@ void GolfHub::Send(const std::string& player_id, GameEvents event, } else if (const auto* castle = event.as_castle_or_null()) { metrics_->RecordCounter("castle_events", 1, {{"event", std::string(castle->update.case_name())}}); + } else if (const auto* rummy = event.as_rummy_or_null()) { + metrics_->RecordCounter("rummy_events", 1, + {{"event", std::string(rummy->update.case_name())}}); } else if (const auto* lobby = event.as_lobby_or_null()) { metrics_->RecordCounter("lobby_events", 1, {{"event", std::string(lobby->update.case_name())}}); @@ -2808,6 +3010,64 @@ moonbase::games::CastleView GolfHub::CastleViewLocked(const std::string& game_id return view; } +moonbase::games::RummyView GolfHub::RummyViewLocked(const std::string& game_id, + const GameEntry& entry, + const std::string& viewer_id) const { + moonbase::games::RummyView view; + view.gameId = game_id; + view.stockCount = 0; + view.canDrawStock = false; + view.discardCount = 0; + if (!entry.started()) { + view.phase = "waiting"; + for (const std::string& roster_id : entry.roster) { + moonbase::games::RummyPlayer player; + player.playerId = roster_id; + player.handCount = 0; + view.players.push_back(std::move(player)); + } + 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()) { + 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); + view.players.push_back(std::move(player)); + } + return view; +} + GameEvents GolfHub::JoinedEventLocked(const std::string& game_id, const GameEntry& entry, const std::string& viewer_id) const { if (entry.kind == GameKind::kCastle) { @@ -2815,6 +3075,11 @@ GameEvents GolfHub::JoinedEventLocked(const std::string& game_id, const GameEntr joined.view = CastleViewLocked(game_id, entry, viewer_id); return CastleUpdateEvent(CastleUpdate::FromGamejoined(std::move(joined))); } + if (entry.kind == GameKind::kRummy) { + moonbase::games::RummyGameJoined joined; + joined.view = RummyViewLocked(game_id, entry, viewer_id); + return RummyUpdateEvent(RummyUpdate::FromGamejoined(std::move(joined))); + } moonbase::games::GameJoined joined; joined.view = ViewLocked(game_id, entry, viewer_id); return GolfUpdateEvent(GolfUpdate::FromGamejoined(std::move(joined))); @@ -2829,6 +3094,12 @@ void GolfHub::StageGameViewsLocked(const std::string& game_id, const GameEntry& outbox.To(recipient, CastleUpdateEvent(CastleUpdate::FromGamestate(std::move(update)))); continue; } + if (entry.kind == GameKind::kRummy) { + moonbase::games::RummyGameState update; + update.view = RummyViewLocked(game_id, entry, recipient); + outbox.To(recipient, RummyUpdateEvent(RummyUpdate::FromGamestate(std::move(update)))); + continue; + } moonbase::games::GameStateUpdate update; update.view = ViewLocked(game_id, entry, recipient); outbox.To(recipient, GolfUpdateEvent(GolfUpdate::FromGamestate(std::move(update)))); @@ -2854,6 +3125,27 @@ void GolfHub::StageGameOverLocked(Room& room, const std::string& game_id, Outbox room.games.erase(game); 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(); + 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)); + } + StageGameViewsLocked(game_id, game->second, outbox); + for (const std::string& recipient : game->second.roster) { + outbox.To(recipient, RummyUpdateEvent(RummyUpdate::FromGameended(ended))); + player_game_.erase(recipient); + } + room.games.erase(game); + return; + } const golf::GameState& state = game->second.golf(); // Seat order, so the display string is stable. Winners come from the diff --git a/domains/games/apis/games_hub/golf_hub.h b/domains/games/apis/games_hub/golf_hub.h index a3afa7df7..6ba98a2ae 100644 --- a/domains/games/apis/games_hub/golf_hub.h +++ b/domains/games/apis/games_hub/golf_hub.h @@ -35,6 +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/platform/libs/futility/otel/metrics.h" #include "domains/platform/libs/pg/listener.h" #include "moonbase/games/server.h" @@ -72,11 +73,12 @@ struct GolfTestHooks { /// GolfHub is the room hub (#1187): seat admission, rooms, /// chat, and the game layer, behind GamesHubHandler::Play. The name is -/// golf's; the room layer, castle (#77), and the lobby (#1490) live here -/// too. A room hosts tables of either game (#79): golf on libs/cards/golf -/// and castle on libs/cards/castle, each a member of the stream's unions -/// with its own per-viewer view. Each tenant's envelope counts on its own -/// series (golf_, castle_, lobby_, voice_); the room layer's own are hub_*. +/// golf's; the room layer, castle (#77), rummy (#245) and the lobby (#1490) +/// live here too. A room hosts tables of any game (#79): golf on +/// libs/cards/golf, castle on libs/cards/castle and rummy on +/// libs/cards/rummy, each a member of the stream's unions with its own +/// per-viewer view. Each tenant's envelope counts on its own series (golf_, +/// castle_, rummy_, lobby_, voice_); the room layer's own are hub_*. /// /// The lobby member is the World (world.h) keyed by the session's room: /// a roomed session stands in its room's world, an unroomed one in the @@ -96,8 +98,9 @@ struct GolfTestHooks { /// /// Redaction discipline: every game broadcast is staged per recipient /// (StageGameViewsLocked, JoinedEventLocked) with views built by each -/// game's ViewLocked/CastleViewLocked — per-viewer state (golf's own peeks -/// and held draw, castle's own hand faces) has exactly one place per game +/// game's ViewLocked/CastleViewLocked/RummyViewLocked — per-viewer state +/// (golf's own peeks and held draw, castle's and rummy's own hand faces) +/// has exactly one place per game /// to land and no identical-bytes path can leak it. /// /// Chat observability (#1226): chat_appends{result}, chat_rows_delivered, @@ -345,7 +348,8 @@ class GolfHub final { /// CommitEntryLocked (store or not), and with a store the commit only /// lands when the stored row holds the predecessor — that condition /// is what serializes instances. kind is fixed at creation and says - /// which engine the state is; golf()/castle() are the typed reads. + /// which engine the state is; golf()/castle()/rummy() are the typed + /// reads. struct GameEntry { GameKind kind = GameKind::kGolf; std::vector roster; @@ -356,6 +360,9 @@ class GolfHub final { [[nodiscard]] const castle::GameState& castle() const { return std::get(*state); } + [[nodiscard]] const rummy::GameState& rummy() const { + return std::get(*state); + } }; struct Room { @@ -390,8 +397,10 @@ class GolfHub final { using Writes = std::vector; using MoveFn = std::function(const golf::GameState&, int seat)>; - using CastleMoveFn = - std::function(const castle::GameState&, int seat)>; + /// A move of castle's or rummy's: the engine's next state, from the + /// mover's seat. + template + using TableMoveFn = std::function(const Engine&, int seat)>; /// What a successful engine move announces beyond the state views. struct MoveEffects { bool announce_turn = false; // turnChanged when the seat advances @@ -411,6 +420,11 @@ class GolfHub final { void HandleCommand(const std::string& player_id, const moonbase::games::GameCommands& command); void HandleMove(const std::string& player_id, const moonbase::games::GolfMove& move); void HandleCastleMove(const std::string& player_id, const moonbase::games::CastleMove& move); + void HandleRummyMove(const std::string& player_id, const moonbase::games::RummyMove& move); + /// The lifecycle half of castle's and rummy's move unions, which share + /// its shapes: true when `move` was one and has been handled. + template + bool LifecycleMove(const std::string& player_id, const Move& move, GameKind kind); /// The lobby member: the session's world is its room's, or the plaza's. void HandleLobby(const std::string& player_id, const moonbase::games::LobbyAction& action); /// The world key a session stands in, or would: its room, else the plaza. @@ -440,9 +454,12 @@ class GolfHub final { /// The shared shape of every in-game engine move: transition, then /// stage the fan-out (views, turn change, game end) the result implies. void EngineMove(const std::string& player_id, const MoveFn& move, MoveEffects effects); - /// Castle's in-game moves: the same commit loop, and the engine's own - /// turn order decides the turnChanged. - void CastleEngineMove(const std::string& player_id, const CastleMoveFn& move); + /// Castle's and rummy's in-game moves: the same commit loop, and the + /// engine's own turn order decides the turnChanged. `kind` is the game + /// `Engine` plays; a move on another game's table is refused. + template + void TableEngineMove(const std::string& player_id, GameKind kind, + const TableMoveFn& move); /// Stream-side observability (#1187): the aura chain instruments /// only unary requests, so admissions, live-session count, disconnects, @@ -605,6 +622,8 @@ class GolfHub final { const std::string& viewer_id) const; moonbase::games::CastleView CastleViewLocked(const std::string& game_id, const GameEntry& entry, const std::string& viewer_id) const; + moonbase::games::RummyView RummyViewLocked(const std::string& game_id, const GameEntry& entry, + const std::string& viewer_id) const; /// One viewer's gameJoined in the table's own vocabulary. moonbase::games::GameEvents JoinedEventLocked(const std::string& game_id, const GameEntry& entry, const std::string& viewer_id) const; diff --git a/domains/games/apis/games_hub/hosted_game.h b/domains/games/apis/games_hub/hosted_game.h index cd6290ebd..d521a433b 100644 --- a/domains/games/apis/games_hub/hosted_game.h +++ b/domains/games/apis/games_hub/hosted_game.h @@ -8,30 +8,42 @@ #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" namespace games_hub { -/// The games a room can host (#79): golf and castle (#77). A table is +/// The games a room can host (#79): golf, castle (#77) and rummy (#245). A table is /// created as one kind and keeps it; the kind names the engine whose /// state the entry (and its stored row) carries, and the wire word on /// GameSummary.game. -enum class GameKind { kGolf, kCastle }; +enum class GameKind { kGolf, kCastle, kRummy }; /// The engine truth of a started table, whichever game it plays. -using HostedState = std::variant; +using HostedState = std::variant; inline std::string_view GameKindName(GameKind kind) { - return kind == GameKind::kCastle ? "castle" : "golf"; + switch (kind) { + case GameKind::kCastle: + return "castle"; + case GameKind::kRummy: + return "rummy"; + case GameKind::kGolf: + break; + } + return "golf"; } inline std::optional ParseGameKind(std::string_view name) { if (name == "golf") return GameKind::kGolf; if (name == "castle") return GameKind::kCastle; + if (name == "rummy") return GameKind::kRummy; return std::nullopt; } inline GameKind KindOf(const HostedState& state) { - return std::holds_alternative(state) ? GameKind::kCastle : GameKind::kGolf; + if (std::holds_alternative(state)) return GameKind::kCastle; + if (std::holds_alternative(state)) return GameKind::kRummy; + return GameKind::kGolf; } inline bool IsOver(const HostedState& state) { diff --git a/domains/games/apis/games_hub/hub_e2e_test.cc b/domains/games/apis/games_hub/hub_e2e_test.cc index e7da82126..caba952e2 100644 --- a/domains/games/apis/games_hub/hub_e2e_test.cc +++ b/domains/games/apis/games_hub/hub_e2e_test.cc @@ -2045,6 +2045,22 @@ TEST_F(GamesHubStreamFixture, BuildingAHandlerDeclaresEveryCounterSeriesAtZero) {"castle_events", {{"event", "turnChanged"}}}, {"castle_events", {{"event", "gameEnded"}}}, {"castle_events", {{"event", "gameLeft"}}}, + {"rummy_commands", {{"command", "createGame"}}}, + {"rummy_commands", {{"command", "joinGame"}}}, + {"rummy_commands", {{"command", "startGame"}}}, + {"rummy_commands", {{"command", "leaveGame"}}}, + {"rummy_commands", {{"command", "drawStock"}}}, + {"rummy_commands", {{"command", "drawDiscard"}}}, + {"rummy_commands", {{"command", "meld"}}}, + {"rummy_commands", {{"command", "layOff"}}}, + {"rummy_commands", {{"command", "discard"}}}, + {"rummy_events", {{"event", "gameJoined"}}}, + {"rummy_events", {{"event", "gameState"}}}, + {"rummy_events", {{"event", "gameCreated"}}}, + {"rummy_events", {{"event", "gameStarted"}}}, + {"rummy_events", {{"event", "turnChanged"}}}, + {"rummy_events", {{"event", "gameEnded"}}}, + {"rummy_events", {{"event", "gameLeft"}}}, {"chat_appends", {{"result", "stored"}}}, {"chat_appends", {{"result", "rejected"}}}, {"chat_appends", {{"result", "unavailable"}}}, @@ -2186,6 +2202,7 @@ TEST(StreamSeriesModelPin, StreamSeriesMatchTheModelUnions) { const auto updates = ModelUnionCases(golf, "GolfUpdate"); ASSERT_NE(std::find(outer_commands.begin(), outer_commands.end(), "castle"), outer_commands.end()); + ASSERT_NE(std::find(outer_commands.begin(), outer_commands.end(), "rummy"), outer_commands.end()); ASSERT_NE(std::find(outer_commands.begin(), outer_commands.end(), "lobby"), outer_commands.end()); // Controls before the comparison: a parser that quietly matched nothing @@ -2201,12 +2218,12 @@ TEST(StreamSeriesModelPin, StreamSeriesMatchTheModelUnions) { // the lobby's and voice's (the tests below), on its own series. std::set room_commands; for (const auto& name : outer_commands) { - if (name != "golf" && name != "castle" && name != "lobby" && name != "voice") + if (name != "golf" && name != "castle" && name != "rummy" && name != "lobby" && name != "voice") room_commands.insert(name); } std::set room_events; for (const auto& name : outer_events) { - if (name != "golf" && name != "castle" && name != "lobby" && name != "voice") + if (name != "golf" && name != "castle" && name != "rummy" && name != "lobby" && name != "voice") room_events.insert(name); } EXPECT_EQ(DeclaredLabelValues("hub_commands", "command"), room_commands); @@ -2262,6 +2279,21 @@ TEST(StreamSeriesModelPin, CastleSeriesMatchTheModelUnions) { std::set(updates.begin(), updates.end())); } +// Rummy's envelope (#245) counts its inner case names on rummy_commands and +// rummy_events, pinned against rummy.smithy the same way. +TEST(StreamSeriesModelPin, RummySeriesMatchTheModelUnions) { + const std::string model = ReadModel("domains/games/apis/games_hub/model/rummy.smithy"); + ASSERT_FALSE(model.empty()); + const auto moves = ModelUnionCases(model, "RummyMove"); + const auto updates = ModelUnionCases(model, "RummyUpdate"); + ASSERT_NE(std::find(moves.begin(), moves.end(), "layOff"), moves.end()); + ASSERT_NE(std::find(updates.begin(), updates.end(), "gameEnded"), updates.end()); + EXPECT_EQ(DeclaredLabelValues("rummy_commands", "command"), + std::set(moves.begin(), moves.end())); + EXPECT_EQ(DeclaredLabelValues("rummy_events", "event"), + std::set(updates.begin(), updates.end())); +} + // The bounded kind is the whole dashboard identity of a rejection (#1327, // #1384): each refusal lands on exactly one declared kind series, and the // free-text reason reaches the rejected player but never a metric label — diff --git a/domains/games/apis/games_hub/model/games.smithy b/domains/games/apis/games_hub/model/games.smithy index 78171ccf0..9ab96e95f 100644 --- a/domains/games/apis/games_hub/model/games.smithy +++ b/domains/games/apis/games_hub/model/games.smithy @@ -10,12 +10,15 @@ use moonbase.golf#GolfEvent use moonbase.lobby#Geometry use moonbase.lobby#LobbyCommand use moonbase.lobby#LobbyEvent +use moonbase.rummy#RummyCommand +use moonbase.rummy#RummyEvent use moonbase.voice#VoiceCommand use moonbase.voice#VoiceEvent /// The games hub (#79): one service, one session identity, one room layer, /// and one stream, Play, on which the lobby (#1490), voice (#1590), golf, -/// and castle (#77) each ride as one envelope member per direction. +/// castle (#77) and rummy (#245) each ride as one envelope member per +/// direction. @simpleRestJson @title("Games Hub") service GamesHub { @@ -46,7 +49,7 @@ operation Play { } /// The room layer's own commands, then one envelope per tenant: the -/// lobby's world, the room's voice, golf's table, castle's table. +/// lobby's world, the room's voice, golf's, castle's and rummy's tables. @streaming union GameCommands { createRoom: CreateRoom @@ -58,6 +61,7 @@ union GameCommands { voice: VoiceCommand golf: GolfCommand castle: CastleCommand + rummy: RummyCommand } @streaming @@ -72,6 +76,7 @@ union GameEvents { voice: VoiceEvent golf: GolfEvent castle: CastleEvent + rummy: RummyEvent } /// Session identity is game-agnostic: the route carries no game segment, @@ -86,7 +91,8 @@ operation GetSession { /// and chat. Apart from GameSummary.game and Table.game — the word that /// names a table's game for the lobby — nothing in this /// namespace knows which game a table plays; a game contributes only its -/// own vocabulary, the way moonbase.golf and moonbase.castle do. +/// own vocabulary, the way moonbase.golf, moonbase.castle and moonbase.rummy +/// do. /// GetSession's input: a resume token exchanges for a fresh ticket and /// the same playerId; absent or expired mints a fresh player. @@ -189,7 +195,7 @@ structure PlayerInfo { /// A room member's table: which game, which table. structure Table { - /// golf | castle, as GameSummary.game spells it. + /// golf | castle | rummy, as GameSummary.game spells it. @required game: String @@ -206,7 +212,7 @@ structure GameSummary { @required gameId: String - /// Which game the table plays: golf | castle. + /// Which game the table plays: golf | castle | rummy. @required game: String @@ -357,7 +363,7 @@ structure SeatConflict { } /// Ranks A 2..10 J Q K, suits ♠ ♥ ♦ ♣ — the glyphs the UI renders. -/// Castle's moves name cards in it too, so +/// Castle's and rummy's moves name cards in it too, so /// it is written as well as read: the ten is "10" and not "T", the suit /// is the glyph and not a letter, and the ranks are upper case. A card /// spelled any other way names nothing and is refused as malformed. diff --git a/domains/games/apis/games_hub/model/rummy.smithy b/domains/games/apis/games_hub/model/rummy.smithy new file mode 100644 index 000000000..efe7e93c5 --- /dev/null +++ b/domains/games/apis/games_hub/model/rummy.smithy @@ -0,0 +1,232 @@ +$version: "2.0" + +namespace moonbase.rummy + +use moonbase.games#Card +use moonbase.games#CreateGame +use moonbase.games#GameCreated +use moonbase.games#GameLeft +use moonbase.games#GameStarted +use moonbase.games#JoinGame +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. +// Shape names carry the game's name: codegen flattens every namespace +// into one. + +/// The rummy envelope on the command stream. +structure RummyCommand { + @required + move: RummyMove +} + +/// A turn is a draw (stock or discard), then any melds and lay-offs, then +/// a discard; a seat that empties its hand wins. Moves name the cards +/// they mean (#1505): a card the hand does not hold is refused in band, +/// so a move sent against a view the table has moved past costs a +/// refusal rather than playing a different card. +union RummyMove { + createGame: CreateGame + joinGame: JoinGame + startGame: StartGame + leaveGame: LeaveGame + drawStock: RummyDrawStock + drawDiscard: RummyDrawDiscard + meld: RummyMeld + layOff: RummyLayOff + discard: RummyDiscard +} + +/// Take the top of the stock. An empty stock is refilled from the +/// discard pile, turned over under its top card. +structure RummyDrawStock {} + +/// Take the top of the discard pile. That card may not be discarded again +/// this turn, unless it is the last card in the hand. +structure RummyDrawDiscard {} + +/// Lay down a set (three or four of a rank) or a run (three or more of a +/// suit in sequence, the ace low or high but not both). +structure RummyMeld { + @required + cards: RummyCards +} + +/// Add one card to a meld on the table, anyone's, naming it by its place +/// in RummyView.melds. Melds only ever grow and are never reordered, so +/// the place is stable for the whole game. +structure RummyLayOff { + @required + card: Card + + @required + meldIndex: Integer +} + +/// End the turn with one card on the discard pile. +structure RummyDiscard { + @required + card: Card +} + +/// The rummy envelope on the event stream. +structure RummyEvent { + @required + update: RummyUpdate +} + +union RummyUpdate { + gameJoined: RummyGameJoined + gameState: RummyGameState + gameCreated: GameCreated + gameStarted: GameStarted + turnChanged: TurnChanged + gameEnded: RummyGameEnded + gameLeft: GameLeft +} + +structure RummyGameJoined { + @required + view: RummyView +} + +structure RummyGameState { + @required + 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. +structure RummyGameEnded { + winner: String + + @required + points: Integer + + @required + scores: RummyScores +} + +list RummyScores { + member: RummyScore +} + +/// What a seat still held at the end: an ace one, two to ten their pips, +/// a face card ten. +structure RummyScore { + @required + playerId: String + + @required + 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. +structure RummyView { + @required + gameId: String + + /// waiting | playing | ended + @required + phase: String + + @required + players: RummyPlayers + + currentPlayerId: String + + /// Where the seat on turn is: draw (about to draw) | play (drawn; + /// melds, lay-offs, then a discard). Absent when nobody is on turn. + stage: String + + @required + stockCount: Integer + + /// Whether drawStock would take a card now: the stock holds one, or + /// the discard pile has cards under its top to turn over. + @required + canDrawStock: Boolean + + @required + discardCount: Integer + + /// The discard pile's top; absent while the pile is empty. + discardTop: Card + + /// The card the seat on turn took from the discard pile this turn, + /// which it may not throw back unless it is its last. Public: everyone + /// saw it taken. + takenDiscard: Card + + /// The melds on the table in the order they were laid; a lay-off names + /// one by its place here. + @required + melds: RummyTableMelds + + /// The table's most recent move, until the next replaces it. Absent + /// before the first. + lastMove: RummyLastMove +} + +list RummyPlayers { + member: RummyPlayer +} + +structure RummyPlayer { + @required + playerId: String + + @required + handCount: Integer + + /// The viewer's own hand, in the order its cards arrived (a drawn + /// card last); empty for everyone else until the game ends. + @required + hand: RummyCards +} + +list RummyTableMelds { + member: RummyTableMeld +} + +structure RummyTableMeld { + /// Who laid it down. May name a seat that has since left. + @required + owner: String + + /// Low to high for a run, by suit for a set. + @required + cards: RummyCards +} + +/// A move as the table saw it. +structure RummyLastMove { + /// Who moved. May name a seat that has since left the game. + @required + playerId: String + + /// drawStock | drawDiscard | meld | layOff | discard + @required + move: String + + /// What went on or came off the table: nothing for a stock draw + /// (nobody else sees it), the card taken, the meld as laid, the card + /// laid off, the card discarded. + @required + cards: RummyCards + + /// The meld a meld or lay-off made or grew; absent otherwise. + meldIndex: Integer +} + +list RummyCards { + member: Card +} 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 5684d5d8c..39efa4d7c 100644 --- a/domains/games/apis/games_hub/pg_hub_e2e_test.cc +++ b/domains/games/apis/games_hub/pg_hub_e2e_test.cc @@ -719,6 +719,97 @@ TEST_F(PgGamesHubFixture, CastleTableSurvivesARestart) { EXPECT_EQ(rows.games[0].version, version_before + 1); } +// A rummy table (#245) behind the same rows: a turn caught mid-way — the +// discard's top taken, a meld down — survives a restart with the taken +// card still barred from going back, and the turn finishes on the +// restored table. +TEST_F(PgGamesHubFixture, RummyTableSurvivesARestartMidTurn) { + using moonbase::games::RummyMove; + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + Seat& alice = table->alice; + Seat& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + + ASSERT_TRUE( + alice.stream.Send(Rummy(RummyMove::FromDrawdiscard(moonbase::games::RummyDrawDiscard{}))) + .ok()); + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + moonbase::games::RummyMeld meld; + 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()); + const moonbase::games::RummyView before = before_update->as_gameState_or_null()->view; + ASSERT_EQ(before.melds.size(), 1u); + + const int64_t version_before = [&] { + auto rows = Rows(); + EXPECT_EQ(rows.games.size(), 1u); + if (!rows.games.empty()) EXPECT_EQ(rows.games[0].kind, GameKind::kRummy); + return rows.games.empty() ? 0 : rows.games[0].version; + }(); + ASSERT_GT(version_before, 0); + + const std::string alice_token = alice.resume_token; + const std::string bob_token = bob.resume_token; + RestartHub(); + + auto alice_back = OpenSeat(alice_token); + ASSERT_TRUE(alice_back.has_value()); + ASSERT_TRUE(ReceiveCase(alice_back->stream, "sessionReady").has_value()); + auto resynced = ReceiveRummy(alice_back->stream, "gameJoined"); + ASSERT_TRUE(resynced.has_value()); + const auto& after = resynced->as_gameJoined_or_null()->view; + EXPECT_EQ(after.phase, "playing"); + EXPECT_EQ(after.currentPlayerId.value_or(""), alice.player_id); + EXPECT_EQ(after.stage.value_or(""), "play"); + 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♠"); + 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♠"); + ASSERT_TRUE(after.lastMove.has_value()); + EXPECT_EQ(after.lastMove->move, "meld"); + EXPECT_EQ(after.lastMove->meldIndex.value_or(-1), 0); + 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); + } + EXPECT_TRUE(after.players[1].hand.empty()); + EXPECT_EQ(after.players[1].handCount, 10); + + auto bob_back = OpenSeat(bob_token); + ASSERT_TRUE(bob_back.has_value()); + ASSERT_TRUE(ReceiveCase(bob_back->stream, "sessionReady").has_value()); + ASSERT_TRUE(ReceiveRummy(bob_back->stream, "gameJoined").has_value()); + + // The rule the taken card carries came back with it. + moonbase::games::RummyDiscard straight_back; + straight_back.card = Named("9", "♠"); + 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", "♦"); + 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()); + EXPECT_EQ(next_turn->as_turnChanged_or_null()->playerId, bob.player_id); + + auto rows = Rows(); + ASSERT_EQ(rows.games.size(), 1u); + EXPECT_EQ(rows.games[0].version, version_before + 1); +} + // A castle table shared across two instances: readies and plays from // either side land on the other as projected views, one commit each. TEST_F(PgGamesHubFixture, TwoInstancesShareOneCastleTable) { diff --git a/domains/games/apis/games_hub/pg_hub_store.cc b/domains/games/apis/games_hub/pg_hub_store.cc index 1dce663b3..af8c7ec91 100644 --- a/domains/games/apis/games_hub/pg_hub_store.cc +++ b/domains/games/apis/games_hub/pg_hub_store.cc @@ -8,6 +8,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" namespace games_hub { namespace { @@ -373,6 +374,10 @@ absl::StatusOr PgHubStore::RowFromColumns( auto state = castle::deserializeGameState(state_json); if (!state.ok()) return state.status(); row.state.emplace(*std::move(state)); + } else if (row.kind == GameKind::kRummy) { + auto state = rummy::deserializeGameState(state_json); + if (!state.ok()) return state.status(); + row.state.emplace(*std::move(state)); } else { auto state = golf::deserializeGameState(state_json); if (!state.ok()) return state.status(); 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 520a15e6b..4e0b2608b 100644 --- a/domains/games/apis/games_hub/pg_hub_store_test.cc +++ b/domains/games/apis/games_hub/pg_hub_store_test.cc @@ -20,6 +20,8 @@ #include "domains/games/libs/cards/dealer.h" #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/platform/libs/pg/listener.h" #include "domains/platform/libs/pg/pg.h" #include "gtest/gtest.h" @@ -148,6 +150,53 @@ TEST_F(PgHubStoreTest, CastleRowsKeepTheirKindAndDecodeWithCastleSerde) { EXPECT_EQ((*reread)->version, 2); } +// 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. +TEST_F(PgHubStoreTest, RummyRowsKeepTheirKindAndDecodeWithRummySerde) { + store_->Enqueue({PgHubStore::UpsertRoom{"R1"}}); + store_->Flush(); + PgHubStore::GameRow waiting{"R1", "M1", {"alice"}, std::nullopt, 1, games_hub::GameKind::kRummy}; + ASSERT_TRUE(*store_->CommitGameSave(waiting, "")); + cards::NoShuffleDealer dealer; + auto dealt = rummy::dealRummyGame("M2", {"alice", "bob"}, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(dealt.ok()) << dealt.status(); + auto drew = dealt->drawDiscard(0); + ASSERT_TRUE(drew.ok()); + auto melded = drew->meld(0, {cards::Card{cards::Suit::Spades, cards::Rank::Ace}, + 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}; + ASSERT_TRUE(*store_->CommitGameSave(started, "")); + + auto rows = store_->LoadRoom("R1"); + ASSERT_TRUE(rows.ok()) << rows.status(); + ASSERT_EQ(rows->games.size(), 2u); + for (const auto& game : rows->games) { + EXPECT_EQ(game.kind, games_hub::GameKind::kRummy) << game.game_id; + if (game.game_id == "M1") { + EXPECT_FALSE(game.state.has_value()); + 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)); + } + // A started row's column follows its state, whatever the row said. + PgHubStore::GameRow mislabeled{"R1", "M3", {"alice", "bob"}, games_hub::HostedState(*melded), 1}; + ASSERT_TRUE(*store_->CommitGameSave(mislabeled, "")); + auto relabeled = store_->LoadGame("R1", "M3"); + ASSERT_TRUE(relabeled.ok() && relabeled->has_value()); + EXPECT_EQ((*relabeled)->kind, games_hub::GameKind::kRummy); +} + // A row written before the kind column reads as golf (the migration's // default), and a kind no engine plays costs that row alone — the same // one-bad-row policy as an undecodable state. diff --git a/domains/games/apis/games_hub/rummy_e2e_test.cc b/domains/games/apis/games_hub/rummy_e2e_test.cc new file mode 100644 index 000000000..838848914 --- /dev/null +++ b/domains/games/apis/games_hub/rummy_e2e_test.cc @@ -0,0 +1,867 @@ +// 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 +// 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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "domains/games/apis/games_hub/stream_test_fixture.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/dealer.h" +#include "domains/games/libs/cards/rummy/game_state.h" +#include "domains/games/libs/cards/rummy/meld.h" + +namespace games_hub { +namespace { + +using moonbase::games::CastleMove; +using moonbase::games::GameCommands; +using moonbase::games::RummyMove; +using moonbase::games::RummyUpdate; +using moonbase::games::RummyView; + +std::string Face(const moonbase::games::Card& card) { return card.rank + card.suit; } + +std::vector Faces(const std::vector& cards) { + std::vector faces; + for (const auto& card : cards) faces.push_back(Face(card)); + return faces; +} + +// The engine's cards spelled the way the wire spells them — this table, +// not the hub's, so a view is compared to the mirror face by face. +moonbase::games::Card Wire(const cards::Card& card) { + static constexpr const char* kRanks[] = {"2", "3", "4", "5", "6", "7", "8", + "9", "10", "J", "Q", "K", "A"}; + static constexpr const char* kSuits[] = {"♣", "♦", "♥", "♠"}; + return Named(kRanks[static_cast(card.getRank())], kSuits[static_cast(card.getSuit())]); +} + +std::vector Wire(const std::vector& cards) { + std::vector wire; + for (const cards::Card& card : cards) wire.push_back(Wire(card)); + return wire; +} + +std::vector Faces(const std::vector& cards) { return Faces(Wire(cards)); } + +std::string MoveName(rummy::MoveKind kind) { + switch (kind) { + case rummy::MoveKind::DrawStock: + return "drawStock"; + case rummy::MoveKind::DrawDiscard: + return "drawDiscard"; + case rummy::MoveKind::Meld: + return "meld"; + case rummy::MoveKind::LayOff: + return "layOff"; + case rummy::MoveKind::Discard: + return "discard"; + } + return ""; +} + +GameCommands DrawStock() { + return Rummy(RummyMove::FromDrawstock(moonbase::games::RummyDrawStock{})); +} +GameCommands DrawDiscard() { + return Rummy(RummyMove::FromDrawdiscard(moonbase::games::RummyDrawDiscard{})); +} +GameCommands MeldOf(std::vector cards) { + moonbase::games::RummyMeld meld; + meld.cards = std::move(cards); + return Rummy(RummyMove::FromMeld(std::move(meld))); +} +GameCommands LayOff(moonbase::games::Card card, int meld_index) { + moonbase::games::RummyLayOff lay_off; + lay_off.card = std::move(card); + lay_off.meldIndex = meld_index; + return Rummy(RummyMove::FromLayoff(std::move(lay_off))); +} +GameCommands Discard(moonbase::games::Card card) { + moonbase::games::RummyDiscard discard; + discard.card = std::move(card); + return Rummy(RummyMove::FromDiscard(std::move(discard))); +} + +// A fixed shuffle, so a whole game replays the same every run. +class SeededDealer : public cards::Dealer { + public: + explicit SeededDealer(uint32_t seed) : seed_(seed) {} + void ShuffleDeck(std::deque& deck) override { + std::mt19937 generator(seed_); + std::shuffle(deck.begin(), deck.end(), generator); + } + + private: + uint32_t seed_; +}; + +// --- The mirror's policy: plain, greedy, and legal by construction. --- + +// Some three cards of the hand that make a meld, if any do. +std::optional> MeldIn(const std::vector& hand) { + for (std::size_t i = 0; i < hand.size(); ++i) { + for (std::size_t j = i + 1; j < hand.size(); ++j) { + for (std::size_t k = j + 1; k < hand.size(); ++k) { + if (rummy::arrangedMeld({hand[i], hand[j], hand[k]}).has_value()) { + return std::vector{hand[i], hand[j], hand[k]}; + } + } + } + } + return std::nullopt; +} + +// Whether this card and two of the hand would make a meld. +bool Completes(const std::vector& hand, const cards::Card& card) { + for (std::size_t i = 0; i < hand.size(); ++i) { + for (std::size_t j = i + 1; j < hand.size(); ++j) { + if (rummy::arrangedMeld({hand[i], hand[j], card}).has_value()) return true; + } + } + return false; +} + +// A card of the hand and a meld it fits, if any. +std::optional> LayOffIn(const std::vector& hand, + const std::vector& melds) { + for (const cards::Card& card : hand) { + for (std::size_t m = 0; m < melds.size(); ++m) { + std::vector grown = melds[m].cards; + grown.push_back(card); + if (rummy::arrangedMeld(grown).has_value()) return std::make_pair(card, static_cast(m)); + } + } + return std::nullopt; +} + +// The costliest card the hand may throw: never the one just taken from +// the discard, unless it is all that is left. +cards::Card DiscardFrom(const std::vector& hand, + const std::optional& taken) { + std::optional worst; + for (const cards::Card& card : hand) { + if (hand.size() > 1 && taken == card) continue; + if (!worst.has_value() || rummy::cardPoints(card) > rummy::cardPoints(*worst)) worst = card; + } + return *worst; +} + +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) { + auto dealer = MakeDealer(); + std::deque deck = dealer->DealNewUnshuffledDeck(); + dealer->ShuffleDeck(deck); + auto state = rummy::dealRummyGame(game_id, ids, std::move(deck)); + EXPECT_TRUE(state.ok()) << state.status(); + return *state; + } + + // The board, as one chair sees it, against the mirror: every public + // fact, the last move, and hand faces only for the viewer's own seat. + 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"); + if (ended) { + EXPECT_FALSE(seen.currentPlayerId.has_value()); + EXPECT_FALSE(seen.stage.has_value()); + EXPECT_FALSE(seen.canDrawStock); + } else { + 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()); + } + EXPECT_EQ(seen.stockCount, static_cast(mirror.getStock().size())); + EXPECT_EQ(seen.discardCount, static_cast(mirror.getDiscard().size())); + ASSERT_EQ(seen.discardTop.has_value(), !mirror.getDiscard().empty()); + 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 (seen.takenDiscard.has_value()) { + EXPECT_EQ(Face(*seen.takenDiscard), Face(Wire(*mirror.getTakenDiscard()))); + } + ASSERT_EQ(seen.melds.size(), mirror.getMelds().size()); + for (std::size_t m = 0; m < seen.melds.size(); ++m) { + EXPECT_EQ(seen.melds[m].owner, mirror.getMelds()[m].owner); + EXPECT_EQ(Faces(seen.melds[m].cards), Faces(mirror.getMelds()[m].cards)); + } + ASSERT_EQ(seen.lastMove.has_value(), mirror.getLastMove().has_value()); + if (seen.lastMove.has_value()) { + const rummy::LastMove& want = *mirror.getLastMove(); + EXPECT_EQ(seen.lastMove->playerId, want.playerId); + EXPECT_EQ(seen.lastMove->move, MoveName(want.kind)); + EXPECT_EQ(Faces(seen.lastMove->cards), Faces(want.cards)); + EXPECT_EQ(seen.lastMove->meldIndex.value_or(-1), want.meld); + } + ASSERT_EQ(seen.players.size(), mirror.getPlayers().size()); + for (std::size_t i = 0; i < seen.players.size(); ++i) { + const rummy::Player& want = mirror.getPlayer(static_cast(i)); + EXPECT_EQ(seen.players[i].playerId, want.id); + EXPECT_EQ(seen.players[i].handCount, static_cast(want.hand.size())); + EXPECT_EQ(Faces(seen.players[i].hand), + ended || want.id == viewer ? Faces(want.hand) : std::vector{}); + } + } + + // Every seat's next view, checked against the mirror. + void ExpectViews(const std::vector& seats, const rummy::GameState& mirror, + const std::string& after) { + for (Seat* seat : seats) { + auto update = ReceiveRummy(seat->stream, "gameState"); + ASSERT_TRUE(update.has_value()) << seat->player_id << " after " << after; + ExpectBoard(update->as_gameState_or_null()->view, seat->player_id, mirror); + } + } + + // What a game went through on its way to the end. + struct PlayedOut { + int moves = 0; + int discard_draws = 0; + int stock_draws = 0; + int melds = 0; + int lay_offs = 0; + // Lay-offs onto a meld another seat laid. + int foreign_lay_offs = 0; + }; + + // Plays the mirror's policy on every seat until someone goes out: the + // same command to the hub and the mirror, and every seat's view checked + // after each. Leaves each stream just before the final views. + void PlayToEnd(const std::vector& seats, std::optional& mirror, + PlayedOut& played) { + std::map by_id; + for (Seat* seat : seats) by_id[seat->player_id] = seat; + auto step = [&](Seat& mover, GameCommands command, absl::StatusOr next, + const std::string& what) { + ASSERT_TRUE(next.ok()) << what << ": " << next.status(); + const int turn_before = mirror->getWhoseTurn(); + mirror.emplace(*std::move(next)); + ++played.moves; + ASSERT_TRUE(mover.stream.Send(std::move(command)).ok()); + if (mirror->isOver()) return; + ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, what)); + if (mirror->getWhoseTurn() != turn_before) { + for (Seat* hearer : seats) { + auto turn = ReceiveRummy(hearer->stream, "turnChanged"); + ASSERT_TRUE(turn.has_value()) << "after " << what; + EXPECT_EQ(turn->as_turnChanged_or_null()->playerId, + mirror->getPlayer(mirror->getWhoseTurn()).id); + } + } + }; + while (!mirror->isOver()) { + ASSERT_LT(played.moves, 2000) << "the policy's game never ends"; + const int seat = mirror->getWhoseTurn(); + const std::string mover_id = mirror->getPlayer(seat).id; + Seat& mover = *by_id.at(mover_id); + + const std::vector& hand = mirror->getPlayer(seat).hand; + const bool wants_discard = + !mirror->getDiscard().empty() && Completes(hand, mirror->getDiscard().back()); + if (wants_discard || !mirror->canDrawStock()) { + ++played.discard_draws; + ASSERT_NO_FATAL_FAILURE( + step(mover, DrawDiscard(), mirror->drawDiscard(seat), "drawDiscard")); + } else { + ++played.stock_draws; + ASSERT_NO_FATAL_FAILURE(step(mover, DrawStock(), mirror->drawStock(seat), "drawStock")); + } + while (!mirror->isOver()) { + const std::vector held = mirror->getPlayer(seat).hand; + if (auto meld = MeldIn(held); meld.has_value()) { + ++played.melds; + ASSERT_NO_FATAL_FAILURE( + step(mover, MeldOf(Wire(*meld)), mirror->meld(seat, *meld), "meld")); + continue; + } + if (auto lay_off = LayOffIn(held, mirror->getMelds()); lay_off.has_value()) { + ++played.lay_offs; + if (mirror->getMelds().at(lay_off->second).owner != mover_id) ++played.foreign_lay_offs; + ASSERT_NO_FATAL_FAILURE(step(mover, LayOff(Wire(lay_off->first), lay_off->second), + mirror->layOff(seat, lay_off->first, lay_off->second), + "layOff")); + continue; + } + const cards::Card thrown = DiscardFrom(held, mirror->getTakenDiscard()); + ASSERT_NO_FATAL_FAILURE( + step(mover, Discard(Wire(thrown)), mirror->discard(seat, thrown), "discard")); + break; + } + } + } + + // 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) { + 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; + } + 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()); + for (const auto& player : room->as_roomState_or_null()->players) { + EXPECT_EQ(player.gamesPlayed, 1); + EXPECT_EQ(player.gamesWon, player.playerId == winner ? 1 : 0); + // 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. + EXPECT_EQ(player.totalScore, 0); + EXPECT_FALSE(player.table.has_value()); + } + } + } +}; + +// 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. +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})); + + // 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(drew.ok()); + mirror.emplace(*drew); + ASSERT_NO_FATAL_FAILURE(ExpectViews({&alice, &bob}, *mirror, "drawDiscard")); + + 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); + // 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♠"})); + 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); + EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "meld"}}), 2); + EXPECT_EQ(metrics_->CounterTotal("rummy_events", {{"event", "gameEnded"}}), 2); +} + +// A stock draw is private: the drawer sees the card, everyone else a +// count and a move that names nothing. +TEST_F(RummyGameFixture, AStockDrawShowsTheCardOnlyToItsDrawer) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + auto hers = ReceiveRummy(alice.stream, "gameState"); + auto his = ReceiveRummy(bob.stream, "gameState"); + 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); + ASSERT_TRUE(his_view.lastMove.has_value()); + EXPECT_EQ(his_view.lastMove->move, "drawStock"); + EXPECT_TRUE(his_view.lastMove->cards.empty()); + EXPECT_FALSE(his_view.lastMove->meldIndex.has_value()); + EXPECT_EQ(his_view.stage.value_or(""), "play"); + // Nobody's turn changed: a draw opens the rest of the same turn. + ExpectNoEvent(bob.stream); +} + +// The turn's order is the engine's, and its refusals are rules refusals, +// in band: nothing on the table moves. +TEST_F(RummyGameFixture, OutOfTurnOrOutOfOrderIsRefusedInBand) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + + ASSERT_TRUE(bob.stream.Send(DrawStock()).ok()); + auto refused = ReceiveCase(bob.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + 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()); + 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()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "draw a card first"); + + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + ASSERT_TRUE(alice.stream.Send(DrawDiscard()).ok()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + 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()); + 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()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "no such meld"); + + EXPECT_EQ(metrics_->CounterTotal("hub_rejections", {{"kind", "rules"}}), 6); + EXPECT_EQ(metrics_->CounterTotal("hub_rejections", {{"kind", "state"}}), 0); + ExpectNoEvent(bob.stream); +} + +// The point of naming cards (#1505): a card the hand does not hold is a +// client out of sync with its view, refused with the card it named; a +// spelling no card has is a client sending nonsense. Different spikes. +TEST_F(RummyGameFixture, ACardTheHandDoesNotHoldIsRefusedAsStaleAndNamed) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + ASSERT_TRUE(alice.stream.Send(DrawStock()).ok()); + 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()); + 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()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + 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()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + 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()); + 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(view.currentPlayerId.value_or(""), bob.player_id); + EXPECT_EQ(view.stage.value_or(""), "draw"); +} + +// A draw, a meld and a lay-off are all the same seat's turn: the table +// hears views and no turnChanged until the discard hands it on. +TEST_F(RummyGameFixture, ADrawAndAMeldLeaveTheTurnUnannounced) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveCase(bob.stream, "roomState").has_value()); + + 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(ReceiveRummy(bob.stream, "gameState").has_value()); + ExpectNoEvent(bob.stream); + 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(ReceiveRummy(bob.stream, "gameState").has_value()); + auto next = bob.stream.Receive(kReceiveBudget); + ASSERT_TRUE(next.ok() && next->has_value()); + ASSERT_NE((*next)->as_rummy_or_null(), nullptr); + EXPECT_EQ((*next)->as_rummy_or_null()->update.case_name(), std::string("turnChanged")); +} + +// Taken from the discard, a card is seen taken by the whole table, and +// it cannot go straight back. +TEST_F(RummyGameFixture, TheCardTakenFromTheDiscardIsPublicAndCannotGoStraightBack) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + + ASSERT_TRUE(alice.stream.Send(DrawDiscard()).ok()); + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + auto his = ReceiveRummy(bob.stream, "gameState"); + 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(view.lastMove->move, "drawDiscard"); + EXPECT_EQ(Faces(view.lastMove->cards), std::vector{"9♠"}); + EXPECT_FALSE(view.discardTop.has_value()); + EXPECT_EQ(view.discardCount, 0); + + ASSERT_TRUE(alice.stream.Send(Discard(Named("9", "♠"))).ok()); + auto refused = ReceiveCase(alice.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"); + ExpectNoEvent(bob.stream); +} + +// Whole games, played by the policy on both sides of the wire, at every +// table size: melds, lay-offs onto anyone's meld, discard draws, and the +// 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); + } +}; + +TEST_P(SeededRummyFixture, AWholeGameAgreesWithTheEngine) { + const int seats_at_table = GetParam(); + auto table = MultiSeatRummyTable(seats_at_table); + ASSERT_TRUE(table.has_value()); + std::vector seats; + for (Seat& seat : table->seats) seats.push_back(&seat); + std::optional mirror(MirrorDeal(table->game_id, table->ids())); + ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "the deal")); + + PlayedOut played; + ASSERT_NO_FATAL_FAILURE(PlayToEnd(seats, mirror, played)); + ASSERT_NO_FATAL_FAILURE(ExpectEnding(seats, *mirror)); + // The policy's game touched every move, so each was checked above. + EXPECT_GT(played.melds, 0); + EXPECT_GT(played.lay_offs, 0); + EXPECT_GT(played.foreign_lay_offs, 0); + EXPECT_GT(played.discard_draws, 0); + EXPECT_GT(played.stock_draws, 0); + EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "layOff"}}), played.lay_offs); + EXPECT_EQ(metrics_->CounterTotal("rummy_commands", {{"command", "meld"}}), played.melds); +} + +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 +// 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}; + std::optional mirror( + MirrorDeal(table->game_id, {table->alice.player_id, table->bob.player_id})); + ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "the deal")); + while (!mirror->getStock().empty()) { + const int seat = mirror->getWhoseTurn(); + Seat& mover = *seats.at(seat); + ASSERT_TRUE(mover.stream.Send(DrawStock()).ok()); + auto drew = mirror->drawStock(seat); + ASSERT_TRUE(drew.ok()); + mirror.emplace(*drew); + ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "drawStock")); + const cards::Card drawn = mirror->getPlayer(seat).hand.back(); + ASSERT_TRUE(mover.stream.Send(Discard(Wire(drawn))).ok()); + auto threw = mirror->discard(seat, drawn); + ASSERT_TRUE(threw.ok()) << threw.status(); + mirror.emplace(*threw); + ASSERT_NO_FATAL_FAILURE(ExpectViews(seats, *mirror, "discard")); + } + ASSERT_EQ(mirror->getDiscard().size(), 32u); + const cards::Card top = mirror->getDiscard().back(); + const cards::Card bottom = mirror->getDiscard().front(); + const int seat = mirror->getWhoseTurn(); + // Drawn cards went straight back, which a discard draw would not allow; + // a stock draw remembers nothing. + ASSERT_TRUE(seats.at(seat)->stream.Send(DrawStock()).ok()); + auto refilled = mirror->drawStock(seat); + ASSERT_TRUE(refilled.ok()); + mirror.emplace(*refilled); + EXPECT_EQ(mirror->getPlayer(seat).hand.back(), bottom); + for (Seat* hearer : seats) { + auto update = ReceiveRummy(hearer->stream, "gameState"); + 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.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. +TEST_F(RummyGameFixture, LeavingMidGameAbandonsItWithNoWinner) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + + ASSERT_TRUE( + alice.stream.Send(Rummy(RummyMove::FromLeavegame(moonbase::games::LeaveGame{}))).ok()); + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameLeft").has_value()); + auto final_view = ReceiveRummy(bob.stream, "gameState"); + ASSERT_TRUE(final_view.has_value()); + { + const RummyView& view = final_view->as_gameState_or_null()->view; + EXPECT_EQ(view.phase, "ended"); + EXPECT_FALSE(view.currentPlayerId.has_value()); + 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); + } + 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); + auto room = ReceiveCase(bob.stream, "roomState"); + ASSERT_TRUE(room.has_value()); + EXPECT_TRUE(room->as_roomState_or_null()->games.empty()); + for (const auto& player : room->as_roomState_or_null()->players) { + EXPECT_EQ(player.gamesPlayed, player.playerId == bob.player_id ? 1 : 0); + EXPECT_EQ(player.gamesWon, 0); + } +} + +// Three seats, the one on turn leaves mid-turn: the next seat draws, and +// the table hears the turn move. +TEST_F(RummyGameFixture, ALeaveOnTurnPassesTheDrawToTheNextSeat) { + auto table = MultiSeatRummyTable(3); + ASSERT_TRUE(table.has_value()); + std::vector seats; + 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()); + 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]}) { + 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); + 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); + } + ASSERT_TRUE(table->seats[1].stream.Send(DrawStock()).ok()); + ASSERT_TRUE(ReceiveRummy(table->seats[1].stream, "gameState").has_value()); +} + +// Tables of every game share a room; the room says which is which, and a +// move in one game's vocabulary is refused at another's table. +TEST_F(RummyGameFixture, TheRoomListsRummyTablesAndOtherGamesMovesAreRefused) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + auto& alice = table->alice; + auto& bob = table->bob; + ASSERT_TRUE(ReceiveRummy(alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(bob.stream, "gameState").has_value()); + auto room = AwaitRoomState( + alice.stream, + [&](const moonbase::games::RoomState& state) { + return state.games.size() == 1 && state.games[0].status == "playing"; + }, + "the room listing the started rummy table"); + ASSERT_TRUE(room.has_value()); + EXPECT_EQ(room->games[0].game, "rummy"); + EXPECT_EQ(room->games[0].playerCount, 2); + EXPECT_EQ(TableOf(*room, alice.player_id), AtTable("rummy", table->game_id)); + + ASSERT_TRUE(alice.stream.Send(Castle(CastleMove::FromPickup(moonbase::games::PickUp{}))).ok()); + auto refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "that table plays rummy"); + ASSERT_TRUE( + alice.stream.Send(Move(moonbase::games::GolfMove::FromKnock(moonbase::games::Knock{}))).ok()); + refused = ReceiveCase(alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "that table plays rummy"); + + // And the other way: a rummy move at a golf table, a rummy join of one. + auto golf_table = SeatedTable(); + ASSERT_TRUE(golf_table.has_value()); + ASSERT_TRUE(golf_table->alice.stream.Send(DrawStock()).ok()); + refused = ReceiveCase(golf_table->alice.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "that table plays golf"); + auto third = OpenSeat(); + ASSERT_TRUE(third.has_value()); + ASSERT_TRUE(ReceiveCase(third->stream, "sessionReady").has_value()); + moonbase::games::JoinRoom join_room; + join_room.roomId = golf_table->room_id; + ASSERT_TRUE(third->stream.Send(GameCommands::FromJoinroom(join_room)).ok()); + ASSERT_TRUE(ReceiveCase(third->stream, "roomState").has_value()); + moonbase::games::JoinGame join_game; + join_game.gameId = golf_table->game_id; + ASSERT_TRUE(third->stream.Send(Rummy(RummyMove::FromJoingame(join_game))).ok()); + refused = ReceiveCase(third->stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "that table plays golf"); +} + +// A browser close parks the seat; the resume token brings back the +// seat's own view in rummy's envelope, redacted for it, mid-turn. +TEST_F(RummyGameFixture, AResumedRummySeatGetsItsOwnViewBack) { + auto table = SeatedRummyTable(); + ASSERT_TRUE(table.has_value()); + ASSERT_TRUE(ReceiveRummy(table->alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(table->bob.stream, "gameState").has_value()); + ASSERT_TRUE(table->alice.stream.Send(DrawStock()).ok()); + ASSERT_TRUE(ReceiveRummy(table->alice.stream, "gameState").has_value()); + ASSERT_TRUE(ReceiveRummy(table->bob.stream, "gameState").has_value()); + + table->alice.stream.Close(); + ASSERT_TRUE(ReceiveCase(table->bob.stream, "roomState").has_value()); + auto resumed = OpenSeat(table->alice.resume_token); + ASSERT_TRUE(resumed.has_value()); + auto ready = ReceiveCase(resumed->stream, "sessionReady"); + ASSERT_TRUE(ready.has_value()); + EXPECT_TRUE(ready->as_sessionReady_or_null()->resumed); + auto joined = ReceiveRummy(resumed->stream, "gameJoined"); + ASSERT_TRUE(joined.has_value()); + const RummyView& view = joined->as_gameJoined_or_null()->view; + EXPECT_EQ(view.phase, "playing"); + 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); + // The turn is still hers to finish. + ASSERT_TRUE(resumed->stream.Send(Discard(Named("9", "♥"))).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); +} + +// Before the deal a table is a roster: no cards anywhere, no turn. +TEST_F(RummyGameFixture, AWaitingTableIsARosterWithNoCards) { + auto room = SeatedRoom(1); + ASSERT_TRUE(room.has_value()); + Seat& host = room->seats.front(); + ASSERT_TRUE( + host.stream.Send(Rummy(RummyMove::FromCreategame(moonbase::games::CreateGame{}))).ok()); + auto joined = ReceiveRummy(host.stream, "gameJoined"); + ASSERT_TRUE(joined.has_value()); + const RummyView& view = joined->as_gameJoined_or_null()->view; + EXPECT_EQ(view.phase, "waiting"); + EXPECT_FALSE(view.currentPlayerId.has_value()); + EXPECT_FALSE(view.stage.has_value()); + EXPECT_EQ(view.stockCount, 0); + EXPECT_FALSE(view.canDrawStock); + EXPECT_FALSE(view.discardTop.has_value()); + EXPECT_TRUE(view.melds.empty()); + ASSERT_EQ(view.players.size(), 1u); + EXPECT_EQ(view.players[0].playerId, host.player_id); + EXPECT_EQ(view.players[0].handCount, 0); + // One seat is no game. + ASSERT_TRUE(host.stream.Send(Rummy(RummyMove::FromStartgame(moonbase::games::StartGame{}))).ok()); + auto refused = ReceiveCase(host.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "need at least 2 players to start"); + ASSERT_TRUE(host.stream.Send(DrawStock()).ok()); + refused = ReceiveCase(host.stream, "commandRejected"); + ASSERT_TRUE(refused.has_value()); + EXPECT_EQ(refused->as_commandRejected_or_null()->reason, "game not started"); +} + +} // namespace +} // namespace games_hub diff --git a/domains/games/apis/games_hub/rummy_wire_test.cc b/domains/games/apis/games_hub/rummy_wire_test.cc new file mode 100644 index 000000000..77ecb801b --- /dev/null +++ b/domains/games/apis/games_hub/rummy_wire_test.cc @@ -0,0 +1,239 @@ +// Wire-contract goldens for rummy (#245) on the room stream, the way +// 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. +// +// 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. + +#include + +#include +#include +#include +#include + +#include "domains/games/apis/games_hub/wire_test_fixture.h" +#include "opal/http/message.h" + +namespace games_hub { +namespace { + +using json = nlohmann::json; + +constexpr char kPlayPath[] = "/games/v2/play"; + +class RummyWireTest : public HubWireFixture { + protected: + std::shared_ptr DialReady(json& session) { + 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) { + json creator_session; + creator = DialReady(creator_session); + EXPECT_TRUE(creator->Send(CommandFrame("createRoom", "{}")).ok()); + (void)EventPayload(NextFrame(*creator), "roomState"); + EXPECT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"createGame":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); // gameCreated + (void)EventPayload(NextFrame(*creator), "rummy"); // gameJoined + (void)EventPayload(NextFrame(*creator), "roomState"); + json joiner_session; + joiner = DialReady(joiner_session); + EXPECT_TRUE(joiner->Send(CommandFrame("joinRoom", R"({"roomId":"room-1"})")).ok()); + (void)EventPayload(NextFrame(*joiner), "roomState"); + (void)EventPayload(NextFrame(*joiner), "roomChatHistory"); + (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), "roomState"); + (void)EventPayload(NextFrame(*creator), "roomState"); + EXPECT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"startGame":{}}})")).ok()); + EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), R"({"update":{"gameStarted":{}}})"); + const std::string dealt = EventPayload(NextFrame(*creator), "rummy"); + (void)EventPayload(NextFrame(*creator), "roomState"); + (void)EventPayload(NextFrame(*joiner), "rummy"); // gameStarted + return dealt; + } +}; + +TEST_F(RummyWireTest, CreateAndJoinPinTheWaitingViewAndTheLobbysWord) { + json creator_session; + auto creator = DialReady(creator_session); + ASSERT_TRUE(creator->Send(CommandFrame("createRoom", "{}")).ok()); + (void)EventPayload(NextFrame(*creator), "roomState"); + + ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"createGame":{}}})")).ok()); + 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}}}})"); + 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,)" + R"("playerId":"player-1","table":{"game":"rummy","gameId":"GAME01"},)" + R"("totalScore":0}],"roomId":"room-1"})"); +} + +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}}}})"); + 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"})); + 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")); + + // The other chair: its own faces, the creator'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"][1]["hand"][0], (json{{"rank", "A"}, {"suit", "♥"}})); +} + +// 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) { + 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"); + + 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( + creator + ->Send(CommandFrame( + "rummy", + 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")); + EXPECT_EQ(melded["update"]["gameState"]["view"]["melds"].dump(), + R"([{"cards":[{"rank":"J","suit":"♠"},{"rank":"Q","suit":"♠"},)" + R"({"rank":"K","suit":"♠"}],"owner":"player-1"}])"); + 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"})"); + } + + // The diamonds go down as one run and her hand is empty: the final views + // show every hand, then the ending. + ASSERT_TRUE( + creator + ->Send(CommandFrame( + "rummy", + R"({"move":{"meld":{"cards":[{"rank":"A","suit":"♦"},{"rank":"K","suit":"♦"},)" + R"({"rank":"Q","suit":"♦"},{"rank":"J","suit":"♦"},{"rank":"10","suit":"♦"}]}}})")) + .ok()); + const json final_view = + 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"}}})"); +} + +// 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) { + std::shared_ptr creator; + std::shared_ptr joiner; + (void)DealtTable(creator, joiner); + (void)EventPayload(NextFrame(*joiner), "rummy"); + (void)EventPayload(NextFrame(*joiner), "roomState"); + + 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"}]}}})"); +} + +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"); + + ASSERT_TRUE(creator->Send(CommandFrame("rummy", R"({"move":{"drawStock":{}}})")).ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + const json drew = json::parse(EventPayload(NextFrame(*joiner), "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"})"); + + ASSERT_TRUE( + creator + ->Send(CommandFrame("rummy", R"({"move":{"discard":{"card":{"rank":"9","suit":"♥"}}}})")) + .ok()); + (void)EventPayload(NextFrame(*creator), "rummy"); + const json threw = json::parse(EventPayload(NextFrame(*joiner), "rummy")); + EXPECT_EQ(threw["update"]["gameState"]["view"]["lastMove"].dump(), + R"({"cards":[{"rank":"9","suit":"♥"}],"move":"discard","playerId":"player-1"})"); + EXPECT_EQ(threw["update"]["gameState"]["view"]["discardTop"].dump(), + R"({"rank":"9","suit":"♥"})"); + EXPECT_EQ(EventPayload(NextFrame(*creator), "rummy"), + R"({"update":{"turnChanged":{"playerId":"player-2"}}})"); +} + +} // namespace +} // namespace games_hub diff --git a/domains/games/apis/games_hub/stream_test_fixture.h b/domains/games/apis/games_hub/stream_test_fixture.h index 4c604f391..c2e21640f 100644 --- a/domains/games/apis/games_hub/stream_test_fixture.h +++ b/domains/games/apis/games_hub/stream_test_fixture.h @@ -197,6 +197,14 @@ inline std::optional ReceiveCastle( [](const moonbase::games::GameEvents& event) { return event.as_castle_or_null(); }, budget); } +inline std::optional ReceiveRummy( + moonbase::games::PlayClientStream& stream, const std::string& wanted, + std::chrono::milliseconds budget = kReceiveBudget) { + return ReceiveEnvelope( + stream, wanted, "rummy", + [](const moonbase::games::GameEvents& event) { return event.as_rummy_or_null(); }, budget); +} + inline std::optional ReceiveLobby( moonbase::games::PlayClientStream& stream, const std::string& wanted, std::chrono::milliseconds budget = kReceiveBudget) { @@ -294,6 +302,19 @@ std::optional AwaitCastleView( predicate, waiting_for, budget); } +template +std::optional AwaitRummyView( + moonbase::games::PlayClientStream& stream, Predicate&& predicate, + const std::string& waiting_for, std::chrono::milliseconds budget = kReceiveBudget) { + return AwaitMatching( + [&](std::chrono::milliseconds remaining) -> std::optional { + auto update = ReceiveRummy(stream, "gameState", remaining); + if (!update.has_value()) return std::nullopt; + return update->as_gameState_or_null()->view; + }, + predicate, waiting_for, budget); +} + // Effectively-unlimited stream budgets (#1240) for suites whose flows // send at test speed, not human speed. Every direct GolfHub // construction in a test should pass this unless the test is about the @@ -372,7 +393,7 @@ inline moonbase::games::GameCommands Lobby(moonbase::games::LobbyAction action) return moonbase::games::GameCommands::FromLobby(std::move(command)); } -// A card the way a castle move names one (#1505) — the wire's own +// A card the way a castle or rummy move names one (#1505) — the wire's own // spelling, spelled out here rather than borrowed from the hub. inline moonbase::games::Card Named(const std::string& rank, const std::string& suit) { moonbase::games::Card card; @@ -388,6 +409,13 @@ inline moonbase::games::GameCommands Castle(moonbase::games::CastleMove move) { return moonbase::games::GameCommands::FromCastle(std::move(command)); } +// And rummy's: a RummyMove in its envelope on the room stream. +inline moonbase::games::GameCommands Rummy(moonbase::games::RummyMove move) { + moonbase::games::RummyCommand command; + command.move = std::move(move); + return moonbase::games::GameCommands::FromRummy(std::move(command)); +} + // Captures every metric the hub records so tests can assert what is // counted — and, just as important, what never appears in a name or // label (room ids, player ids, message text; the model forbids them). @@ -617,12 +645,10 @@ class GamesHubStreamFixture : public testing::Test { return Table{std::move(alice), std::move(bob), room->room_id, game_id}; } - // A started castle table of `count` seats, the first its creator; each - // seat has heard gameStarted and still has its setup view to read. - // With the NoShuffleDealer seat 0 holds the aces face down, A♣ K♠ K♥ - // face up and K♦ K♣ Q♠ in hand; seat 1 the queens, jacks and J♣ 10♠ - // 10♥; the rest follow down the deck. - struct CastleTable { + // A started table of `count` seats, the first its creator, in the + // envelope `wrap` puts a move in and `receive` reads an update out of; + // each seat has heard gameStarted and still has its first view to read. + struct ManySeats { std::vector seats; std::string room_id; std::string game_id; @@ -632,39 +658,61 @@ class GamesHubStreamFixture : public testing::Test { return ids; } }; - std::optional MultiSeatCastleTable(int count) { - using moonbase::games::CastleMove; + template + std::optional MultiSeatTableOf(int count, Wrap wrap, Receive receive) { auto room = SeatedRoom(count); if (!room.has_value()) return std::nullopt; - CastleTable table{std::move(room->seats), room->room_id, ""}; + ManySeats table{std::move(room->seats), room->room_id, ""}; Seat& host = table.seats.front(); - if (!host.stream.Send(Castle(CastleMove::FromCreategame(moonbase::games::CreateGame{}))).ok()) { + if (!host.stream.Send(wrap(MoveUnion::FromCreategame(moonbase::games::CreateGame{}))).ok()) { return std::nullopt; } - auto created = ReceiveCastle(host.stream, "gameJoined"); + auto created = receive(host.stream, "gameJoined"); if (!created.has_value()) return std::nullopt; table.game_id = created->as_gameJoined_or_null()->view.gameId; moonbase::games::JoinGame join_game; join_game.gameId = table.game_id; for (std::size_t i = 1; i < table.seats.size(); ++i) { - if (!table.seats[i].stream.Send(Castle(CastleMove::FromJoingame(join_game))).ok()) { + if (!table.seats[i].stream.Send(wrap(MoveUnion::FromJoingame(join_game))).ok()) { return std::nullopt; } - if (!ReceiveCastle(table.seats[i].stream, "gameJoined").has_value()) return std::nullopt; + if (!receive(table.seats[i].stream, "gameJoined").has_value()) return std::nullopt; } - if (!host.stream.Send(Castle(CastleMove::FromStartgame(moonbase::games::StartGame{}))).ok()) { + if (!host.stream.Send(wrap(MoveUnion::FromStartgame(moonbase::games::StartGame{}))).ok()) { return std::nullopt; } for (Seat& seat : table.seats) { - if (!ReceiveCastle(seat.stream, "gameStarted").has_value()) return std::nullopt; + if (!receive(seat.stream, "gameStarted").has_value()) return std::nullopt; } return table; } - // The castle table's twin of SeatedTable: MultiSeatCastleTable(2) in + // A started castle table. With the NoShuffleDealer seat 0 holds the + // aces face down, A♣ K♠ K♥ face up and K♦ K♣ Q♠ in hand; seat 1 the + // queens, jacks and J♣ 10♠ 10♥; the rest follow down the deck. + std::optional MultiSeatCastleTable(int count) { + return MultiSeatTableOf( + count, Castle, [](moonbase::games::PlayClientStream& stream, const std::string& wanted) { + return ReceiveCastle(stream, wanted); + }); + } + + // 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( + count, Rummy, [](moonbase::games::PlayClientStream& stream, const std::string& wanted) { + return ReceiveRummy(stream, wanted); + }); + } + + // The castle and rummy tables' twins of SeatedTable: two seats in // alice-and-bob form. - std::optional SeatedCastleTable() { - auto table = MultiSeatCastleTable(2); + std::optional
SeatedCastleTable() { return AliceAndBob(MultiSeatCastleTable(2)); } + std::optional
SeatedRummyTable() { return AliceAndBob(MultiSeatRummyTable(2)); } + 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, table->game_id}; diff --git a/domains/games/libs/cards/README.md b/domains/games/libs/cards/README.md index 6ee864a2e..ad67547e0 100644 --- a/domains/games/libs/cards/README.md +++ b/domains/games/libs/cards/README.md @@ -10,6 +10,7 @@ This module provides a set of classes and utilities for building card games. - Top level module contains core components for building card games. - [golf](./golf) is a mostly complete implementation of the game Golf. - [castle](./castle) is the rules engine for Castle (Palace). +- [rummy](./rummy) is the rules engine for basic Rummy. ## Building diff --git a/domains/games/libs/cards/rummy/BUILD.bazel b/domains/games/libs/cards/rummy/BUILD.bazel new file mode 100644 index 000000000..b0877d11d --- /dev/null +++ b/domains/games/libs/cards/rummy/BUILD.bazel @@ -0,0 +1,79 @@ +load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + +# The rummy rules engine (#245): immutable GameState value type for the +# games hub. + +cc_library( + name = "meld", + srcs = ["meld.cc"], + hdrs = ["meld.h"], + visibility = ["//visibility:public"], + deps = ["//domains/games/libs/cards"], +) + +cc_library( + name = "game_state", + srcs = ["game_state.cc"], + hdrs = ["game_state.h"], + visibility = ["//visibility:public"], + deps = [ + ":meld", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_test( + name = "meld_test", + size = "small", + srcs = ["meld_test.cc"], + deps = [ + ":meld", + "//domains/games/libs/cards", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "game_state_test", + size = "small", + srcs = ["game_state_test.cc"], + deps = [ + ":game_state", + "//domains/games/libs/cards", + "//domains/games/libs/cards:dealer", + "@com_google_absl//absl/status", + "@googletest//:gtest_main", + ], +) + +# Versioned serde of the engine's full truth, the games table's column +# for a rummy row. Server-side only — redaction stays in the hub. +cc_library( + name = "game_state_serde", + srcs = ["game_state_serde.cc"], + hdrs = ["game_state_serde.h"], + visibility = ["//visibility:public"], + deps = [ + ":game_state", + "//domains/games/libs/cards", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@nlohmann_json//:json", + ], +) + +cc_test( + name = "game_state_serde_test", + size = "small", + srcs = ["game_state_serde_test.cc"], + deps = [ + ":game_state", + ":game_state_serde", + "//domains/games/libs/cards", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + ], +) diff --git a/domains/games/libs/cards/rummy/README.md b/domains/games/libs/cards/rummy/README.md new file mode 100644 index 000000000..912487f24 --- /dev/null +++ b/domains/games/libs/cards/rummy/README.md @@ -0,0 +1,24 @@ +# rummy + +The rules engine for basic Rummy (#245): an immutable `GameState` value +type in the style of [`../castle`](../castle), for the games hub. + +The rules 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 +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 +else still holds. What makes a meld is `arrangedMeld` in `meld.h`: three +or four of a rank, or three or more of a suit in sequence with the ace low +or high but never both. + +```bash +bazel test //domains/games/libs/cards/rummy/... +``` + +`game_state_serde.{h,cc}` is the versioned JSON the games hub stores a +rummy table in — the engine's full truth, stock and every hand included, +so it is server-side only; redaction stays in the hub. +`game_state_serde_test.cc` pins the schema. diff --git a/domains/games/libs/cards/rummy/game_state.cc b/domains/games/libs/cards/rummy/game_state.cc new file mode 100644 index 000000000..7e0eb2740 --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state.cc @@ -0,0 +1,269 @@ +#include "domains/games/libs/cards/rummy/game_state.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/meld.h" + +namespace rummy { +using absl::FailedPreconditionError; +using absl::InvalidArgumentError; +using absl::StatusOr; +using std::deque; +using std::vector; + +namespace { + +// The hand without these cards, or NotFound naming the first it lacks. +StatusOr> without(const vector& hand, const vector& cards) { + vector rest = hand; + for (const Card& card : cards) { + const auto at = std::find(rest.begin(), rest.end(), card); + if (at == rest.end()) return absl::NotFoundError("that card is not in your hand"); + rest.erase(at); + } + return rest; +} + +vector withHand(const vector& roster, int seat, vector hand) { + vector out = roster; + out.at(seat).hand = std::move(hand); + return out; +} + +} // namespace + +StatusOr dealRummyGame(const string& game_id, const vector& player_ids, + deque shuffled_deck) { + const int seats = static_cast(player_ids.size()); + if (seats < GameState::kMinPlayers || seats > GameState::kMaxPlayers) { + return InvalidArgumentError("2 to 4 players"); + } + const int handSize = GameState::handSizeFor(seats); + if (static_cast(shuffled_deck.size()) < seats * handSize + 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 (Player& player : players) { + player.hand.push_back(shuffled_deck.back()); + shuffled_deck.pop_back(); + } + } + vector discard{shuffled_deck.back()}; + shuffled_deck.pop_back(); + return GameState{std::move(shuffled_deck), + std::move(discard), + std::move(players), + {}, + 0, + Stage::Draw, + Phase::Playing, + std::nullopt, + game_id, + ""}; +} + +absl::Status GameState::ensureTurn(int player, Stage wanted) const { + if (player < 0 || player >= static_cast(players.size())) { + return InvalidArgumentError("no such player"); + } + if (isOver()) return FailedPreconditionError("game is over"); + if (whoseTurn != player) return FailedPreconditionError("not your turn"); + if (stage != wanted) { + return FailedPreconditionError(wanted == Stage::Draw ? "you have already drawn" + : "draw a card first"); + } + return absl::OkStatus(); +} + +bool GameState::canDrawStock() const { return !stock.empty() || discardPile.size() > 1; } + +StatusOr GameState::drawStock(int player) const { + if (auto turn = ensureTurn(player, Stage::Draw); !turn.ok()) return turn; + if (!canDrawStock()) return FailedPreconditionError("the stock is empty"); + deque newStock = stock; + vector newDiscard = discardPile; + if (newStock.empty()) { + // Turned over, all but the top: the pile's bottom card is the stock's + // new top. + newStock.assign(newDiscard.rbegin() + 1, newDiscard.rend()); + newDiscard.erase(newDiscard.begin(), newDiscard.end() - 1); + } + vector hand = players.at(player).hand; + hand.push_back(newStock.back()); + newStock.pop_back(); + return GameState{std::move(newStock), + std::move(newDiscard), + withHand(players, player, std::move(hand)), + melds, + player, + Stage::Play, + phase, + std::nullopt, + gameId, + versionId, + LastMove{players.at(player).id, MoveKind::DrawStock, {}, -1}}; +} + +StatusOr GameState::drawDiscard(int player) const { + if (auto turn = ensureTurn(player, Stage::Draw); !turn.ok()) return turn; + if (discardPile.empty()) return FailedPreconditionError("the discard pile is empty"); + const Card top = discardPile.back(); + vector newDiscard(discardPile.begin(), discardPile.end() - 1); + vector hand = players.at(player).hand; + hand.push_back(top); + return GameState{stock, + std::move(newDiscard), + withHand(players, player, std::move(hand)), + melds, + player, + Stage::Play, + phase, + top, + gameId, + versionId, + LastMove{players.at(player).id, MoveKind::DrawDiscard, {top}, -1}}; +} + +StatusOr GameState::meld(int player, const vector& cards) const { + if (auto turn = ensureTurn(player, Stage::Play); !turn.ok()) return turn; + auto rest = without(players.at(player).hand, cards); + if (!rest.ok()) return rest.status(); + auto arranged = arrangedMeld(cards); + if (!arranged.has_value()) return InvalidArgumentError("those cards are not a set or a run"); + vector table = melds; + table.push_back(Meld{players.at(player).id, *arranged}); + const int index = static_cast(table.size()) - 1; + return afterLaying(player, *std::move(rest), std::move(table), + LastMove{players.at(player).id, MoveKind::Meld, *arranged, index}); +} + +StatusOr GameState::layOff(int player, const Card& card, int meldIndex) const { + if (auto turn = ensureTurn(player, Stage::Play); !turn.ok()) return turn; + if (meldIndex < 0 || meldIndex >= static_cast(melds.size())) { + return InvalidArgumentError("no such meld"); + } + auto rest = without(players.at(player).hand, {card}); + if (!rest.ok()) return rest.status(); + vector grown = melds.at(meldIndex).cards; + grown.push_back(card); + auto arranged = arrangedMeld(grown); + if (!arranged.has_value()) return InvalidArgumentError("that card does not fit that meld"); + vector table = melds; + table.at(meldIndex).cards = *std::move(arranged); + return afterLaying(player, *std::move(rest), std::move(table), + LastMove{players.at(player).id, MoveKind::LayOff, {card}, meldIndex}); +} + +StatusOr GameState::discard(int player, const Card& card) const { + if (auto turn = ensureTurn(player, Stage::Play); !turn.ok()) return turn; + auto rest = without(players.at(player).hand, {card}); + if (!rest.ok()) return rest.status(); + if (takenDiscard == card && !rest->empty()) { + return FailedPreconditionError("you took that card from the discard pile this turn"); + } + vector newDiscard = discardPile; + newDiscard.push_back(card); + const bool out = rest->empty(); + const int seats = static_cast(players.size()); + return GameState{stock, + std::move(newDiscard), + withHand(players, player, *std::move(rest)), + melds, + out ? kNoTurn : (player + 1) % seats, + Stage::Draw, + out ? Phase::Over : phase, + std::nullopt, + gameId, + versionId, + LastMove{players.at(player).id, MoveKind::Discard, {card}, -1}}; +} + +GameState GameState::afterLaying(int player, vector hand, vector table, + LastMove move) const { + const bool out = hand.empty(); + return GameState{stock, + discardPile, + withHand(players, player, std::move(hand)), + std::move(table), + out ? kNoTurn : player, + Stage::Play, + out ? Phase::Over : phase, + takenDiscard, + gameId, + versionId, + std::move(move)}; +} + +StatusOr GameState::removePlayer(int player) const { + if (player < 0 || player >= static_cast(players.size())) { + return InvalidArgumentError("no such player"); + } + if (isOver()) return FailedPreconditionError("game is over"); + vector rest = players; + rest.erase(rest.begin() + player); + if (rest.size() < static_cast(kMinPlayers)) { + return GameState{stock, discardPile, std::move(rest), melds, + kNoTurn, stage, Phase::Abandoned, takenDiscard, + gameId, versionId, lastMove}; + } + int turn = whoseTurn; + Stage newStage = stage; + std::optional taken = takenDiscard; + if (whoseTurn == player) { + // The seat after the leaver's takes its place, from the draw; past the + // end of the table that is the first seat. + turn = player % static_cast(rest.size()); + newStage = Stage::Draw; + taken.reset(); + } else if (whoseTurn > player) { + turn--; + } + return GameState{stock, discardPile, std::move(rest), melds, turn, newStage, + phase, taken, gameId, versionId, lastMove}; +} + +std::optional GameState::winner() const { + if (phase != Phase::Over) return std::nullopt; + for (const Player& p : players) { + if (p.hand.empty()) return p.id; + } + return std::nullopt; +} + +int GameState::deadwood(int player) const { + int points = 0; + for (const Card& card : players.at(player).hand) points += cardPoints(card); + return points; +} + +int GameState::winnerPoints() const { + if (!winner().has_value()) return 0; + int points = 0; + for (size_t i = 0; i < players.size(); i++) points += deadwood(static_cast(i)); + return points; +} + +GameState GameState::withIdAndVersion(const string& game_id, const string& version_id) const { + return GameState{stock, discardPile, players, melds, whoseTurn, stage, + phase, takenDiscard, game_id, version_id, lastMove}; +} + +int GameState::playerIndex(const string& id) const { + for (size_t i = 0; i < players.size(); i++) { + if (players.at(i).id == id) return static_cast(i); + } + return -1; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/game_state.h b/domains/games/libs/cards/rummy/game_state.h new file mode 100644 index 000000000..7454a5834 --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state.h @@ -0,0 +1,182 @@ +#ifndef CPP_CARDS_RUMMY_GAME_STATE_H +#define CPP_CARDS_RUMMY_GAME_STATE_H + +#include +#include +#include +#include +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/card.h" + +namespace rummy { +using namespace cards; +using std::string; + +/// Rummy (#245), the basic game: be the first to get rid of every card +/// 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. Seat 0 moves first. +/// - 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 +/// there is no stock to draw from. +/// - Then any number of melds and lay-offs, in any order. A meld puts +/// down a set or a run from the hand (arrangedMeld); a lay-off adds +/// one card to any meld on the table, anyone's, when the meld stays a +/// meld. +/// - The turn ends with one card discarded. The card taken from the +/// discard pile this turn may not go straight back, unless it is the +/// last card in the hand. +/// - A seat that empties its hand — by a discard, a meld or a lay-off — +/// wins, and that ends the game. The winner scores what everyone else +/// still holds (cardPoints). +/// +/// Refusals are absl statuses: FailedPrecondition for the wrong turn or +/// the wrong part of it, InvalidArgument for cards that make no meld, and +/// NotFound for a card the hand does not hold — which is how the hub +/// tells a player acting on a view the table has moved past (#1505). +class GameState; + +enum class Phase { Playing, Over, Abandoned }; + +/// Where the seat on turn is within it: about to draw, or holding the +/// drawn card with melds, lay-offs and the discard to come. +enum class Stage { Draw, Play }; + +/// Cards on the table: laid in order (arrangedMeld) by `owner`, who may +/// since have left. A meld is never taken back; lay-offs only grow it. +struct Meld { + string owner; + std::vector cards; + bool operator==(const Meld& o) const { return owner == o.owner && cards == o.cards; } +}; + +enum class MoveKind { DrawStock, DrawDiscard, Meld, LayOff, Discard }; + +/// The table's most recent move, as everyone saw it. `cards` is what went +/// on or came off the table: nothing for a stock draw (nobody sees it), +/// the card taken for a discard draw, the meld's cards, the card laid +/// off, the card discarded. `meld` is the table meld a meld or lay-off +/// made or grew, -1 otherwise. The seat named may since have left. +struct LastMove { + string playerId; + MoveKind kind = MoveKind::DrawStock; + std::vector cards; + int meld = -1; + bool operator==(const LastMove& o) const { + return playerId == o.playerId && kind == o.kind && cards == o.cards && meld == o.meld; + } +}; + +/// One seat: who, and the hand in the order its cards arrived (a drawn +/// card goes on the end). Nothing outside the engine addresses a card by +/// its position; moves name cards. +struct Player { + string id; + std::vector hand; + bool operator==(const Player& o) const { return id == o.id && hand == o.hand; } +}; + +/// 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. +[[nodiscard]] absl::StatusOr dealRummyGame(const string& game_id, + const std::vector& player_ids, + std::deque shuffled_deck); + +class GameState { + public: + static constexpr int kMinPlayers = 2; + static constexpr int kMaxPlayers = 4; + /// whoseTurn once the game is over. + 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; } + + GameState(std::deque _stock, std::vector _discard, std::vector _players, + std::vector _melds, int _whoseTurn, Stage _stage, Phase _phase, + std::optional _takenDiscard, string _gameId, string _versionId, + std::optional _lastMove = std::nullopt) + : stock(std::move(_stock)), + discardPile(std::move(_discard)), + players(std::move(_players)), + melds(std::move(_melds)), + whoseTurn(_whoseTurn), + stage(_stage), + phase(_phase), + takenDiscard(std::move(_takenDiscard)), + lastMove(std::move(_lastMove)), + gameId(std::move(_gameId)), + versionId(std::move(_versionId)) {} + + // The draw. + [[nodiscard]] absl::StatusOr drawStock(int player) const; + [[nodiscard]] absl::StatusOr drawDiscard(int player) const; + // After it. + [[nodiscard]] absl::StatusOr meld(int player, const std::vector& cards) const; + [[nodiscard]] absl::StatusOr layOff(int player, const Card& card, int meldIndex) const; + [[nodiscard]] absl::StatusOr discard(int player, const Card& card) const; + + /// A seat abandoned mid-game: it leaves with its hand, indices compact, + /// and a turn it held passes to the next seat's draw. Its melds stay on + /// the table. Below two seats the game is over by abandonment and nobody + /// wins. + [[nodiscard]] absl::StatusOr removePlayer(int player) const; + + // Queries. + [[nodiscard]] bool isOver() const { return phase != Phase::Playing; } + [[nodiscard]] Phase getPhase() const { return phase; } + [[nodiscard]] Stage getStage() const { return stage; } + /// The seat that went out, once the game is over by play. + [[nodiscard]] std::optional winner() const; + /// What a seat's hand counts against it (cardPoints summed). + [[nodiscard]] int deadwood(int player) const; + /// The winner's score: everyone else's deadwood. Zero with no winner. + [[nodiscard]] int winnerPoints() const; + /// Whether the stock can be drawn from right now: it holds cards, or the + /// discard pile has cards under its top to turn over. + [[nodiscard]] bool canDrawStock() const; + + [[nodiscard]] GameState withIdAndVersion(const string& game_id, const string& version_id) const; + [[nodiscard]] const std::deque& getStock() const { return stock; } + [[nodiscard]] const std::vector& getDiscard() const { return discardPile; } + [[nodiscard]] const std::vector& getPlayers() const { return players; } + [[nodiscard]] const Player& getPlayer(int index) const { return players.at(index); } + [[nodiscard]] const std::vector& getMelds() const { return melds; } + [[nodiscard]] int playerIndex(const string& id) const; + [[nodiscard]] int getWhoseTurn() const { return whoseTurn; } + /// The card the seat on turn took from the discard pile this turn. + [[nodiscard]] const std::optional& getTakenDiscard() const { return takenDiscard; } + [[nodiscard]] const std::optional& getLastMove() const { return lastMove; } + [[nodiscard]] const string& getGameId() const { return gameId; } + [[nodiscard]] const string& getVersionId() const { return versionId; } + + private: + [[nodiscard]] absl::Status ensureTurn(int player, Stage wanted) const; + /// The state after the seat on turn put cards down, its hand now `hand`: + /// over if the hand is empty, else still its turn. + [[nodiscard]] GameState afterLaying(int player, std::vector hand, std::vector table, + LastMove move) const; + + const std::deque stock; // back is the top + const std::vector discardPile; // back is the top + const std::vector players; + const std::vector melds; + const int whoseTurn; + const Stage stage; + const Phase phase; + const std::optional takenDiscard; + const std::optional lastMove; + const string gameId; + const string versionId; +}; + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/game_state_serde.cc b/domains/games/libs/cards/rummy/game_state_serde.cc new file mode 100644 index 000000000..534f2f5bf --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state_serde.cc @@ -0,0 +1,300 @@ +#include "domains/games/libs/cards/rummy/game_state_serde.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" +#include "domains/games/libs/cards/card.h" + +namespace rummy { +namespace { + +using nlohmann::json; + +constexpr int kSchemaVersion = 1; + +template +json cardsToJson(const Cards& cards) { + json codes = json::array(); + for (const Card& card : cards) codes.push_back(card.intValue()); + return codes; +} + +// postgres jsonb rejects a NUL byte, so it gets the same treatment as +// invalid UTF-8: U+FFFD, and the write path survives any player id. +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(Phase phase) { + switch (phase) { + case Phase::Playing: + return "playing"; + case Phase::Over: + return "over"; + case Phase::Abandoned: + return "abandoned"; + } + return "playing"; +} + +const char* stageName(Stage stage) { return stage == Stage::Draw ? "draw" : "play"; } + +const char* kindName(MoveKind kind) { + switch (kind) { + case MoveKind::DrawStock: + return "drawStock"; + case MoveKind::DrawDiscard: + return "drawDiscard"; + case MoveKind::Meld: + return "meld"; + case MoveKind::LayOff: + return "layOff"; + case MoveKind::Discard: + return "discard"; + } + return "drawStock"; +} + +// Field readers that turn shape errors into invalid-argument statuses; +// no get<>() runs before an is_*() check of the same value, and integers +// range-check as int64 before narrowing. + +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(); +} + +absl::StatusOr readCard(const json& object, const char* key) { + auto code = readIntInRange(object, key, 0, 51); + if (!code.ok()) return code.status(); + return Card(*code); +} + +absl::StatusOr> readCardCodes(const json& object, const char* key) { + if (!object.contains(key) || !object[key].is_array()) { + return absl::InvalidArgumentError(absl::StrCat("expected array field '", key, "'")); + } + if (object[key].size() > 52) { + return absl::InvalidArgumentError(absl::StrCat("too many cards in '", key, "'")); + } + std::vector cards; + for (const json& code : object[key]) { + if (!code.is_number_integer() || code.get() < 0 || code.get() > 51) { + return absl::InvalidArgumentError(absl::StrCat("card code out of range in '", key, "'")); + } + cards.emplace_back(static_cast(code.get())); + } + return cards; +} + +absl::StatusOr readPhase(const json& object) { + auto name = readString(object, "phase"); + if (!name.ok()) return name.status(); + if (*name == "playing") return Phase::Playing; + if (*name == "over") return Phase::Over; + if (*name == "abandoned") return Phase::Abandoned; + return absl::InvalidArgumentError("unknown phase"); +} + +absl::StatusOr readStage(const json& object) { + auto name = readString(object, "stage"); + if (!name.ok()) return name.status(); + if (*name == "draw") return Stage::Draw; + if (*name == "play") return Stage::Play; + return absl::InvalidArgumentError("unknown stage"); +} + +absl::StatusOr readKind(const json& object) { + auto name = readString(object, "kind"); + if (!name.ok()) return name.status(); + for (MoveKind kind : {MoveKind::DrawStock, MoveKind::DrawDiscard, MoveKind::Meld, + MoveKind::LayOff, MoveKind::Discard}) { + if (*name == kindName(kind)) return kind; + } + return absl::InvalidArgumentError("unknown move kind"); +} + +absl::StatusOr> readObjects(const json& object, const char* key) { + if (!object.contains(key) || !object[key].is_array()) { + return absl::InvalidArgumentError(absl::StrCat("expected array field '", key, "'")); + } + std::vector entries; + for (const json& entry : object[key]) { + if (!entry.is_object()) { + return absl::InvalidArgumentError(absl::StrCat("expected objects in '", key, "'")); + } + entries.push_back(entry); + } + return entries; +} + +} // namespace + +std::string serializeGameState(const GameState& state) { + json players = json::array(); + for (const Player& player : state.getPlayers()) { + players.push_back(json{{"id", sanitized(player.id)}, {"hand", cardsToJson(player.hand)}}); + } + json melds = json::array(); + for (const Meld& meld : state.getMelds()) { + melds.push_back(json{{"owner", sanitized(meld.owner)}, {"cards", cardsToJson(meld.cards)}}); + } + json serialized{ + {"v", kSchemaVersion}, + {"stock", cardsToJson(state.getStock())}, + {"discard", cardsToJson(state.getDiscard())}, + {"whoseTurn", state.getWhoseTurn()}, + {"stage", stageName(state.getStage())}, + {"phase", phaseName(state.getPhase())}, + {"players", std::move(players)}, + {"melds", std::move(melds)}, + }; + if (const auto& taken = state.getTakenDiscard(); taken.has_value()) { + serialized["takenDiscard"] = taken->intValue(); + } + if (const auto& move = state.getLastMove(); move.has_value()) { + serialized["lastMove"] = json{ + {"player", sanitized(move->playerId)}, + {"kind", kindName(move->kind)}, + {"cards", cardsToJson(move->cards)}, + {"meld", move->meld}, + }; + } + return serialized.dump(/*indent=*/-1, /*indent_char=*/' ', /*ensure_ascii=*/false, + json::error_handler_t::replace); +} + +absl::StatusOr deserializeGameState(const std::string& serialized) { + const json parsed = json::parse(serialized, /*cb=*/nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) { + return absl::InvalidArgumentError("not a JSON object"); + } + auto version = readIntInRange(parsed, "v", kSchemaVersion, kSchemaVersion); + if (!version.ok()) { + return absl::InvalidArgumentError( + absl::StrCat("unknown schema version: ", version.status().message())); + } + auto stock = readCardCodes(parsed, "stock"); + if (!stock.ok()) return stock.status(); + auto discard = readCardCodes(parsed, "discard"); + if (!discard.ok()) return discard.status(); + auto phase = readPhase(parsed); + if (!phase.ok()) return phase.status(); + auto stage = readStage(parsed); + if (!stage.ok()) return stage.status(); + + auto player_entries = readObjects(parsed, "players"); + if (!player_entries.ok()) return player_entries.status(); + std::vector players; + for (const json& entry : *player_entries) { + auto id = readString(entry, "id"); + if (!id.ok()) return id.status(); + auto hand = readCardCodes(entry, "hand"); + if (!hand.ok()) return hand.status(); + players.push_back(Player{*std::move(id), *std::move(hand)}); + } + if (players.empty()) return absl::InvalidArgumentError("no players"); + + auto meld_entries = readObjects(parsed, "melds"); + if (!meld_entries.ok()) return meld_entries.status(); + std::vector melds; + for (const json& entry : *meld_entries) { + auto owner = readString(entry, "owner"); + if (!owner.ok()) return owner.status(); + auto cards = readCardCodes(entry, "cards"); + if (!cards.ok()) return cards.status(); + melds.push_back(Meld{*std::move(owner), *std::move(cards)}); + } + + // whoseTurn indexes the roster while play is on; kNoTurn is the end's. + // A playing row with no turn would wedge every seat on "not your turn", + // so it is a dropped row, not a table. + auto whose_turn = + readIntInRange(parsed, "whoseTurn", *phase == Phase::Playing ? 0 : GameState::kNoTurn, + static_cast(players.size()) - 1); + if (!whose_turn.ok()) return whose_turn.status(); + + std::optional taken; + if (parsed.contains("takenDiscard")) { + auto card = readCard(parsed, "takenDiscard"); + if (!card.ok()) return card.status(); + taken = *card; + } + + std::optional last_move; + if (parsed.contains("lastMove")) { + const json& move = parsed["lastMove"]; + if (!move.is_object()) return absl::InvalidArgumentError("expected object field 'lastMove'"); + auto player = readString(move, "player"); + if (!player.ok()) return player.status(); + auto kind = readKind(move); + if (!kind.ok()) return kind.status(); + auto cards = readCardCodes(move, "cards"); + if (!cards.ok()) return cards.status(); + auto meld = readIntInRange(move, "meld", -1, static_cast(melds.size()) - 1); + if (!meld.ok()) return meld.status(); + // The shape the engine writes for each kind: a stock draw names no + // card, a draw from the discard or a discard or a lay-off names one, a + // meld three or more; only a meld or a lay-off names a meld. + const bool on_meld = *kind == MoveKind::Meld || *kind == MoveKind::LayOff; + const std::size_t named = cards->size(); + const bool shaped = *kind == MoveKind::DrawStock ? named == 0 + : *kind == MoveKind::Meld ? named >= 3 + : named == 1; + if (!shaped || on_meld != (*meld >= 0)) { + return absl::InvalidArgumentError("lastMove does not have its kind's shape"); + } + last_move = LastMove{*std::move(player), *kind, *std::move(cards), *meld}; + } + + // A table in play has a move to make: a draw has somewhere to draw from, + // and a seat mid-turn has a card to put down. A row without one would + // hold every seat at "not your turn" until they all left. + if (*phase == Phase::Playing) { + const Player& on_turn = players.at(static_cast(*whose_turn)); + const bool stuck = + *stage == Stage::Draw ? stock->empty() && discard->empty() : on_turn.hand.empty(); + if (stuck) return absl::InvalidArgumentError("a playing row with no move to make"); + } + + return GameState{std::deque(stock->begin(), stock->end()), + *std::move(discard), + std::move(players), + std::move(melds), + *whose_turn, + *stage, + *phase, + taken, + /*_gameId=*/"", + /*_versionId=*/"", + std::move(last_move)}; +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/game_state_serde.h b/domains/games/libs/cards/rummy/game_state_serde.h new file mode 100644 index 000000000..7671dbfbd --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state_serde.h @@ -0,0 +1,47 @@ +#ifndef CPP_CARDS_RUMMY_GAME_STATE_SERDE_H +#define CPP_CARDS_RUMMY_GAME_STATE_SERDE_H + +#include + +#include "absl/status/statusor.h" +#include "domains/games/libs/cards/rummy/game_state.h" + +namespace rummy { + +/// Serde for the engine's full truth, the shape golf's and castle's set: +/// stock order and every hand included, so serialized states are +/// server-side only — per-viewer redaction stays in the hub. The wire is +/// versioned JSON; deserialize rejects unknown versions and any value the +/// engine would index out of range, because the bytes arrive from a +/// database row, not from code we trust. +/// +/// Schema v1 — engine truth only; gameId/versionId belong to the storage +/// row and come back empty (the store rehydrates via withIdAndVersion). +/// Cards are Card::intValue() codes (0..51); piles list bottom to top, so +/// the last entry is the top; hands keep their order; melds their table +/// order: +/// {"v":1, "stock":[int...], "discard":[int...], "whoseTurn":int, +/// "stage":"draw"|"play", "phase":"playing"|"over"|"abandoned", +/// "players":[{"id":str, "hand":[int...]}...], +/// "melds":[{"owner":str, "cards":[int...]}...], +/// "takenDiscard":int, +/// "lastMove":{"player":str, "kind":"drawStock"|"drawDiscard"|"meld"| +/// "layOff"|"discard", "cards":[int...], "meld":int}} +/// takenDiscard and lastMove are absent when there is none. lastMove has +/// its kind's shape (no card for drawStock, one for drawDiscard, discard +/// and layOff, three or more for meld; meld >= 0 exactly for meld and +/// layOff), and a playing row has a move to make: something to draw at the +/// draw stage, a card in the mover's hand at the play stage. +/// +/// Keys emit alphabetically (nlohmann's sorted-map default), so +/// re-serializing a deserialized state reproduces the bytes. Unknown +/// fields are ignored. Game legality stays the engine's business: a meld +/// that is no meld reads back as stored. A player id that is not valid +/// UTF-8, or carries a NUL byte (which postgres jsonb refuses), serializes +/// with U+FFFD replacement. +[[nodiscard]] std::string serializeGameState(const GameState& state); +[[nodiscard]] absl::StatusOr deserializeGameState(const std::string& serialized); + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/game_state_serde_test.cc b/domains/games/libs/cards/rummy/game_state_serde_test.cc new file mode 100644 index 000000000..d3eaef8fa --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state_serde_test.cc @@ -0,0 +1,344 @@ +#include "domains/games/libs/cards/rummy/game_state_serde.h" + +#include + +#include +#include +#include +#include + +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/rummy/game_state.h" + +using namespace cards; +using namespace rummy; +using nlohmann::json; + +namespace { + +// Card(i) inverts intValue(), so 0..51 is the full deck in a fixed order, +// dealt from the back: the same determinism the hub's NoShuffleDealer +// relies on. +std::deque pristineDeck() { + std::deque deck; + for (int i = 0; i < 52; ++i) deck.emplace_back(i); + return deck; +} + +GameState dealt() { + const auto state = dealRummyGame("g", {"a", "b"}, pristineDeck()); + EXPECT_TRUE(state.ok()) << state.status(); + return *state; +} + +// A known-good serialized game as an editable payload; negative tests +// corrupt exactly the field they name and nothing else. +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♠ + 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}}); + EXPECT_TRUE(melded.ok()) << melded.status(); + return *melded; +} + +json midTurnPayload() { return json::parse(serializeGameState(midTurn())); } + +void expectRejected(const json& payload) { + const auto restored = deserializeGameState(payload.dump()); + ASSERT_FALSE(restored.ok()) << "accepted: " << payload.dump(); + EXPECT_EQ(restored.status().code(), absl::StatusCode::kInvalidArgument); +} + +// Engine truth only: identity is the storage row's business, so the +// serde returns it empty and the comparison excludes it. +void expectStatesEqual(const GameState& a, const GameState& b) { + EXPECT_EQ(a.getStock(), b.getStock()); + EXPECT_EQ(a.getDiscard(), b.getDiscard()); + EXPECT_EQ(a.getPlayers(), b.getPlayers()); + EXPECT_EQ(a.getMelds(), b.getMelds()); + EXPECT_EQ(a.getWhoseTurn(), b.getWhoseTurn()); + EXPECT_EQ(a.getStage(), b.getStage()); + EXPECT_EQ(a.getPhase(), b.getPhase()); + EXPECT_EQ(a.getTakenDiscard(), b.getTakenDiscard()); + EXPECT_EQ(a.getLastMove(), b.getLastMove()); +} + +// Serialize -> deserialize -> compare, and serialize again: the second +// pass must be byte-identical, so stored states re-serialize stably. +void expectRoundTrips(const GameState& state) { + const std::string serialized = serializeGameState(state); + const auto restored = deserializeGameState(serialized); + ASSERT_TRUE(restored.ok()) << restored.status(); + expectStatesEqual(state, *restored); + EXPECT_EQ(restored->getGameId(), ""); + EXPECT_EQ(restored->getVersionId(), ""); + EXPECT_EQ(serializeGameState(*restored), serialized); +} + +TEST(RummySerde, ADealtGameRoundTrips) { expectRoundTrips(dealt()); } + +// Every move kind, both stages and every ending, reached by play. +TEST(RummySerde, EveryMoveAndPhaseRoundTrips) { + auto stock = dealt().drawStock(0); + ASSERT_TRUE(stock.ok()); + expectRoundTrips(*stock); + auto discarded = stock->discard(0, Card{Suit::Spades, Rank::Ace}); + ASSERT_TRUE(discarded.ok()) << discarded.status(); + expectRoundTrips(*discarded); + + const GameState mid = midTurn(); + expectRoundTrips(mid); + auto laid = mid.layOff(0, Card{Suit::Spades, Rank::Jack}, 0); + ASSERT_TRUE(laid.ok()) << laid.status(); + expectRoundTrips(*laid); + + auto abandoned = mid.removePlayer(1); + ASSERT_TRUE(abandoned.ok()); + 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}}); + ASSERT_TRUE(over.ok()) << over.status(); + ASSERT_EQ(over->getPhase(), Phase::Over); + expectRoundTrips(*over); +} + +// The exact bytes a fresh two-seat deal stores. A change to the shape +// 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":[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); +} + +// The bytes of a row mid-turn: the optional fields and the other stage's +// and move's spellings, which a rename would orphan. +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})"; + EXPECT_EQ(serializeGameState(midTurn()), kRow); + for (const auto& [kind, want] : + std::vector>{{"drawStock", MoveKind::DrawStock}, + {"drawDiscard", MoveKind::DrawDiscard}, + {"layOff", MoveKind::LayOff}, + {"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"]["meld"] = want == MoveKind::LayOff ? 0 : -1; + const auto restored = deserializeGameState(payload.dump()); + ASSERT_TRUE(restored.ok()) << kind; + EXPECT_EQ(restored->getLastMove()->kind, want); + } + for (const char* phase : {"over", "abandoned"}) { + json payload = json::parse(kRow); + payload["phase"] = phase; + payload["whoseTurn"] = -1; + const auto ended = deserializeGameState(payload.dump()); + ASSERT_TRUE(ended.ok()) << phase; + EXPECT_TRUE(ended->isOver()); + } +} + +TEST(RummySerde, RejectsWhatTheEngineWouldIndexOutOfRange) { + json payload = dealtPayload(); + payload["v"] = 2; + expectRejected(payload); + + payload = dealtPayload(); + payload["whoseTurn"] = 2; // two seats: 0..1 in play + expectRejected(payload); + // A playing row must name a seat; no turn is the end's. + payload = dealtPayload(); + payload["whoseTurn"] = -1; + expectRejected(payload); + payload["phase"] = "over"; + EXPECT_TRUE(deserializeGameState(payload.dump()).ok()); + payload["whoseTurn"] = -2; + expectRejected(payload); + + // Integers read as int64 before narrowing: 2^32+1 must not wrap to 1. + payload = dealtPayload(); + payload["whoseTurn"] = 4294967297; + expectRejected(payload); + payload = dealtPayload(); + payload["players"][0]["hand"][0] = 4294967297; + expectRejected(payload); + + for (const char* key : {"id", "hand"}) { + payload = dealtPayload(); + payload["players"][0].erase(key); + expectRejected(payload); + payload = dealtPayload(); + payload["players"][0][key] = 7; + expectRejected(payload); + } + for (const char* key : {"stock", "discard", "melds", "players", "stage", "phase"}) { + payload = dealtPayload(); + payload.erase(key); + expectRejected(payload); + } + + payload = dealtPayload(); + payload["stock"][0] = 52; + expectRejected(payload); + payload = dealtPayload(); + payload["discard"][0] = -1; + expectRejected(payload); + payload = dealtPayload(); + payload["phase"] = "dealing"; + expectRejected(payload); + payload = dealtPayload(); + payload["stage"] = "discard"; + expectRejected(payload); + payload = dealtPayload(); + payload["players"] = json::array(); + expectRejected(payload); + + payload = midTurnPayload(); + payload["melds"][0].erase("owner"); + expectRejected(payload); + payload = midTurnPayload(); + payload["melds"][0]["cards"][0] = 99; + expectRejected(payload); + payload = midTurnPayload(); + payload["takenDiscard"] = 52; + expectRejected(payload); + payload = midTurnPayload(); + payload["takenDiscard"] = "9S"; + expectRejected(payload); + + // A last move names a player, a kind it knows, its cards, and a meld + // that is on the table or -1. + payload = midTurnPayload(); + payload["lastMove"] = 7; + expectRejected(payload); + payload = midTurnPayload(); + payload["lastMove"]["kind"] = "knock"; + expectRejected(payload); + payload = midTurnPayload(); + payload["lastMove"]["meld"] = 1; + expectRejected(payload); + payload = midTurnPayload(); + payload["lastMove"]["meld"] = -2; + expectRejected(payload); + payload = midTurnPayload(); + payload["lastMove"].erase("player"); + expectRejected(payload); + payload = midTurnPayload(); + payload["lastMove"]["cards"][0] = 52; + expectRejected(payload); + + for (const char* input : {"", "[]", "not json", R"({"v":1})", R"({"v":"1"})"}) { + EXPECT_FALSE(deserializeGameState(input).ok()) << input; + } +} + +// A last move's cards and meld follow from its kind — the shape the +// engine writes. Anything else would be sent to every chair as it stands: +// a stock draw naming a card would show the table a card nobody saw. +TEST(RummySerde, ALastMoveMustHaveItsKindsShape) { + auto with_move = [](const char* kind, json cards, int meld) { + json payload = midTurnPayload(); + payload["lastMove"] = json{{"player", "a"}, {"kind", kind}, {"cards", cards}, {"meld", meld}}; + return payload; + }; + expectRejected(with_move("drawStock", json::array({3}), -1)); + expectRejected(with_move("drawStock", json::array(), 0)); + expectRejected(with_move("drawDiscard", json::array(), -1)); + expectRejected(with_move("drawDiscard", json::array({3, 4}), -1)); + expectRejected(with_move("discard", json::array({3}), 0)); + expectRejected(with_move("meld", json::array({43, 47, 51}), -1)); + expectRejected(with_move("meld", json::array({43, 47}), 0)); + expectRejected(with_move("layOff", json::array({3}), -1)); + expectRejected(with_move("layOff", json::array({3, 4}), 0)); + // The twins, each the shape the engine writes. + for (const auto& payload : + {with_move("drawStock", json::array(), -1), with_move("drawDiscard", json::array({3}), -1), + with_move("discard", json::array({3}), -1), with_move("meld", json::array({43, 47, 51}), 0), + with_move("layOff", json::array({3}), 0)}) { + EXPECT_TRUE(deserializeGameState(payload.dump()).ok()) << payload.dump(); + } +} + +// A table in play must have a move to make: a draw with nothing to draw +// from, or a play stage with no card to put down, would hold every seat +// at "not your turn" until they all left. Such a row is dropped instead. +TEST(RummySerde, APlayingRowWithNoMoveToMakeIsRejected) { + json payload = dealtPayload(); + payload["stock"] = json::array(); + payload["discard"] = json::array(); + expectRejected(payload); + // Its twin: the empty stock with a discard to take is a table. + payload["discard"] = json::array({31}); + EXPECT_TRUE(deserializeGameState(payload.dump()).ok()); + + payload = midTurnPayload(); + payload["players"][0]["hand"] = json::array(); + expectRejected(payload); + // Over, an empty hand is the winner's. + payload["phase"] = "over"; + payload["whoseTurn"] = -1; + EXPECT_TRUE(deserializeGameState(payload.dump()).ok()); +} + +TEST(RummySerde, UnknownFieldsAreIgnored) { + json payload = dealtPayload(); + payload["future"] = "field"; + const auto restored = deserializeGameState(payload.dump()); + ASSERT_TRUE(restored.ok()) << restored.status(); + expectStatesEqual(dealt(), *restored); +} + +// A NUL in a player id would fail the postgres write; it is replaced, +// not fatal — wherever the id is written. +TEST(RummySerde, NulInAPlayerIdIsReplaced) { + const std::string nul_id("a\0b", 3); + auto state = dealRummyGame("g", {nul_id, "b"}, pristineDeck()); + ASSERT_TRUE(state.ok()); + auto drew = state->drawDiscard(0); + ASSERT_TRUE(drew.ok()); + auto melded = drew->meld(0, {Card{Suit::Spades, Rank::Ace}, Card{Suit::Spades, Rank::King}, + Card{Suit::Spades, Rank::Queen}}); + ASSERT_TRUE(melded.ok()); + const std::string serialized = serializeGameState(*melded); + EXPECT_EQ(serialized.find('\0'), std::string::npos); + const json parsed = json::parse(serialized); + const std::string replaced = + "a\xEF\xBF\xBD" + "b"; + EXPECT_EQ(parsed["players"][0]["id"], replaced); + EXPECT_EQ(parsed["melds"][0]["owner"], replaced); + EXPECT_EQ(parsed["lastMove"]["player"], replaced); + ASSERT_TRUE(deserializeGameState(serialized).ok()); +} + +} // namespace diff --git a/domains/games/libs/cards/rummy/game_state_test.cc b/domains/games/libs/cards/rummy/game_state_test.cc new file mode 100644 index 000000000..89b470196 --- /dev/null +++ b/domains/games/libs/cards/rummy/game_state_test.cc @@ -0,0 +1,511 @@ +#include "domains/games/libs/cards/rummy/game_state.h" + +#include + +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "domains/games/libs/cards/card.h" +#include "domains/games/libs/cards/dealer.h" + +using namespace cards; +using namespace rummy; +using std::deque; +using std::string; +using std::vector; + +namespace { + +Card c(Rank rank, Suit suit = Suit::Clubs) { return Card{suit, rank}; } + +/// A game in play: seats as given, piles' backs on top, seat `turn` to +/// move at `stage`. +GameState playing(vector players, deque stock, vector discard, int turn = 0, + Stage stage = Stage::Draw, vector melds = {}, + std::optional taken = std::nullopt) { + return GameState{std::move(stock), + std::move(discard), + std::move(players), + std::move(melds), + turn, + stage, + Phase::Playing, + std::move(taken), + "game", + "v0"}; +} + +// Two seats, alice to play (she has drawn): a run and a set to go down, +// and odd cards to discard. +GameState alicePlaying() { + return playing( + {{"alice", + {c(Rank::Four), c(Rank::Five), c(Rank::Six), c(Rank::Nine, Suit::Hearts), + c(Rank::Nine, Suit::Spades), c(Rank::Nine, Suit::Diamonds), c(Rank::King, Suit::Hearts)}}, + {"bob", {c(Rank::Two, Suit::Hearts), c(Rank::Jack, Suit::Spades)}}}, + {c(Rank::Three, Suit::Spades)}, {c(Rank::Queen, Suit::Diamonds)}, 0, Stage::Play); +} + +} // namespace + +// --- The deal --- + +TEST(Deal, TwoSeatsGetTenOneCardIsTurnedUpAndTheRestIsStock) { + NoShuffleDealer dealer; + auto game = dealRummyGame("g1", {"a", "b"}, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(game.ok()) << game.status(); + EXPECT_EQ(game->getPhase(), Phase::Playing); + EXPECT_EQ(game->getStage(), Stage::Draw); + 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->getDiscard().size(), 1u); + EXPECT_EQ(game->getStock().size(), 52u - 20u - 1u); + EXPECT_TRUE(game->getMelds().empty()); + EXPECT_FALSE(game->getLastMove().has_value()); + EXPECT_FALSE(game->getTakenDiscard().has_value()); +} + +TEST(Deal, DealsOneCardASeatAroundTheTableFromTheBack) { + // The unshuffled deck's back is A♠ A♥ A♦ A♣ K♠ ...: seat 0 takes every + // other card from it, and the card after the hands is turned up. + NoShuffleDealer dealer; + auto game = dealRummyGame("g1", {"a", "b"}, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(game.ok()); + const vector& a = game->getPlayer(0).hand; + const vector& b = game->getPlayer(1).hand; + EXPECT_EQ(a.at(0), c(Rank::Ace, Suit::Spades)); + 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)); +} + +TEST(Deal, ThreeOrFourSeatsGetSeven) { + for (int seats : {3, 4}) { + NoShuffleDealer dealer; + vector ids; + for (int i = 0; i < seats; i++) ids.push_back(string(1, static_cast('a' + i))); + auto game = dealRummyGame("g1", ids, dealer.DealNewUnshuffledDeck()); + ASSERT_TRUE(game.ok()); + for (const Player& p : game->getPlayers()) EXPECT_EQ(p.hand.size(), 7u) << seats; + EXPECT_EQ(game->getStock().size(), static_cast(52 - 7 * seats - 1)); + } +} + +TEST(Deal, TakesTwoToFourSeats) { + NoShuffleDealer dealer; + EXPECT_FALSE(dealRummyGame("g", {"a"}, dealer.DealNewUnshuffledDeck()).ok()); + EXPECT_FALSE(dealRummyGame("g", {"a", "b", "c", "d", "e"}, dealer.DealNewUnshuffledDeck()).ok()); +} + +TEST(Deal, RefusesADeckTooSmallForTheHandsAndTheTurnedCard) { + deque deck; + for (int i = 0; i < 20; i++) deck.emplace_back(i); + EXPECT_FALSE(dealRummyGame("g", {"a", "b"}, deck).ok()); + deck.emplace_back(20); + EXPECT_TRUE(dealRummyGame("g", {"a", "b"}, deck).ok()); +} + +// --- The draw --- + +TEST(Draw, FromTheStockTakesItsTopAndOpensThePlay) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, + {c(Rank::Four), c(Rank::Five)}, {c(Rank::Six)}); + auto next = game.drawStock(0); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getPlayer(0).hand, (vector{c(Rank::Two), c(Rank::Five)})); + EXPECT_EQ(next->getStock(), (deque{c(Rank::Four)})); + EXPECT_EQ(next->getStage(), Stage::Play); + EXPECT_EQ(next->getWhoseTurn(), 0); + EXPECT_FALSE(next->getTakenDiscard().has_value()); + // Nobody else sees the card: the move names none. + ASSERT_TRUE(next->getLastMove().has_value()); + EXPECT_EQ(next->getLastMove()->kind, MoveKind::DrawStock); + EXPECT_EQ(next->getLastMove()->playerId, "alice"); + EXPECT_TRUE(next->getLastMove()->cards.empty()); +} + +TEST(Draw, FromTheDiscardTakesItsTopAndRemembersIt) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {c(Rank::Four)}, + {c(Rank::Five), c(Rank::Six)}); + auto next = game.drawDiscard(0); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getPlayer(0).hand, (vector{c(Rank::Two), c(Rank::Six)})); + EXPECT_EQ(next->getDiscard(), (vector{c(Rank::Five)})); + EXPECT_EQ(next->getTakenDiscard(), c(Rank::Six)); + EXPECT_EQ(next->getStage(), Stage::Play); + EXPECT_EQ(next->getLastMove()->kind, MoveKind::DrawDiscard); + EXPECT_EQ(next->getLastMove()->cards, vector{c(Rank::Six)}); +} + +TEST(Draw, OnlyOnePerTurn) { + auto drawn = alicePlaying(); + EXPECT_EQ(drawn.drawStock(0).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(drawn.drawDiscard(0).status().code(), absl::StatusCode::kFailedPrecondition); +} + +TEST(Draw, OnlyOnYourTurn) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {c(Rank::Four)}, + {c(Rank::Five)}); + EXPECT_EQ(game.drawStock(1).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(game.drawDiscard(1).status().code(), absl::StatusCode::kFailedPrecondition); + EXPECT_EQ(game.drawStock(2).status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(Draw, AnEmptyStockIsTheDiscardPileTurnedOverUnderItsTop) { + // Discard bottom to top: 4 5 6 7. Turned over, 4 is the stock's top, + // and 7 stays face up. + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {}, + {c(Rank::Four), c(Rank::Five), c(Rank::Six), c(Rank::Seven)}); + EXPECT_TRUE(game.canDrawStock()); + auto next = game.drawStock(0); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getPlayer(0).hand, (vector{c(Rank::Two), c(Rank::Four)})); + EXPECT_EQ(next->getStock(), (deque{c(Rank::Six), c(Rank::Five)})); + EXPECT_EQ(next->getDiscard(), vector{c(Rank::Seven)}); +} + +TEST(Draw, WithNothingUnderTheDiscardTopThereIsNoStock) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {}, {c(Rank::Seven)}); + EXPECT_FALSE(game.canDrawStock()); + EXPECT_EQ(game.drawStock(0).status().code(), absl::StatusCode::kFailedPrecondition); + // The discard is still there to take. + EXPECT_TRUE(game.drawDiscard(0).ok()); +} + +TEST(Draw, AnEmptyDiscardPileHasNothingToTake) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {c(Rank::Four)}, {}); + EXPECT_EQ(game.drawDiscard(0).status().code(), absl::StatusCode::kFailedPrecondition); +} + +// --- Melds --- + +TEST(Meld, NotBeforeTheDraw) { + auto game = playing({{"alice", {c(Rank::Four), c(Rank::Five), c(Rank::Six), c(Rank::Ten)}}, + {"bob", {c(Rank::Three)}}}, + {c(Rank::Two)}, {c(Rank::Nine)}); + EXPECT_EQ(game.meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Six)}).status().code(), + absl::StatusCode::kFailedPrecondition); +} + +TEST(Meld, PutsTheCardsOnTheTableInOrderAndTheTurnStays) { + auto next = alicePlaying().meld(0, {c(Rank::Six), c(Rank::Four), c(Rank::Five)}); + ASSERT_TRUE(next.ok()) << next.status(); + ASSERT_EQ(next->getMelds().size(), 1u); + EXPECT_EQ(next->getMelds()[0].owner, "alice"); + EXPECT_EQ(next->getMelds()[0].cards, (vector{c(Rank::Four), c(Rank::Five), c(Rank::Six)})); + EXPECT_EQ(next->getPlayer(0).hand, + (vector{c(Rank::Nine, Suit::Hearts), c(Rank::Nine, Suit::Spades), + c(Rank::Nine, Suit::Diamonds), c(Rank::King, Suit::Hearts)})); + EXPECT_EQ(next->getWhoseTurn(), 0); + EXPECT_EQ(next->getStage(), Stage::Play); + EXPECT_EQ(next->getLastMove()->kind, MoveKind::Meld); + EXPECT_EQ(next->getLastMove()->meld, 0); + EXPECT_EQ(next->getLastMove()->cards, next->getMelds()[0].cards); +} + +TEST(Meld, SeveralInOneTurn) { + auto one = alicePlaying().meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Six)}); + ASSERT_TRUE(one.ok()); + auto two = one->meld( + 0, {c(Rank::Nine, Suit::Hearts), c(Rank::Nine, Suit::Spades), c(Rank::Nine, Suit::Diamonds)}); + ASSERT_TRUE(two.ok()) << two.status(); + EXPECT_EQ(two->getMelds().size(), 2u); + EXPECT_EQ(two->getLastMove()->meld, 1); + EXPECT_EQ(two->getPlayer(0).hand, vector{c(Rank::King, Suit::Hearts)}); +} + +TEST(Meld, CardsThatMakeNoMeldAreRefusedAndNothingMoves) { + auto game = alicePlaying(); + auto refused = game.meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::King, Suit::Hearts)}); + EXPECT_EQ(refused.status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(Meld, ACardTheHandDoesNotHoldIsNotFound) { + auto refused = alicePlaying().meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Three)}); + EXPECT_EQ(refused.status().code(), absl::StatusCode::kNotFound); +} + +TEST(Meld, OnlyOnYourTurn) { + auto game = alicePlaying(); + EXPECT_EQ(game.meld(1, {c(Rank::Two, Suit::Hearts)}).status().code(), + absl::StatusCode::kFailedPrecondition); +} + +// --- Lay-offs --- + +TEST(LayOff, GrowsAMeldAnyoneLaid) { + auto game = playing({{"alice", {c(Rank::Seven), c(Rank::King)}}, {"bob", {c(Rank::Three)}}}, {}, + {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + auto next = game.layOff(0, c(Rank::Seven), 0); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getMelds()[0].owner, "bob"); + EXPECT_EQ(next->getMelds()[0].cards, + (vector{c(Rank::Four), c(Rank::Five), c(Rank::Six), c(Rank::Seven)})); + EXPECT_EQ(next->getPlayer(0).hand, vector{c(Rank::King)}); + EXPECT_EQ(next->getLastMove()->kind, MoveKind::LayOff); + EXPECT_EQ(next->getLastMove()->meld, 0); + EXPECT_EQ(next->getLastMove()->cards, vector{c(Rank::Seven)}); +} + +TEST(LayOff, AtTheLowEndOfARunOrOntoASet) { + auto game = playing( + {{"alice", {c(Rank::Three), c(Rank::Nine, Suit::Spades), c(Rank::King)}}, + {"bob", {c(Rank::Three, Suit::Hearts)}}}, + {}, {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}, + {"bob", + {c(Rank::Nine, Suit::Clubs), c(Rank::Nine, Suit::Diamonds), c(Rank::Nine, Suit::Hearts)}}}); + auto low = game.layOff(0, c(Rank::Three), 0); + ASSERT_TRUE(low.ok()) << low.status(); + EXPECT_EQ(low->getMelds()[0].cards.front(), c(Rank::Three)); + auto set = low->layOff(0, c(Rank::Nine, Suit::Spades), 1); + ASSERT_TRUE(set.ok()) << set.status(); + EXPECT_EQ(set->getMelds()[1].cards.size(), 4u); +} + +TEST(LayOff, ACardThatBreaksTheMeldIsRefused) { + auto game = playing({{"alice", {c(Rank::Eight), c(Rank::King)}}, {"bob", {c(Rank::Three)}}}, {}, + {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + EXPECT_EQ(game.layOff(0, c(Rank::Eight), 0).status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(LayOff, NamesAMeldOnTheTable) { + auto game = playing({{"alice", {c(Rank::Seven), c(Rank::King)}}, {"bob", {c(Rank::Three)}}}, {}, + {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + EXPECT_EQ(game.layOff(0, c(Rank::Seven), 1).status().code(), absl::StatusCode::kInvalidArgument); + EXPECT_EQ(game.layOff(0, c(Rank::Seven), -1).status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(LayOff, ACardTheHandDoesNotHoldIsNotFound) { + auto game = playing({{"alice", {c(Rank::Seven), c(Rank::King)}}, {"bob", {c(Rank::Three)}}}, {}, + {c(Rank::Two)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + EXPECT_EQ(game.layOff(0, c(Rank::Three), 0).status().code(), absl::StatusCode::kNotFound); +} + +TEST(LayOff, NotBeforeTheDraw) { + auto game = playing({{"alice", {c(Rank::Seven), c(Rank::King)}}, {"bob", {c(Rank::Three)}}}, + {c(Rank::Two)}, {c(Rank::Two, Suit::Hearts)}, 0, Stage::Draw, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + EXPECT_EQ(game.layOff(0, c(Rank::Seven), 0).status().code(), + absl::StatusCode::kFailedPrecondition); +} + +// --- The discard --- + +TEST(Discard, EndsTheTurnAtTheNextSeatsDraw) { + auto next = alicePlaying().discard(0, c(Rank::King, Suit::Hearts)); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getDiscard().back(), c(Rank::King, Suit::Hearts)); + EXPECT_EQ(next->getWhoseTurn(), 1); + EXPECT_EQ(next->getStage(), Stage::Draw); + EXPECT_EQ(next->getPlayer(0).hand.size(), 6u); + EXPECT_EQ(next->getLastMove()->kind, MoveKind::Discard); + EXPECT_EQ(next->getLastMove()->cards, vector{c(Rank::King, Suit::Hearts)}); +} + +TEST(Discard, TheTurnWrapsAroundTheTable) { + auto game = playing( + {{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}, {"c", {c(Rank::Four), c(Rank::Five)}}}, + {c(Rank::Six)}, {c(Rank::Seven)}, 2, Stage::Play); + auto next = game.discard(2, c(Rank::Four)); + ASSERT_TRUE(next.ok()); + EXPECT_EQ(next->getWhoseTurn(), 0); +} + +TEST(Discard, NotBeforeTheDraw) { + auto game = playing({{"alice", {c(Rank::Two), c(Rank::Three)}}, {"bob", {c(Rank::Three)}}}, + {c(Rank::Four)}, {c(Rank::Five)}); + EXPECT_EQ(game.discard(0, c(Rank::Two)).status().code(), absl::StatusCode::kFailedPrecondition); +} + +TEST(Discard, ACardTheHandDoesNotHoldIsNotFound) { + EXPECT_EQ(alicePlaying().discard(0, c(Rank::Ace)).status().code(), absl::StatusCode::kNotFound); +} + +TEST(Discard, TheTakenCardStaysBarredAfterAMeld) { + // The bar is the turn's, not the draw's: melding in between lifts it + // only if the taken card is then all the hand holds. + auto game = playing({{"alice", {c(Rank::Four), c(Rank::Five), c(Rank::Six), c(Rank::Two)}}, + {"bob", {c(Rank::Three)}}}, + {c(Rank::Ace)}, {c(Rank::King)}); + auto drew = game.drawDiscard(0); + ASSERT_TRUE(drew.ok()); + auto melded = drew->meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Six)}); + ASSERT_TRUE(melded.ok()); + EXPECT_EQ(melded->getTakenDiscard(), c(Rank::King)); + EXPECT_EQ(melded->discard(0, c(Rank::King)).status().code(), + absl::StatusCode::kFailedPrecondition); + EXPECT_TRUE(melded->discard(0, c(Rank::Two)).ok()); +} + +TEST(Discard, TheCardTakenFromTheDiscardCannotGoStraightBack) { + auto game = playing({{"alice", {c(Rank::Two), c(Rank::Three)}}, {"bob", {c(Rank::Three)}}}, + {c(Rank::Four)}, {c(Rank::Five)}); + auto drew = game.drawDiscard(0); + ASSERT_TRUE(drew.ok()); + EXPECT_EQ(drew->discard(0, c(Rank::Five)).status().code(), absl::StatusCode::kFailedPrecondition); + auto other = drew->discard(0, c(Rank::Two)); + ASSERT_TRUE(other.ok()) << other.status(); + // The next turn is its own: nothing is remembered past the discard. + EXPECT_FALSE(other->getTakenDiscard().has_value()); +} + +TEST(Discard, TheTakenCardMayGoBackWhenItIsTheLastInTheHand) { + // Everything else went down: holding nothing else, the seat may throw + // it and go out rather than be stuck. + auto game = + playing({{"alice", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}, {"bob", {c(Rank::Three)}}}, + {c(Rank::Two)}, {c(Rank::King)}); + auto drew = game.drawDiscard(0); + ASSERT_TRUE(drew.ok()); + auto melded = drew->meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Six)}); + ASSERT_TRUE(melded.ok()); + auto out = melded->discard(0, c(Rank::King)); + ASSERT_TRUE(out.ok()) << out.status(); + EXPECT_EQ(out->winner(), "alice"); +} + +TEST(Discard, AStockDrawRemembersNothing) { + auto game = playing({{"alice", {c(Rank::Two)}}, {"bob", {c(Rank::Three)}}}, {c(Rank::Five)}, + {c(Rank::Five, Suit::Hearts)}); + auto drew = game.drawStock(0); + ASSERT_TRUE(drew.ok()); + EXPECT_TRUE(drew->discard(0, c(Rank::Five)).ok()); +} + +// --- Going out --- + +TEST(GoingOut, AnEmptyHandAfterTheDiscardWinsAndScoresTheRestsHands) { + auto game = playing({{"alice", {c(Rank::King)}}, + {"bob", {c(Rank::Ace), c(Rank::Seven), c(Rank::Queen)}}, + {"carol", {c(Rank::Two, Suit::Hearts)}}}, + {c(Rank::Three)}, {c(Rank::Four)}, 0, Stage::Play); + auto out = game.discard(0, c(Rank::King)); + ASSERT_TRUE(out.ok()) << out.status(); + EXPECT_EQ(out->getPhase(), Phase::Over); + EXPECT_TRUE(out->isOver()); + EXPECT_EQ(out->getWhoseTurn(), GameState::kNoTurn); + EXPECT_EQ(out->winner(), "alice"); + EXPECT_EQ(out->deadwood(0), 0); + EXPECT_EQ(out->deadwood(1), 1 + 7 + 10); + EXPECT_EQ(out->deadwood(2), 2); + EXPECT_EQ(out->winnerPoints(), 20); +} + +TEST(GoingOut, ByMeldingEverythingNeedsNoDiscard) { + auto game = + playing({{"alice", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}, {"bob", {c(Rank::King)}}}, + {c(Rank::Two)}, {c(Rank::Nine)}, 0, Stage::Play); + auto out = game.meld(0, {c(Rank::Four), c(Rank::Five), c(Rank::Six)}); + ASSERT_TRUE(out.ok()) << out.status(); + EXPECT_EQ(out->getPhase(), Phase::Over); + EXPECT_EQ(out->winner(), "alice"); + EXPECT_EQ(out->winnerPoints(), 10); +} + +TEST(GoingOut, ByLayingOffTheLastCard) { + auto game = playing({{"alice", {c(Rank::Seven)}}, {"bob", {c(Rank::King)}}}, {c(Rank::Two)}, + {c(Rank::Nine)}, 0, Stage::Play, + {{"bob", {c(Rank::Four), c(Rank::Five), c(Rank::Six)}}}); + auto out = game.layOff(0, c(Rank::Seven), 0); + ASSERT_TRUE(out.ok()) << out.status(); + EXPECT_EQ(out->winner(), "alice"); +} + +TEST(GoingOut, NothingMovesOnceTheGameIsOver) { + auto game = playing({{"alice", {c(Rank::King)}}, {"bob", {c(Rank::Ace), c(Rank::Two)}}}, + {c(Rank::Three)}, {c(Rank::Four)}, 0, Stage::Play); + auto out = game.discard(0, c(Rank::King)); + ASSERT_TRUE(out.ok()); + for (int seat : {0, 1}) { + EXPECT_FALSE(out->drawStock(seat).ok()); + EXPECT_FALSE(out->drawDiscard(seat).ok()); + EXPECT_FALSE(out->discard(seat, c(Rank::Ace)).ok()); + EXPECT_FALSE(out->meld(seat, {c(Rank::Ace)}).ok()); + } + EXPECT_FALSE(out->removePlayer(1).ok()); +} + +TEST(GoingOut, NoWinnerWhileThePlayGoesOn) { + EXPECT_FALSE(alicePlaying().winner().has_value()); + EXPECT_EQ(alicePlaying().winnerPoints(), 0); +} + +// --- Leaving --- + +TEST(Leave, OffTurnTheSeatGoesAndTheTurnFollowsItsHolder) { + auto game = playing({{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}, {"c", {c(Rank::Four)}}}, + {c(Rank::Five)}, {c(Rank::Six)}, 2, Stage::Play); + auto next = game.removePlayer(0); + ASSERT_TRUE(next.ok()) << next.status(); + ASSERT_EQ(next->getPlayers().size(), 2u); + EXPECT_EQ(next->getPlayer(next->getWhoseTurn()).id, "c"); + EXPECT_EQ(next->getStage(), Stage::Play); + EXPECT_EQ(next->getPhase(), Phase::Playing); +} + +TEST(Leave, OnTurnTheNextSeatDraws) { + auto game = playing({{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}, {"c", {c(Rank::Four)}}}, + {c(Rank::Five)}, {c(Rank::Six)}, 1, Stage::Play, {}, c(Rank::Seven)); + auto next = game.removePlayer(1); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getPlayer(next->getWhoseTurn()).id, "c"); + EXPECT_EQ(next->getStage(), Stage::Draw); + EXPECT_FALSE(next->getTakenDiscard().has_value()); +} + +TEST(Leave, OnTurnInTheLastSeatWrapsToTheFirst) { + auto game = playing({{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}, {"c", {c(Rank::Four)}}}, + {c(Rank::Five)}, {c(Rank::Six)}, 2, Stage::Draw); + auto next = game.removePlayer(2); + ASSERT_TRUE(next.ok()); + EXPECT_EQ(next->getPlayer(next->getWhoseTurn()).id, "a"); +} + +TEST(Leave, TheLeaversMeldsStayOnTheTable) { + auto game = playing({{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}, {"c", {c(Rank::Four)}}}, + {c(Rank::Five)}, {c(Rank::Six)}, 0, Stage::Draw, + {{"b", {c(Rank::Nine), c(Rank::Ten), c(Rank::Jack)}}}); + auto next = game.removePlayer(1); + ASSERT_TRUE(next.ok()); + EXPECT_EQ(next->getMelds(), game.getMelds()); +} + +TEST(Leave, BelowTwoSeatsTheGameIsAbandonedWithNoWinner) { + auto game = + playing({{"a", {c(Rank::Two)}}, {"b", {c(Rank::Three)}}}, {c(Rank::Five)}, {c(Rank::Six)}); + auto next = game.removePlayer(0); + ASSERT_TRUE(next.ok()) << next.status(); + EXPECT_EQ(next->getPhase(), Phase::Abandoned); + EXPECT_TRUE(next->isOver()); + EXPECT_FALSE(next->winner().has_value()); + EXPECT_EQ(next->winnerPoints(), 0); + EXPECT_EQ(next->getWhoseTurn(), GameState::kNoTurn); +} + +TEST(Leave, NamesASeat) { + EXPECT_EQ(alicePlaying().removePlayer(5).status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(Queries, PlayerIndexByIdAndIdAndVersionAreTheRowsToSet) { + auto game = alicePlaying(); + EXPECT_EQ(game.playerIndex("bob"), 1); + EXPECT_EQ(game.playerIndex("zed"), -1); + auto stamped = game.withIdAndVersion("G", "V"); + EXPECT_EQ(stamped.getGameId(), "G"); + EXPECT_EQ(stamped.getVersionId(), "V"); + EXPECT_EQ(stamped.getPlayers(), game.getPlayers()); +} diff --git a/domains/games/libs/cards/rummy/meld.cc b/domains/games/libs/cards/rummy/meld.cc new file mode 100644 index 000000000..db2f5e5d9 --- /dev/null +++ b/domains/games/libs/cards/rummy/meld.cc @@ -0,0 +1,77 @@ +#include "domains/games/libs/cards/rummy/meld.h" + +#include +#include +#include + +#include "domains/games/libs/cards/card.h" + +namespace rummy { +namespace { + +// The ace's rank value below the two: the enum puts it above the king. +constexpr int kLowAce = static_cast(Rank::Two) - 1; + +int rankValue(const Card& card, bool aceLow) { + return aceLow && card.getRank() == Rank::Ace ? kLowAce : static_cast(card.getRank()); +} + +bool allDistinct(const std::vector& cards) { + for (std::size_t i = 0; i < cards.size(); i++) { + for (std::size_t j = i + 1; j < cards.size(); j++) { + if (cards[i] == cards[j]) return false; + } + } + return true; +} + +// The cards low to high with the ace at the given end, if that is a run. +std::optional> asRun(std::vector cards, bool aceLow) { + std::sort(cards.begin(), cards.end(), [aceLow](const Card& a, const Card& b) { + return rankValue(a, aceLow) < rankValue(b, aceLow); + }); + for (std::size_t i = 1; i < cards.size(); i++) { + if (rankValue(cards[i], aceLow) != rankValue(cards[i - 1], aceLow) + 1) return std::nullopt; + } + return cards; +} + +} // namespace + +std::optional> arrangedMeld(const std::vector& cards) { + if (cards.size() < 3 || !allDistinct(cards)) return std::nullopt; + const Card& first = cards.front(); + + const bool oneRank = std::all_of(cards.begin(), cards.end(), [&](const Card& card) { + return card.getRank() == first.getRank(); + }); + if (oneRank) { + // Distinct cards of one rank: at most the four suits. + std::vector set = cards; + std::sort(set.begin(), set.end(), + [](const Card& a, const Card& b) { return a.getSuit() < b.getSuit(); }); + return set; + } + + const bool oneSuit = std::all_of(cards.begin(), cards.end(), [&](const Card& card) { + return card.getSuit() == first.getSuit(); + }); + if (!oneSuit) return std::nullopt; + if (auto high = asRun(cards, /*aceLow=*/false); high.has_value()) return high; + return asRun(cards, /*aceLow=*/true); +} + +int cardPoints(const Card& card) { + switch (card.getRank()) { + case Rank::Ace: + return 1; + case Rank::Jack: + case Rank::Queen: + case Rank::King: + return 10; + default: + return static_cast(card.getRank()) + 2; + } +} + +} // namespace rummy diff --git a/domains/games/libs/cards/rummy/meld.h b/domains/games/libs/cards/rummy/meld.h new file mode 100644 index 000000000..300cb5ab5 --- /dev/null +++ b/domains/games/libs/cards/rummy/meld.h @@ -0,0 +1,26 @@ +#ifndef CPP_CARDS_RUMMY_MELD_H +#define CPP_CARDS_RUMMY_MELD_H + +#include +#include + +#include "domains/games/libs/cards/card.h" + +namespace rummy { +using namespace cards; + +/// The cards as they lie on the table if they make a meld, else nothing. +/// +/// A set is three or four cards of one rank, laid in suit order. A run is +/// three or more cards of one suit in consecutive ranks, laid low to high; +/// the ace sits below the two or above the king, never both, so a run +/// does not turn the corner (K-A-2 is no run). No card may repeat. +[[nodiscard]] std::optional> arrangedMeld(const std::vector& cards); + +/// What a card left in hand costs at the end: an ace one, two through ten +/// their pips, a face card ten. +[[nodiscard]] int cardPoints(const Card& card); + +} // namespace rummy + +#endif diff --git a/domains/games/libs/cards/rummy/meld_test.cc b/domains/games/libs/cards/rummy/meld_test.cc new file mode 100644 index 000000000..05d01cf3d --- /dev/null +++ b/domains/games/libs/cards/rummy/meld_test.cc @@ -0,0 +1,98 @@ +#include "domains/games/libs/cards/rummy/meld.h" + +#include + +#include + +#include "domains/games/libs/cards/card.h" + +using namespace cards; +using namespace rummy; +using std::vector; + +namespace { + +Card c(Rank rank, Suit suit = Suit::Clubs) { return Card{suit, rank}; } + +} // namespace + +TEST(Meld, ThreeOfARankIsASetLaidInSuitOrder) { + auto meld = arrangedMeld( + {c(Rank::Seven, Suit::Spades), c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Hearts)}); + ASSERT_TRUE(meld.has_value()); + EXPECT_EQ(*meld, (vector{c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Hearts), + c(Rank::Seven, Suit::Spades)})); +} + +TEST(Meld, FourOfARankIsASet) { + EXPECT_TRUE(arrangedMeld({c(Rank::King, Suit::Clubs), c(Rank::King, Suit::Diamonds), + c(Rank::King, Suit::Hearts), c(Rank::King, Suit::Spades)}) + .has_value()); +} + +TEST(Meld, TwoCardsAreNoMeldOfEitherKind) { + EXPECT_FALSE(arrangedMeld({c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Hearts)})); + EXPECT_FALSE(arrangedMeld({c(Rank::Seven), c(Rank::Eight)})); + EXPECT_FALSE(arrangedMeld({})); +} + +TEST(Meld, ARepeatedCardIsNoMeld) { + // One deck never deals a card twice, so a meld naming one twice is a + // client error rather than three of a kind. + EXPECT_FALSE(arrangedMeld({c(Rank::Seven), c(Rank::Seven), c(Rank::Seven, Suit::Hearts)})); + EXPECT_FALSE(arrangedMeld({c(Rank::Four), c(Rank::Five), c(Rank::Five)})); +} + +TEST(Meld, ConsecutiveRanksOfOneSuitAreARunLaidLowToHigh) { + auto meld = arrangedMeld({c(Rank::Nine), c(Rank::Seven), c(Rank::Eight), c(Rank::Ten)}); + ASSERT_TRUE(meld.has_value()); + EXPECT_EQ(*meld, (vector{c(Rank::Seven), c(Rank::Eight), c(Rank::Nine), c(Rank::Ten)})); +} + +TEST(Meld, ARunWithAGapOrTwoSuitsIsNoMeld) { + EXPECT_FALSE(arrangedMeld({c(Rank::Seven), c(Rank::Eight), c(Rank::Ten)})); + EXPECT_FALSE(arrangedMeld({c(Rank::Seven), c(Rank::Eight), c(Rank::Nine, Suit::Hearts)})); +} + +TEST(Meld, MixedRanksAndSuitsAreNeitherSetNorRun) { + EXPECT_FALSE(arrangedMeld( + {c(Rank::Seven, Suit::Clubs), c(Rank::Seven, Suit::Hearts), c(Rank::Eight, Suit::Hearts)})); +} + +TEST(Meld, TheAceRunsLowUnderTheTwo) { + auto meld = arrangedMeld({c(Rank::Three), c(Rank::Ace), c(Rank::Two)}); + ASSERT_TRUE(meld.has_value()); + EXPECT_EQ(*meld, (vector{c(Rank::Ace), c(Rank::Two), c(Rank::Three)})); +} + +TEST(Meld, TheAceRunsHighOverTheKing) { + auto meld = arrangedMeld({c(Rank::Ace), c(Rank::King), c(Rank::Queen)}); + ASSERT_TRUE(meld.has_value()); + EXPECT_EQ(*meld, (vector{c(Rank::Queen), c(Rank::King), c(Rank::Ace)})); +} + +TEST(Meld, ARunDoesNotTurnTheCorner) { + EXPECT_FALSE(arrangedMeld({c(Rank::King), c(Rank::Ace), c(Rank::Two)})); + EXPECT_FALSE(arrangedMeld({c(Rank::Queen), c(Rank::King), c(Rank::Ace), c(Rank::Two)})); +} + +TEST(Meld, AWholeSuitIsOneRun) { + vector suit; + for (int r = 0; r <= static_cast(Rank::Ace); r++) suit.push_back(c(static_cast(r))); + auto meld = arrangedMeld(suit); + ASSERT_TRUE(meld.has_value()); + // Thirteen ranks and one ace, which fits at either end: the run reads + // in rank order, the ace on top. + ASSERT_EQ(meld->size(), 13u); + EXPECT_EQ(meld->front(), c(Rank::Two)); + EXPECT_EQ(meld->back(), c(Rank::Ace)); +} + +TEST(CardPoints, AceOnePipsAsPrintedFacesTen) { + EXPECT_EQ(cardPoints(c(Rank::Ace)), 1); + EXPECT_EQ(cardPoints(c(Rank::Two)), 2); + EXPECT_EQ(cardPoints(c(Rank::Ten)), 10); + EXPECT_EQ(cardPoints(c(Rank::Jack)), 10); + EXPECT_EQ(cardPoints(c(Rank::Queen)), 10); + EXPECT_EQ(cardPoints(c(Rank::King)), 10); +} diff --git a/domains/platform/apis/prom_proxy/registry.go b/domains/platform/apis/prom_proxy/registry.go index 4dae9e3bd..8ad765ec7 100644 --- a/domains/platform/apis/prom_proxy/registry.go +++ b/domains/platform/apis/prom_proxy/registry.go @@ -360,7 +360,7 @@ var serviceRegistry = map[string]serviceEntry{ counter("Chat", "history_replays", "", `chat_history_replays_total`), counter("Chat", "failures", "", `chat_failures_total`), // Each tenant's envelope on the one stream (#1490): golf, castle - // (#77), and the lobby's world. Series and labels carry the game + // (#77), rummy (#245), and the lobby's world. Series and labels carry the game // prefix because a label is a tile's identity across the whole // service, not just its group; chat_* and hub_* are the room // layer's. @@ -368,6 +368,8 @@ var serviceRegistry = map[string]serviceEntry{ counter("Golf", "golf_events", "", `golf_events_total`), counter("Castle", "castle_commands", "", `castle_commands_total`), counter("Castle", "castle_events", "", `castle_events_total`), + counter("Rummy", "rummy_commands", "", `rummy_commands_total`), + counter("Rummy", "rummy_events", "", `rummy_events_total`), counter("Lobby", "lobby_commands", "", `lobby_commands_total`), counter("Lobby", "lobby_events", "", `lobby_events_total`), }, @@ -400,6 +402,8 @@ var serviceRegistry = map[string]serviceEntry{ "golf_event": tsCounter(`golf_events_total`), "castle_command": tsCounter(`castle_commands_total`), "castle_event": tsCounter(`castle_events_total`), + "rummy_command": tsCounter(`rummy_commands_total`), + "rummy_event": tsCounter(`rummy_events_total`), "lobby_command": tsCounter(`lobby_commands_total`), "lobby_event": tsCounter(`lobby_events_total`), }, diff --git a/domains/platform/apis/prom_proxy/registry_test.go b/domains/platform/apis/prom_proxy/registry_test.go index ef9f8a7b3..f397315cb 100644 --- a/domains/platform/apis/prom_proxy/registry_test.go +++ b/domains/platform/apis/prom_proxy/registry_test.go @@ -93,7 +93,7 @@ func TestRegistry_CustomTimeseriesPanelKeysAreUniquePerService(t *testing.T) { } // A tile's label is its identity across the whole service, not just its -// group: the hub_/golf_/castle_/lobby_ prefixes on games_hub exist for this. Two +// group: the hub_/golf_/castle_/rummy_/lobby_ prefixes on games_hub exist for this. Two // groups sharing a label ship two tiles nobody can tell apart, and every // byLabel map in these tests would read whichever came last. func TestRegistry_CustomScalarLabelsAreUniquePerService(t *testing.T) { @@ -518,7 +518,7 @@ var promSeriesToken = regexp.MustCompile(`\b[a-z][a-z0-9_]*_(?:total|gauge|sum|c // shared artifact to pin them together (that is #1308's scope). What these // audits do close, on their own side: every tile reads only names in this // set, and every name in this set is read by some tile. -var gamesHubSelectorPattern = regexp.MustCompile(`\b((?:hub_|golf_|chat_|castle_|lobby_)[a-z_]*)(\{[^}]*\})?`) +var gamesHubSelectorPattern = regexp.MustCompile(`\b((?:hub_|golf_|chat_|castle_|rummy_|lobby_)[a-z_]*)(\{[^}]*\})?`) var gamesHubExportedNames = map[string]bool{ // The castle envelope (#77), from GolfHub::DeclaredCounterSeries(). @@ -543,6 +543,8 @@ var gamesHubExportedNames = map[string]bool{ // series. "golf_commands_total": true, "golf_events_total": true, + "rummy_commands_total": true, + "rummy_events_total": true, "lobby_commands_total": true, "lobby_events_total": true, } diff --git a/domains/platform/apis/prom_proxy/service_handlers_test.go b/domains/platform/apis/prom_proxy/service_handlers_test.go index 6b030b77e..fb2ce8b7b 100644 --- a/domains/platform/apis/prom_proxy/service_handlers_test.go +++ b/domains/platform/apis/prom_proxy/service_handlers_test.go @@ -127,14 +127,15 @@ func TestMetricsHandler_GetServiceMetrics_MapsEveryFieldDistinctly(t *testing.T) assert.Equal(t, "1d", response.Window) // Custom groups keep registry order and every descriptor is present. - require.Len(t, response.Custom, 7) + require.Len(t, response.Custom, 8) assert.Equal(t, "Probes", response.Custom[0].Title) assert.Equal(t, "Sessions", response.Custom[1].Title) assert.Equal(t, "Room layer", response.Custom[2].Title) assert.Equal(t, "Chat", response.Custom[3].Title) assert.Equal(t, "Golf", response.Custom[4].Title) assert.Equal(t, "Castle", response.Custom[5].Title) - assert.Equal(t, "Lobby", response.Custom[6].Title) + assert.Equal(t, "Rummy", response.Custom[6].Title) + assert.Equal(t, "Lobby", response.Custom[7].Title) assert.Len(t, response.Custom[0].Metrics, 1) assert.Len(t, response.Custom[1].Metrics, 7) assert.Len(t, response.Custom[2].Metrics, 4) @@ -155,17 +156,20 @@ func TestMetricsHandler_GetServiceMetrics_MapsEveryFieldDistinctly(t *testing.T) // Chat starts at CustomScalars index 12, so its first value is 200+12. assert.Equal(t, CustomMetricValue{Label: "messages", Value: 212.0, Toggleable: true}, response.Custom[3].Metrics[0]) - // The tenants' envelopes from index 17 (#1490): golf, castle, the lobby. + // The tenants' envelopes from index 17 (#1490): golf, castle, rummy, the + // lobby. // Labels carry the game prefix so a tile's label names it across the // whole service, not just its group. assert.Equal(t, CustomMetricValue{Label: "golf_commands", Value: 217.0, Toggleable: true}, response.Custom[4].Metrics[0]) assert.Equal(t, CustomMetricValue{Label: "castle_commands", Value: 219.0, Toggleable: true}, response.Custom[5].Metrics[0]) - assert.Equal(t, CustomMetricValue{Label: "lobby_commands", Value: 221.0, Toggleable: true}, + assert.Equal(t, CustomMetricValue{Label: "rummy_commands", Value: 221.0, Toggleable: true}, response.Custom[6].Metrics[0]) + assert.Equal(t, CustomMetricValue{Label: "lobby_commands", Value: 223.0, Toggleable: true}, + response.Custom[7].Metrics[0]) // The omitted query's descriptor survives with a zero value. - last := response.Custom[6].Metrics[1] + last := response.Custom[7].Metrics[1] assert.Equal(t, omitted.Label, last.Label) assert.Equal(t, 0.0, last.Value) } @@ -363,6 +367,8 @@ func TestMetricsHandler_GetServiceMetricsTimeSeries_StandardPlusCustom(t *testin "golf_event_rate", "golf_event_count", "castle_command_rate", "castle_command_count", "castle_event_rate", "castle_event_count", + "rummy_command_rate", "rummy_command_count", + "rummy_event_rate", "rummy_event_count", "lobby_command_rate", "lobby_command_count", "lobby_event_rate", "lobby_event_count", } diff --git a/domains/platform/apis/stats/games.go b/domains/platform/apis/stats/games.go index 92860a06a..8b723e2e8 100644 --- a/domains/platform/apis/stats/games.go +++ b/domains/platform/apis/stats/games.go @@ -62,7 +62,7 @@ var ( "chat_message": true, "geometry_changed": true, "game_started": true, "game_finished": true, } - hubVariants = map[string]bool{"golf": true, "castle": true} + hubVariants = map[string]bool{"golf": true, "castle": true, "rummy": true} hubOutcomes = map[string]bool{"completed": true, "abandoned": true} hubSurfaces = map[string]bool{"plane": true, "sphere": true, "glasshouse": true} ) diff --git a/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go b/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go index 09a341203..bbb67ce65 100644 --- a/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go +++ b/domains/platform/libs/otel_contract/hub_event_vocabulary_test.go @@ -97,7 +97,7 @@ func TestHubEventValuesAgreeBetweenGamesHubAndStats(t *testing.T) { regexp.MustCompile(`hubOutcomes = map\[string\]bool\{([^}]*)\}`), "hubOutcomes"), "an ending the hub can write that stats would count as \"other\"") - // GameKindName: return kind == GameKind::kCastle ? "castle" : "golf"; + // GameKindName: case GameKind::kCastle: return "castle"; ... return "golf"; hosted, err := os.ReadFile(hubHostedGameH) require.NoError(t, err) kindName := regexp.MustCompile(`(?s)GameKindName\(GameKind kind\) \{(.*?)\n\}`).