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
2 changes: 1 addition & 1 deletion domains/ai/apis/deja/src/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 72 additions & 3 deletions domains/games/apis/games_hub/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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 = [
"model/castle.smithy",
"model/games.smithy",
"model/golf.smithy",
"model/lobby.smithy",
"model/rummy.smithy",
"model/voice.smithy",
],
namespace = "moonbase::games",
Expand All @@ -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",
Expand All @@ -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",
],
)

Expand Down Expand Up @@ -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",
],
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -803,21 +839,54 @@ 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",
srcs = [
"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 = [
Expand Down
40 changes: 28 additions & 12 deletions domains/games/apis/games_hub/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
5 changes: 5 additions & 0 deletions domains/games/apis/games_hub/game_events.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<rummy::GameState>(&state)) {
if (rummy_state->getPhase() == rummy::Phase::Abandoned) finished.outcome = kOutcomeAbandoned;
return finished;
}
if (std::get<castle::GameState>(state).getPhase() == castle::Phase::Abandoned) {
finished.outcome = kOutcomeAbandoned;
}
Expand Down
2 changes: 1 addition & 1 deletion domains/games/apis/games_hub/game_events.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion domains/games/apis/games_hub/game_events_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -104,6 +121,28 @@ TEST(GameEvents, ACastleGameLeftBelowTwoSeatsIsAbandoned) {
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);

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<rummy::GameState> 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;
Expand Down Expand Up @@ -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<HostedState> 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);
}
}
Expand Down
Loading
Loading