Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions domains/games/apis/games_hub/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ cc_library(
"//domains/games/libs/cards/castle:game_state",
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:table",
],
)

Expand Down Expand Up @@ -107,6 +108,7 @@ cc_library(
"//domains/games/libs/cards/castle:game_state",
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:table",
"@com_google_absl//absl/strings:str_format",
"@com_google_absl//absl/time",
],
Expand All @@ -127,6 +129,7 @@ cc_test(
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/golf:player",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:table",
"@com_google_absl//absl/status:statusor",
"@com_google_absl//absl/time",
"@googletest//:gtest_main",
Expand Down Expand Up @@ -348,7 +351,7 @@ cc_library(
"//domains/games/libs/cards/castle:game_state_serde",
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/golf:game_state_serde",
"//domains/games/libs/cards/rummy:game_state_serde",
"//domains/games/libs/cards/rummy:table_serde",
"//domains/platform/libs/pg",
"@com_google_absl//absl/log",
"@com_google_absl//absl/status",
Expand Down Expand Up @@ -426,7 +429,8 @@ cc_test(
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/golf:game_state_serde",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:game_state_serde",
"//domains/games/libs/cards/rummy:table",
"//domains/games/libs/cards/rummy:table_serde",
"//domains/platform/libs/pg",
"//domains/platform/libs/pg:listener",
"@googletest//:gtest_main",
Expand Down Expand Up @@ -642,6 +646,7 @@ cc_library(
"//domains/games/libs/cards/golf:game_state",
"//domains/games/libs/cards/golf:player",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:table",
"//domains/games/libs/mithril_cpp:client",
"//domains/platform/libs/futility/otel:metrics",
"//domains/platform/libs/pg:listener",
Expand Down Expand Up @@ -861,6 +866,7 @@ cc_test(
"//domains/games/libs/cards:dealer",
"//domains/games/libs/cards/rummy:game_state",
"//domains/games/libs/cards/rummy:meld",
"//domains/games/libs/cards/rummy:table",
"//domains/platform/libs/futility/otel:capturing_metrics_recorder",
"//domains/platform/libs/futility/otel:metrics",
"@googletest//:gtest_main",
Expand Down
18 changes: 12 additions & 6 deletions domains/games/apis/games_hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,17 +351,23 @@ engine is `libs/cards/golf`'s immutable `GameState`, which also carries
`libs/cards/castle`'s and rummy's `libs/cards/rummy`'s, stated on each
engine's `GameState`.

A rummy finish credits the seat that went out with a win and every seat
with a game played; its points (what the others still held) ride
`gameEnded` and stay out of the room's running total, which is golf's
lower-is-better scale.
A rummy table is dealer's choice (#1609, `libs/cards/rummy`'s
`TableState`): `startGame` seats it between deals, the dealer's
`chooseVariant` deals, and each deal's end passes the deal to the next
seat. A dealer the room shows as not connected lets any seat deal. Each
deal is a game to the room's stats and the event log: its end credits the
seat that went out with a win and every seat with a game played. Its
points (what the others still held) ride the view's `lastDeal` and stay
out of the room's running total, which is golf's lower-is-better scale.
The table ends only below two seats, and its `gameEnded` carries the hands
each seat won.

## Redaction

A castle table redacts by `CastleViewLocked`: own hand faces (everyone's
once the game ends), every face-up row, face-down rows as counts. A rummy
table by `RummyViewLocked`: own hand faces (everyone's once the game
ends), other hands as counts, the stock as a count; the melds, the
table by `RummyViewLocked`: own hand faces (everyone's once the deal
ends, and between deals), other hands as counts, the stock as a count; the melds, the
discard's top and the card taken from it this turn are public, and a
stock draw's `lastMove` names no card. Golf's rules, below, are
`ViewLocked`'s.
Expand Down
22 changes: 17 additions & 5 deletions domains/games/apis/games_hub/game_events.cc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "domains/games/libs/cards/castle/game_state.h"
#include "domains/games/libs/cards/golf/game_state.h"
#include "domains/games/libs/cards/rummy/game_state.h"
#include "domains/games/libs/cards/rummy/table.h"

namespace games_hub {
namespace {
Expand Down Expand Up @@ -40,18 +41,29 @@ std::string RoomTag(std::string_view room) {
return tag;
}

GameFinished FinishedOf(const HostedState& state, std::size_t players) {
GameFinished finished{GameKindName(KindOf(state)), kOutcomeCompleted, players};
std::optional<GameFinished> FinishedOf(const HostedState& state, std::size_t players) {
GameFinished finished{VariantWordOf(state), kOutcomeCompleted, players};
if (const auto* golf_state = std::get_if<golf::GameState>(&state)) {
// The engine supersedes any knock with this sentinel when a leave
// drops the table below two seats.
if (golf_state->getWhoKnocked() == golf::GameState::kAbandoned)
finished.outcome = kOutcomeAbandoned;
return finished;
}
if (const auto* rummy_state = std::get_if<rummy::GameState>(&state)) {
if (rummy_state->getPhase() == rummy::Phase::Abandoned) finished.outcome = kOutcomeAbandoned;
return finished;
if (const auto* table = std::get_if<rummy::TableState>(&state)) {
const auto& deal = table->getDeal();
if (!deal.has_value()) return std::nullopt;
if (deal->getPhase() == rummy::Phase::Abandoned) {
finished.outcome = kOutcomeAbandoned;
return finished;
}
// Won by play, and the table dealt on to choosing: the deal's end. A
// closed table's finished deal was recorded when it finished.
if (deal->getPhase() == rummy::Phase::Over &&
table->getPhase() == rummy::TablePhase::Choosing) {
return finished;
}
return std::nullopt;
}
if (std::get<castle::GameState>(state).getPhase() == castle::Phase::Abandoned) {
finished.outcome = kOutcomeAbandoned;
Expand Down
12 changes: 8 additions & 4 deletions domains/games/apis/games_hub/game_events.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define DOMAINS_GAMES_APIS_GAMES_HUB_GAME_EVENTS_H

#include <cstddef>
#include <optional>
#include <string>
#include <string_view>

Expand Down Expand Up @@ -58,8 +59,8 @@ inline constexpr std::string_view kOutcomeAbandoned = "abandoned";

/// A game that ended, as the event describes it.
struct GameFinished {
/// "golf", "castle" or "rummy" — GameKindName's spelling, which is also the
/// wire's and the stored row's.
/// VariantWordOf's spelling: GameKindName's for golf and castle, a rummy
/// deal's own variant's for a rummy table.
std::string_view variant;
/// kOutcomeCompleted — the engine played it out — or kOutcomeAbandoned: too few
/// seats were left to go on.
Expand All @@ -77,8 +78,11 @@ struct GameFinished {
/// unchanged; this is the guarantee, not a transformation.
std::string RoomTag(std::string_view room);

/// How `state` ended, for a roster of `players` seats.
GameFinished FinishedOf(const HostedState& state, std::size_t players);
/// How `state` ended, for a roster of `players` seats; nothing when no game
/// ended. A rummy table's games are its deals: a deal won by play is
/// completed, one broken up by a leave abandoned, and a table closing
/// between deals ended nothing — its deals were each recorded as they ended.
std::optional<GameFinished> FinishedOf(const HostedState& state, std::size_t players);

/// A room was made, on the surface it chose — SurfaceKindName's
/// spelling, which is the wire's and the stored row's. Its creator is
Expand Down
77 changes: 52 additions & 25 deletions domains/games/apis/games_hub/game_events_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "domains/games/libs/cards/golf/game_state.h"
#include "domains/games/libs/cards/golf/player.h"
#include "domains/games/libs/cards/rummy/game_state.h"
#include "domains/games/libs/cards/rummy/table.h"
#include "gmock/gmock.h"
#include "gtest/gtest.h"

Expand Down Expand Up @@ -59,10 +60,10 @@ castle::GameState Castle() {
"game", "v0"};
}

/// Two rummy seats, seat 0 holding one card after its draw: a discard
/// goes out.
rummy::GameState Rummy() {
return rummy::GameState{
/// A rummy table (#1609) with a deal in play: seat 0 holds one card after
/// its draw, so a discard goes out.
rummy::TableState Rummy() {
rummy::GameState deal{
{Card{Suit::Clubs, Rank::Two}},
{Card{Suit::Clubs, Rank::Three}},
{{"andy", {Card{Suit::Clubs, Rank::Five}}}, {"mercy", {Card{Suit::Clubs, Rank::Six}}}},
Expand All @@ -73,13 +74,22 @@ rummy::GameState Rummy() {
std::nullopt,
"game",
"v0"};
return rummy::TableState{{"andy", "mercy"}, {0, 0}, 1, 1, rummy::TablePhase::Playing,
rummy::Variant::Basic, deal, "game", "v0"};
}

rummy::TableState RummyDealWon() {
auto won = Rummy().inDeal(
[](const rummy::GameState& deal) { return deal.discard(0, Card{Suit::Clubs, Rank::Five}); });
EXPECT_TRUE(won.ok()) << won.status();
return *won;
}

TEST(GameEvents, AGolfGamePlayedOutIsCompleted) {
const golf::GameState knocked = Golf(0);
ASSERT_TRUE(knocked.isOver());

const GameFinished finished = FinishedOf(HostedState(knocked), 2);
const GameFinished finished = *FinishedOf(HostedState(knocked), 2);
EXPECT_EQ(finished.variant, "golf");
EXPECT_EQ(finished.outcome, "completed");
EXPECT_EQ(finished.players, 2u);
Expand All @@ -93,7 +103,7 @@ TEST(GameEvents, AGolfGameLeftBelowTwoSeatsIsAbandoned) {
ASSERT_TRUE(lone.isOver());
ASSERT_EQ(lone.getWhoKnocked(), golf::GameState::kAbandoned);

const GameFinished finished = FinishedOf(HostedState(lone), 1);
const GameFinished finished = *FinishedOf(HostedState(lone), 1);
EXPECT_EQ(finished.variant, "golf");
EXPECT_EQ(finished.outcome, "abandoned");
EXPECT_EQ(finished.players, 1u);
Expand All @@ -104,7 +114,7 @@ TEST(GameEvents, ACastleGamePlayedOutIsCompleted) {
ASSERT_TRUE(over.ok()) << over.status();
ASSERT_EQ(over->getPhase(), castle::Phase::Over);

const GameFinished finished = FinishedOf(HostedState(*over), 2);
const GameFinished finished = *FinishedOf(HostedState(*over), 2);
EXPECT_EQ(finished.variant, "castle");
EXPECT_EQ(finished.outcome, "completed");
EXPECT_EQ(finished.players, 2u);
Expand All @@ -115,32 +125,49 @@ TEST(GameEvents, ACastleGameLeftBelowTwoSeatsIsAbandoned) {
ASSERT_TRUE(gone.ok()) << gone.status();
ASSERT_EQ(gone->getPhase(), castle::Phase::Abandoned);

const GameFinished finished = FinishedOf(HostedState(*gone), 1);
const GameFinished finished = *FinishedOf(HostedState(*gone), 1);
EXPECT_EQ(finished.variant, "castle");
EXPECT_EQ(finished.outcome, "abandoned");
EXPECT_EQ(finished.players, 1u);
}

TEST(GameEvents, ARummyGamePlayedOutIsCompleted) {
const absl::StatusOr<rummy::GameState> over = Rummy().discard(0, Card{Suit::Clubs, Rank::Five});
ASSERT_TRUE(over.ok()) << over.status();
ASSERT_EQ(over->getPhase(), rummy::Phase::Over);
// A rummy table's games are its deals (#1609): a deal won by play is a
// game completed, under the deal's variant word — basic rummy is "rummy".
TEST(GameEvents, ARummyDealPlayedOutIsCompleted) {
const rummy::TableState table = RummyDealWon();
ASSERT_EQ(table.getPhase(), rummy::TablePhase::Choosing);
ASSERT_FALSE(table.isOver());

const GameFinished finished = FinishedOf(HostedState(*over), 2);
EXPECT_EQ(finished.variant, "rummy");
EXPECT_EQ(finished.outcome, "completed");
EXPECT_EQ(finished.players, 2u);
const auto finished = FinishedOf(HostedState(table), 2);
ASSERT_TRUE(finished.has_value());
EXPECT_EQ(finished->variant, "rummy");
EXPECT_EQ(finished->outcome, "completed");
EXPECT_EQ(finished->players, 2u);
}

TEST(GameEvents, ARummyGameLeftBelowTwoSeatsIsAbandoned) {
const absl::StatusOr<rummy::GameState> gone = Rummy().removePlayer(1);
TEST(GameEvents, ARummyDealBrokenUpByALeaveIsAbandoned) {
const absl::StatusOr<rummy::TableState> gone = Rummy().removePlayer(1);
ASSERT_TRUE(gone.ok()) << gone.status();
ASSERT_EQ(gone->getPhase(), rummy::Phase::Abandoned);
ASSERT_TRUE(gone->isOver());

const GameFinished finished = FinishedOf(HostedState(*gone), 1);
EXPECT_EQ(finished.variant, "rummy");
EXPECT_EQ(finished.outcome, "abandoned");
EXPECT_EQ(finished.players, 1u);
const auto finished = FinishedOf(HostedState(*gone), 1);
ASSERT_TRUE(finished.has_value());
EXPECT_EQ(finished->variant, "rummy");
EXPECT_EQ(finished->outcome, "abandoned");
EXPECT_EQ(finished->players, 1u);
}

// A table closing between deals, or a deal still going, is no game
// ending: the deals it played were each recorded as they ended.
TEST(GameEvents, ARummyTableClosingBetweenDealsRecordsNothing) {
const absl::StatusOr<rummy::TableState> closed = RummyDealWon().removePlayer(1);
ASSERT_TRUE(closed.ok()) << closed.status();
ASSERT_TRUE(closed->isOver());
EXPECT_FALSE(FinishedOf(HostedState(*closed), 1).has_value());
EXPECT_FALSE(FinishedOf(HostedState(Rummy()), 2).has_value());
const absl::StatusOr<rummy::TableState> opened = rummy::TableState::open("T", {"a", "b"});
ASSERT_TRUE(opened.ok());
EXPECT_FALSE(FinishedOf(HostedState(*opened), 2).has_value());
}

// 2026-09-21T13:40:00Z, so a reader can see the stamp is epoch millis
Expand Down Expand Up @@ -221,11 +248,11 @@ TEST(GameEvents, EveryEventsLineIsTextWithNothingToEscape) {
endings.emplace_back(*Golf(golf::GameState::kNoKnock).removePlayer(1));
endings.emplace_back(*Castle().playFromHand(0, {0}));
endings.emplace_back(*Castle().removePlayer(1));
endings.emplace_back(*Rummy().discard(0, Card{Suit::Clubs, Rank::Five}));
endings.emplace_back(RummyDealWon());
endings.emplace_back(*Rummy().removePlayer(1));
for (const HostedState& state : endings) {
for (std::size_t players = 1; players <= 4; ++players) {
const GameFinished finished = FinishedOf(state, players);
const GameFinished finished = *FinishedOf(state, players);
EXPECT_TRUE(finished.outcome == kOutcomeCompleted || finished.outcome == kOutcomeAbandoned)
<< finished.outcome;
EXPECT_TRUE(finished.variant == "golf" || finished.variant == "castle" ||
Expand Down
Loading
Loading