Skip to content

Latest commit

 

History

History
2454 lines (1947 loc) · 106 KB

README.md

File metadata and controls

2454 lines (1947 loc) · 106 KB

BotFramework

framework/ is the reusable runtime layer for CasinoShiz modules.

Release notes and the NuGet/GitHub publishing procedure live in docs/releases/1.0.0.md and docs/framework-release.md.

The framework is split into transport-neutral contracts, backend/runtime infrastructure, and channel adapter infrastructure. Telegram and Discord are presentation/transport adapters; game contracts, decisions, effects and domain events do not depend on either channel or on the deployment shape. Game modules should depend on the smallest framework surface they need.

1.0.0 public product

The current public integration boundary is tenant-aware and intentionally breaking. Every inbound operation carries a TenantContext:

public sealed record TenantContext(
    TenantId TenantId,
    ScopeId ScopeId,
    PlayerId? PlayerId,
    BotChannel Channel,
    RequestId RequestId,
    RequestId CorrelationId);

TenantId, ScopeId, PlayerId and RequestId are opaque string value types. TenantKey and ScopeKey are internal PostgreSQL numbers and never appear in SDK DTOs, REST paths, gRPC metadata contracts or module signatures. Wallets are scope-local: the same player id in two scopes or tenants is always a different wallet boundary.

The scoped ITenantContextAccessor is the ambient boundary for infrastructure below a transport adapter. Host code pushes it once at ingress and restores it after the operation. Background jobs and outbox consumers restore it from persisted tenant/scope metadata before invoking module code.

Tenant resolution

Channel Tenant Scope
Telegram forum chat chat topic id
Telegram ordinary chat chat main
Telegram private chat private chat container main
Discord guild guild channel or thread
Discord DM private container main
REST trusted JWT tenant_id trusted JWT scope_id

REST uses the JWT sub claim as the opaque PlayerId. The route tenant and scope are checked against the token before a module handler is called. A channel binding is provisioned idempotently in the Host registry for the resolved platform container.

Public packages

The supported package-only surface is:

Package Purpose Target
BotFramework.Contracts Opaque ids, tenant context, transport contracts, errors, pagination and limiter contracts net8.0;net10.0
BotFramework.Sdk Pure module, domain, decision/effect, event and scheduling abstractions net8.0;net10.0
BotFramework.Testing Test doubles and in-memory SDK fixtures net8.0;net10.0
BotFramework.Text Platform-neutral text normalization, analyzers, policies and effect contracts net8.0;net10.0
BotFramework.Scheduling.Abstractions Transport-neutral scheduled command contracts net8.0;net10.0
BotFramework.Rest Canonical REST middleware and route-module contract net10.0
BotFramework.Telegram.Abstractions Telegram update and tenant-resolution contracts net10.0
BotFramework.Discord.Abstractions Discord tenant-resolution contracts net10.0
BotFramework.Client Authenticated typed REST transport and generated OpenAPI client net8.0;net10.0

The text-processing subsystem is documented separately in docs/botframework-text.md. It intentionally contains no censorship, spam, moderation, persistence, or rule-management business logic.

BotFramework.Host, BotFramework.Telegram, BotFramework.Discord and other runtime projects are composition/runtime adapters. They may use PostgreSQL, Redis, CAP, Discord.Net or Telegram.Bot and target net10.0. They are not dependencies of a pure domain or contracts project.

The supported packages are released as 1.0.0 and carry this framework README, MIT license metadata, repository metadata, SourceLink, embedded symbols and deterministic package output. BotFramework.GameTemplates is a separate dotnet new consumer artifact released at the same version.

Canonical REST contract

REST modules map only under:

/api/v1/tenants/{tenantId}/scopes/{scopeId}/{module}/{operation}

The old scope-only /scopes/{scopeId} route is removed. State-changing requests require a printable Idempotency-Key. The framework creates or propagates X-Request-ID and X-Correlation-ID and returns them on the response. Errors are RFC 7807 application/problem+json documents with stable code, correlationId and optional retryAfterSeconds fields.

BotFramework.Client owns token acquisition, tenant/scope context, idempotency and correlation headers, and typed RFC 7807 failures. Its low-level client is generated by NSwag from the checked-in OpenAPI artifact:

dotnet build framework/BotFramework.Client \
  -p:BotFrameworkGenerateClient=true

List endpoints use CursorPageRequest and CursorPage<T>; cursors are opaque and must not be interpreted by clients.

gRPC context propagation

The service-defaults client interceptor propagates request metadata with these lowercase keys:

tenant_id
scope_id
player_id
request_id
correlation_id
channel

Numeric Telegram/Discord update and message ids stay inside their adapters. They are not public module identity or wire-contract fields. Removed protobuf field numbers remain reserved in the owning .proto files.

Distributed rate limiting

REST, Telegram, Discord and the Host command pipeline use the same IRateLimiter contract. Redis evaluates all applicable token buckets in one Lua call:

tenant
tenant + player
tenant + IP        (REST only)
tenant + route
tenant + player + route

Route keys are stable module/command identifiers, never raw URLs or user payloads. Deployment defaults are REST player 120/min, REST IP 300/min, Telegram burst 10 with refill 1/s, Discord 8/10s, and a tenant ceiling of 3000/min. PostgreSQL tenant overrides use this precedence:

tenant + route -> tenant + channel -> deployment route -> deployment channel -> global default

All applicable dimensions are charged. Redis outages switch to bounded local buckets with the same policy values; the decision carries IsFallback, the service reports degraded limiter telemetry and retries Redis with backoff. REST returns 429, Retry-After and rate-limit headers; channel adapters stop before game side effects.

Observability and operations

Framework instrumentation uses BotFramework.* meters and activity sources. Metric labels are limited to service, channel, module, route template, outcome, limiter dimension and fallback state. Tenant, scope, player, request and correlation identifiers belong only in structured logs and traces.

The standard service defaults expose request/error counters and latency, atomic execution, lock wait and transaction duration, outbox lag/depth, limiter decisions/fallback, policy-cache hits/misses and tenant provisioning. Framework integration messaging additionally exposes durable outbox depth and publish/failure counters, inbox duplicate/result-replay/handler-failure counters, schema rejection/quarantine counters, durable workflow timeout schedule/dispatch/retry counters, and service database ownership violations. Compose includes an OTLP Collector, Tempo, Prometheus, Grafana trace links and Alertmanager's configurable generic webhook. Helm supports an external OTLP endpoint and optional Prometheus Operator ServiceMonitor, PrometheusRule and AlertmanagerConfig resources. The alert rules cover unavailable service/DB/Redis, prolonged local fallback, HTTP/game failures, request/game p95 latency, outbox lag, queue backlog, CPU and memory pressure.

Performance baseline

The 1.0.0 repository baseline was measured on 2026-08-07 UTC. It is a development reference for comparing changes, not an SLA or a production capacity guarantee. The complete route matrix, error classifications and artifacts are documented in docs/rest-baseline.md.

The measurement host was:

Property Value
OS CachyOS Linux, rolling, x86_64
Kernel 7.1.5-1-cachyos
CPU AMD Ryzen 7 6800H, 8 cores / 16 logical CPUs
RAM 30 GiB host memory; 30 GiB swap
.NET SDK 10.0.110, runtime 10.0.10
Deployment local k3d/k3s dev cluster, ingress api.casinoshiz.localhost
Dev pod profile 2 CPU / 1 GiB for game backend and REST pods after the final tuning pass
Services PostgreSQL and Redis; ClickHouse and OTLP exporters disabled for the clean post-fix REST run

The load generator was wrk f8eb608 with the epoll backend. The profiling harnesses were eng/run-rest-profile-matrix.sh and eng/profile-rest-endpoint.sh. Runtime diagnostics were collected with dotnet-trace and dotnet-counters, both version 9.0.661903; process CPU and RSS were sampled from Linux /proc/ps. PostgreSQL CPU, RSS and transactions were sampled where the benchmark used a Docker PostgreSQL container. The high-concurrency matrix used 2 wrk threads, 128 connections and 10 seconds per scenario; the stable post-fix Horse comparison also used 32 and 64 connections.

Representative successful read baselines:

Route Profile RPS Average latency
Horse GET /horse/info 2 threads / 32 connections / 10 s, post-fix 2704.70 12.00 ms
Horse GET /horse/info 2 threads / 64 connections / 10 s, post-fix 2510.38 26.20 ms
Leaderboard GET /leaderboard/ 2 threads / 128 connections / 10 s 2005.54 66.98 ms
Pick GET /pick/daily/schedule 2 threads / 128 connections / 10 s 3855.30 33.37 ms
PixelBattle GET /pixelbattle/grid 2 threads / 32 connections / 10 s, stable profile 131.20 —

The measurements used real framework middleware and game routes through the dev ingress. Routes returning 404 or 500 were retained for diagnostics but are not capacity baselines. Stateful game mutations were excluded from the read-only matrix unless a dedicated fixture and cleanup strategy existed. Kafka/Redpanda was not part of these RPS measurements; its framework transport was verified separately with a functional CAP round-trip smoke test.

Package consumer and template workflow

The repository validates the framework as an external consumer, not only via project references. eng/package-consumer-smoke.sh packs public packages into .artifacts/local-feed with package validation enabled, restores the external samples/CoinFlip module from that feed and runs its tests. It also installs the template package in an isolated dotnet new hive, generates all channels, builds the atomic scaffold and runs its isolated tests.

Install the scaffold from a local feed or NuGet:

dotnet new install BotFramework.GameTemplates::1.0.0
dotnet new botframework-game -n CoinFlip --module-id coin-flip

The default scaffold contains pure Domain, Application, Contracts, Infrastructure, REST, Telegram, Discord and isolated tests. Use --channels rest|telegram|discord|all, --persistence atomic|event-sourced|none and --include-tests true|false to select the generated surface.

Compatibility and migration boundary

The repository still contains demonstration games using previous numeric contracts. They are a repository compatibility layer only; they are not part of the supported package-only consumer contract. New modules use opaque ids, TenantContext, tenant wallet effects and tenant-aware state/event/schedule persistence. The package contract is frozen for 1.0.0; repository game migration does not change it.

The assembly map below includes the existing runtime and demonstration-game surface for repository maintainers. It is not the supported package-only SDK surface described above; legacy numeric game contracts are staging material until the games migration is completed.

Deferred-outcome wager model

In addition to a one-shot atomic action and the turn-based action base, the SDK has a third composition model for a game whose result is delivered later by another system: DeferredOutcomeWager. It fits a bot animation, remote match result, asynchronous renderer or any other flow shaped as:

place wager -> wait for external outcome -> settle
                           \-> abort and refund if delivery fails

The model owns idempotent atomic execution, per-user/chat versioned JSONB state, wallet debit/credit, optional standard daily quota, and compensation. A game owns only its marker type, outcome type, deterministic payout function, and optional domain-event factories. The Host adds all three actions, descriptors, state stores and a compact executor in one call:

services.AddDeferredOutcomeWagerGame(
    new DeferredOutcomeWagerDefinition<CoinFlipGame, CoinSide>(
        gameId: "coin-flip",
        displayName: "Coin flip",
        calculatePayout: static (wager, side) =>
            side == CoinSide.Heads ? wager.Amount * 2 : 0,
        dailyQuota: new DeferredOutcomeWagerDailyQuota("coin-flip.daily")));

An application service injects IDeferredOutcomeWagerGameExecutor<CoinFlipGame, CoinSide> and passes the three typed Place, Resolve, and Abort commands. The transport remains responsible for producing a stable command id and delivering the external outcome. The model deliberately does not force the older native-dice modules to change their public contracts or relational persistence.

For a custom action with a daily quota, derive its descriptor from DailyGameQuotaExecutionDescriptor. Override UserId and DailyQuotaId; the descriptor delegates the schedule, limit and exemption rules to IGameDailyQuotaPolicy. Returning null from DailyQuotaId opts out, while an unlimited policy can still provide a zero-limit snapshot for actions that need one. The default runtime policy is the separate TelegramDiceGameDailyQuotaPolicy adapter for the existing native-dice configuration; game code and the common model do not reference Telegram and can use another policy unchanged.

Game manifests and one-shot wagers

New modules can opt into a GameDefinition manifest. It contains only stable, transport-neutral metadata: game id and display name, supported execution styles, stake limits, daily-quota id and named entropy values. Registering a definition makes it available through IGameCatalog; Telegram, REST and Discord adapters can use that catalog without a game depending on any of them.

GameCommandContext carries the repeated player/chat/idempotency fields, and PlayerGameExecutionDescriptor maps it to the standard aggregate and wallet identity. Existing command contracts keep working unchanged; this is an opt-in shape for new games.

For a synchronous game, use InstantWagerDefinition: it validates its declared stake limits and optional quota, debits the stake, resolves a deterministic outcome from framework entropy, pays the result and emits GameCompletedMetaEvent in the same atomic decision. The normalized completion event feeds meta projections such as quests, achievements, clans and risk without making the game depend on the Meta module.

services.AddInstantWagerGame(
    new InstantWagerDefinition<CoinFlipGame, CoinSide>(
        new GameDefinition(
            "coin-flip",
            "Coin flip",
            GameCapabilities.InstantWager,
            new GameStakeLimits(10, 1_000),
            entropyNames: ["side"]),
        static context => context.Entropy.GetDouble("side") < 0.5
            ? CoinSide.Heads
            : CoinSide.Tails,
        static (wager, side) => side == CoinSide.Heads ? wager.Amount * 2 : 0));

The Host registers a stateless state store and IInstantWagerGameExecutor<CoinFlipGame, CoinSide>. A module-specific service only creates InstantWagerCommand<CoinFlipGame> with a GameCommandContext and an amount. Deferred-outcome wagers now also expose and register a manifest, and by default emit the same completion event when the external outcome settles.

Atomic registrations and durable game sessions

For an action that is neither an instant nor a deferred wager, the Host offers small composition helpers instead of making every module repeat action, descriptor and state-store registration:

services.AddAtomicJsonGameAction<MoveCommand, MatchState, MoveAction, MoveResult, MoveDescriptor>();
// Or, for a stateless command:
services.AddAtomicStatelessGameAction<RollCommand, RollAction, RollResult, RollDescriptor>();

AddAtomicGameAction is the equivalent helper when the module supplies its own state-store implementation. AddAtomicTurnBasedGameAction remains available; it is now the turn-based convenience wrapper over the same JSONB registration and additionally registers the scheduled-command adapter.

IGameSessionService is a separate, opt-in durable flow layer for games that need to pause and resume across messages, requests or workers. It records Started, Suspended, Resumed, Completed and Failed lifecycle entries, an optimistic revision, JSON payload and an idempotent correlation id for every operation. GameId, OwnerId and ScopeId are opaque strings: a Telegram, Discord or REST adapter maps its own identities at the edge, so neither game code nor the session model depends on a channel.

var started = await sessions.StartAsync(
    new GameSessionStartRequest("blackjack", "player:42", "table:main", correlationId), ct);
var suspended = await sessions.SuspendAsync(
    new GameSessionTransitionRequest(started.SessionId, suspendCorrelationId, started.Revision), ct);
var resumed = await sessions.ResumeAsync(
    new GameSessionTransitionRequest(suspended.SessionId, resumeCorrelationId, suspended.Revision), ct);

Repeating an operation with the same correlation id returns its recorded snapshot. GetByCorrelationAsync resolves either a root or later operation correlation to the current session, which lets a new transport adapter resume a flow without needing any Telegram-specific state.

Runtime capabilities and effect routing

GameCapabilities continues to describe execution style (InstantWager, DeferredOutcomeWager, TurnBased). Runtime requirements are a separate, opt-in GameCapabilitySet on GameDefinition. This prevents a game being coupled to a frontend while still making its requirements explicit:

var game = new GameDefinition(
    "story-blackjack",
    "Story blackjack",
    GameCapabilities.TurnBased,
    requiredCapabilities:
    [
        EconomyCapabilities.Wallet,
        SchedulingCapabilities.Timers,
        MessagingCapabilities.Messages,
        NarrativeCapabilities.Scenes,
    ]);

The SDK exposes groups for EconomyCapabilities, PersistenceCapabilities, SchedulingCapabilities, MessagingCapabilities, NarrativeCapabilities, TurnBasedCapabilities and optional CasinoCapabilities. The Host already provides the transactional economy/persistence/scheduling subset. A Telegram, Web or test frontend declares the features it implements through IGameRuntimeCapabilityProvider and registers an IGameEffectHandler for its own semantic effects:

services.AddSingleton<IGameRuntimeCapabilityProvider>(
    new StaticGameRuntimeCapabilityProvider(
        [MessagingCapabilities.Messages, MessagingCapabilities.Input]));

At action execution the Host rejects a manifest requirement the current runtime does not provide, and rejects an emitted effect whose capability was not declared by the game. Module-defined effects implement IGameCapabilityEffect, which names the required feature. Built-in economy, quota, record, event and scheduling effects are mapped automatically. This is the stable core for frontend-owned effects.

Narrative effects

BotFramework.Narrative supplies the transport-neutral effects SceneEffect, DialogEffect, ChoiceEffect, NarrativeFlagEffect and NarrativeCheckpointEffect. NarrativeText contains a localization key, parameters and an optional fallback—not frontend markup. NarrativeAddress uses opaque conversation and recipient ids, so games stay independent from Telegram chat ids, browser connection ids and any other transport identity.

services.AddNarrativeEffectSink<WebNarrativeSink>();

var scene = new SceneEffect(
    new NarrativeAddress("table:42", "player:7"),
    "forest",
    new NarrativeText("scene.forest.title"));

BotFramework.Narrative.Host registers the PostgreSQL INarrativeProjectionStore and migrations for flags, the latest checkpoint and the active choice. The projection is keyed by NarrativeAddress and, when supplied by the Host, its tenant/scope. It is applied before the frontend sink and de-duplicates an at-least-once durable effect by its delivery id. A Web or Telegram sink now owns only rendering and delivery; it can read the same projection to render a resumed screen. AddNarrativeEffectSink<TSink>() enables this persistence by default, or AddNarrativePersistence() registers it for a query-only frontend. A frontend returns NarrativeChoiceSelection; the game maps that semantic input to its own command. Neither package references a frontend implementation.

Generic presentation effects

BotFramework.Presentation complements narrative with general-purpose, transport-independent UI output: NotificationEffect, RichResultEffect, InputFormEffect and MediaEffect. These are semantic effects, not Telegram messages, HTML, browser components or direct media URLs. A frontend implements IPresentationEffectSink through BotFramework.Presentation.Host:

services.AddPresentationEffectSink<WebPresentationSink>();

var request = new InputRequestEffect(
    requestId: "bet:42",
    expectedPlayerId: "player:7",
    scopeId: "table:42",
    route: "table.bet",
    expiresAt: now.AddMinutes(1));
var form = new InputFormEffect(
    new PresentationAddress("table:42", "player:7"),
    request.RequestId,
    new PresentationText("bet.title"),
    [new PresentationInputField("amount", new PresentationText("bet.amount"), PresentationInputKind.Number)]);

Emit the input request and form in one game decision. The Host stores the request before commit; all presentation effects are durable and therefore delivered from the transactional effect outbox after commit. A form submits a JSON object through the existing GameInputSubmission path, which still owns one-time consumption, player/scope checks, expiry and correlation. The game route validates field values as untrusted input. MessagingCapabilities now distinguishes ordinary messages, rich results, input and media.

One-time input and durable effect delivery

InputRequestEffect is the transport-independent half of an interaction. It binds an opaque request id to an expected player and scope, optional allowed values, an expiry, a game route and optional GameSession. Emit it with the matching ChoiceEffect; use the same request id as ChoiceEffect.InteractionId.

var input = new InputRequestEffect(
    requestId: "forest:turn:17",
    expectedPlayerId: "player:7",
    scopeId: "table:42",
    route: "story.choice",
    expiresAt: now.AddMinutes(2),
    allowedValues: ["follow", "leave"],
    sessionId: sessionId);

The Host writes the request in the game transaction. A transport converts its callback or HTTP request into GameInputSubmission; IGameInputRequestDispatcher atomically checks request/player/scope/value/expiry and calls the unique IGameInputRouteHandler for (GameId, Route). Re-delivery with the same correlation id calls the handler again, so the handler should use that id as the game's normal command idempotency key.

IDurableGameEffect marks an external effect that must not be delivered before commit. Narrative effects implement it by default. The Host stores them in game_effect_outbox in the same transaction as state, input requests and events. Its lease-based dispatcher deserializes the effect and invokes the registered generic effect handler after commit; it retries failures and keeps the effects of one command in original order. An adapter should therefore implement an idempotent INarrativeEffectSink, but it need not own an outbox.

Runtime assembly map

Role

Referenced by

BotFramework.Contracts

Transport-neutral service contracts, DTOs, wallet/identity/read ports and portable integration contracts

hosts, services, transport adapters, game contracts

BotFramework.Sdk

Module-facing abstractions: modules, domain/events, repositories, projections, analytics, localization, runtime contracts

backend Games.* modules

BotFramework.Sdk.Testing

xUnit helpers and in-memory test doubles

tests/CasinoShiz.Tests

BotFramework.Host

Backend/runtime infrastructure: composition, persistence, CAP/event bus, economics/wallet adapters, analytics, runtime jobs, admin/security, health

backend/monolith composition roots

BotFramework.Telegram.Abstractions

Telegram update context, routes and handler contracts

Telegram adapters and runtime

BotFramework.Scheduling.Abstractions

Transport-neutral scheduled command contracts

game application layers

BotFramework.Scheduling.Quartz

Persistent Quartz implementation of game scheduling

backend composition roots

BotFramework.Rendering

Bounded TPL render queues, content-addressed artifact cache, MinIO media history and admin read endpoints

backend, Telegram adapters and composition roots

BotFramework.Telegram

Telegram adapter runtime: bot client, polling/webhook ingress, update pipeline, router, route attributes, Telegram update context and delivery helpers

Telegram BFF, monolith compatibility host, Games.*.Telegram adapters

BotFramework.Discord

Discord adapter runtime: gateway ingress, message and interaction routing, slash commands, autocomplete, modals, component tokens, embeds, localization, UX rate limiting/cooldowns, health checks and delivery outbox

Discord BFF and Games.*.Discord adapters

The intended dependency direction is:

Games.* backend modules
  -> BotFramework.Sdk
  -> BotFramework.Contracts

Games.*.Telegram adapters
  -> BotFramework.Telegram
  -> BotFramework.Contracts / game contracts

Games.*.Discord adapters
  -> BotFramework.Discord
  -> BotFramework.Contracts / game contracts

Composition roots
  -> BotFramework.Host
  -> BotFramework.Telegram and/or BotFramework.Discord when channel ingress is enabled

Modules should not reference deployment-specific hosts. Hosts select which modules and transports are active.

Repository boundaries

framework/
  BotFramework.Contracts/   transport-neutral contracts and portable DTOs
  BotFramework.Sdk/         module, domain, persistence and event abstractions
  BotFramework.Sdk.Testing/ test helpers and in-memory doubles
  BotFramework.Host/        backend infrastructure and composition
  BotFramework.Telegram.Abstractions/ Telegram update contracts
  BotFramework.Scheduling.Abstractions/ scheduled command contracts
  BotFramework.Scheduling.Quartz/ persistent Quartz scheduler
  BotFramework.Rendering/ bounded rendering runtime and artifact/history ports
  BotFramework.Telegram/    Telegram ingress, update routing and delivery
  BotFramework.Discord/     Discord ingress, interactions, UX and delivery

games/
  Games.X.Contracts/        logical interfaces and portable DTOs
  Games.X/                  backend application/domain/infrastructure
  Games.X.Telegram/         Telegram presentation adapter
  Games.X.Discord/          Discord presentation adapter
  Games.X.Transport.Grpc/   protobuf and remote adapters when needed

host/
  CasinoShiz.Host/          combined compatibility deployment
  CasinoShiz.Backend/       Telegram-free backend process
  CasinoShiz.TelegramBff/   Telegram client process
  CasinoShiz.DiscordBff/    Discord client process
  CasinoShiz.AdminBff/      browser/admin BFF without direct database access

services/
  CasinoShiz.IdentityService/
  CasinoShiz.WalletService/

tests/
  CasinoShiz.Tests/

Not every context needs every optional project. Simple modules may only have Games.X and Games.X.Telegram. Split-service modules may additionally provide Games.X.Contracts and Games.X.Transport.Grpc.

Framework source tree

The following map describes the checked-in source layout of framework/. Build output (bin/, obj/) is intentionally omitted. The project name and namespace do not always have the same suffix: for example, the source project BotFramework.Sdk.Testing produces the package BotFramework.Testing.

framework/
├── BotFramework.Contracts/             # public, transport-neutral contracts
│   ├── Caching/                         # cache ports and cache metadata
│   ├── Cases/                            # durable human-review case contracts
│   ├── Economics/                       # wallet and economic service contracts
│   ├── Games/                           # portable game-facing contracts
│   ├── Identity/                        # player and identity contracts
│   ├── Messaging/                       # channel and request metadata
│   ├── Observability/                   # meters and telemetry contracts
│   ├── Operations/                      # operational/service contracts
│   ├── Ledger/                           # hold/capture/release/refund contracts
│   ├── RateLimiting/                    # limiter decisions and policies
│   ├── ResponsibleGaming/               # protection and player-stat contracts
│   ├── Risk/                             # policy decisions and evaluator ports
│   ├── Tenancy/                         # opaque ids and tenant context
│   └── Transport/                       # pagination and wire-level contracts
├── BotFramework.Sdk/                    # pure module and game abstractions
│   ├── Admin/                           # admin commands and effects
│   ├── Commands/                        # command and request contracts
│   ├── Configuration/                   # neutral options/validation contracts
│   ├── Domain/                          # aggregates, rules and state primitives
│   ├── Events/                          # domain events and event contracts
│   ├── Execution/                       # actions, decisions and effects
│   ├── Health/                          # health/readiness abstractions
│   ├── Metrics/                         # SDK-level telemetry abstractions
│   ├── MiniGames/                       # mini-game contracts
│   ├── Modules/                         # module registration contracts
│   ├── Projections/                     # projection/read-model contracts
│   └── Snapshots/                       # snapshot contracts
├── BotFramework.Sdk.Testing/            # test doubles for SDK consumers
│   ├── Fakes/
│   └── Repositories/
├── BotFramework.Text/                  # platform-neutral text processing
│   ├── normalization and tokenization primitives
│   ├── analyzer, matcher and policy contracts
│   ├── effect contracts and dispatch
│   └── immutable compiled-snapshot boundaries
├── BotFramework.Rest/                   # REST middleware and route support
│   └── RateLimiting/                    # REST limiter options and adapter
├── BotFramework.Client/                 # typed REST client package
│   ├── Generated/                       # NSwag-generated client; do not edit
│   ├── openapi-v1.json                  # checked-in source OpenAPI contract
│   └── openapi-client.nswag.json        # NSwag generation configuration
├── BotFramework.Telegram.Abstractions/  # transport-facing Telegram contracts
│   ├── MiniGames/
│   ├── Pipeline/
│   ├── Tenancy/
│   └── UpdateHandling/
├── BotFramework.Discord.Abstractions/  # transport-facing Discord contracts
├── BotFramework.Host/                   # backend runtime and infrastructure
│   ├── Admin/                            # admin effects and execution
│   ├── Analytics/                        # analytics/query integrations
│   ├── Caching/                          # cache implementations
│   ├── Cases/                             # durable case aggregate adapter
│   ├── Commands/                         # command middleware and dispatch
│   ├── Composition/                      # Host builders and migrations
│   ├── Configuration/                    # runtime configuration
│   ├── Contracts/                        # internal Host contracts
│   ├── DiscordOutbox/                    # Discord delivery outbox
│   ├── Economics/                        # wallet/economy implementations
│   ├── Events/                           # event bus and outbox dispatch
│   ├── Execution/                        # atomic game execution pipeline
│   ├── Fairness/                         # fairness and entropy services
│   ├── Games/                            # game runtime composition
│   ├── Health/                           # database/dependency health checks
│   ├── Localization/                     # backend localization services
│   ├── Ledger/                            # generic ledger operation adapter
│   ├── Messaging/                        # request/transport support
│   ├── Persistence/                      # PostgreSQL stores and migrations
│   ├── Random/                           # framework entropy providers
│   ├── RateLimiting/                     # Redis/DB limiter implementations
│   ├── Redis/                            # Redis infrastructure
│   ├── Runtime/                          # hosted services and lifecycle
│   ├── Security/                         # auth and authorization
│   ├── TelegramOutbox/                   # Telegram delivery outbox
│   ├── Tenancy/                          # tenant provisioning and resolution
│   └── Workflows/                         # durable command workflows, steps and replay
├── BotFramework.Telegram/                # Telegram adapter runtime
│   ├── Composition/                      # Telegram builder extensions
│   ├── Hosting/                          # hosted polling/webhook services
│   ├── Outbox/                            # Telegram delivery workers
│   ├── Pipeline/                          # update middleware
│   ├── Text/                              # text pipeline adapter and opt-in handlers
│   └── Redis/                             # Telegram adapter state
├── BotFramework.Discord/                 # Discord adapter runtime
│   ├── Commands/                          # slash/message command handling
│   ├── Composition/                      # Discord builder extensions
│   ├── Hosting/                           # gateway and hosted services
│   ├── Interactions/                      # buttons, selects, modals and tokens
│   └── Routing/                           # message and interaction routing
├── BotFramework.Scheduling.Abstractions/ # transport-neutral schedule contracts
├── BotFramework.Scheduling.Quartz/       # persistent Quartz implementation
└── BotFramework.Rendering/               # bounded rendering and media history

Where new code belongs

Need Project/location Boundary rule
Public DTO, opaque id, tenant or pagination contract BotFramework.Contracts No database, transport SDK or Host dependency
Pure game action, domain rule, effect or module contract BotFramework.Sdk Synchronous decision code must remain deterministic and I/O-free
Consumer test fake or in-memory repository BotFramework.Sdk.Testing Keep test-only helpers out of runtime packages
REST middleware, route metadata or typed HTTP client BotFramework.Rest / BotFramework.Client REST details must not leak into game/domain contracts
Telegram/Discord-specific contract BotFramework.*.Abstractions Keep channel types out of the neutral SDK
Ingress, routing, presentation or delivery BotFramework.Telegram / BotFramework.Discord Adapters call logical module contracts; they do not own persistence
PostgreSQL, Redis, outbox, migrations or atomic execution BotFramework.Host Infrastructure is selected by a composition root
Long-running command workflow, durable retry or operator replay BotFramework.Host.Workflows Modules provide commands/handlers; Host owns Wolverine and workflow persistence
Durable scheduling BotFramework.Scheduling.* Quartz is an implementation, not a module-facing contract
GIF/PNG rendering and artifact history BotFramework.Rendering Rendering runs after commit and never participates in the game transaction

Keep public package dependencies pointed inward:

BotFramework.Contracts
        ↑
BotFramework.Sdk ────────┐
        ↑                │
Abstractions ────────────┤
        ↑                │
Host ───┴── Telegram / Discord / Rest / Client adapters

BotFramework.Client/Generated/ is regenerated from openapi-v1.json; change the OpenAPI document or NSwag settings first and run the documented generation command. Do not hand-edit generated files. New framework projects should also be added to CasinoShiz.slnx, package smoke validation, public API manifests and this map when they become part of the supported surface.

Layering

┌────────────────────────────────────────────────────────────────────────────┐
│ L5  Presentation adapters                                                  │
│     games/*/*.Telegram, BotFramework.Telegram                              │
│     games/*/*.Discord, BotFramework.Discord                                │
│     channel parsing, embeds, modals, callbacks and interactions             │
├────────────────────────────────────────────────────────────────────────────┤
│ L4  Application                                                            │
│     games/*/Application                                                    │
│     services, commands, jobs, projections, use-case result records         │
├────────────────────────────────────────────────────────────────────────────┤
│ L3  Domain                                                                 │
│     games/*/Domain                                                         │
│     pure domain transitions, aggregates, policies, state machines          │
├────────────────────────────────────────────────────────────────────────────┤
│ L2  Platform contracts                                                     │
│     BotFramework.Contracts + BotFramework.Sdk                              │
│     modules, repositories, event store, projections, event bus, ports      │
├────────────────────────────────────────────────────────────────────────────┤
│ L1  Infrastructure                                                         │
│     games/*/Infrastructure + BotFramework.Host + transport projects        │
│     Postgres, Redis/CAP, ClickHouse, gRPC, migrations, jobs, admin, ops    │
└────────────────────────────────────────────────────────────────────────────┘

BotFramework.Telegram and BotFramework.Discord are intentionally not part of the backend SDK. Channels are adapter boundaries, not domain dependencies.

Physical layout

Game backend modules use a consistent directory shape:

games/Games.X/
  Application/
    Services/ Jobs/ Projections/ Results/ Analytics/
  Domain/
    Configuration/ Commands/ Entities/ Events/ Rules/ Results/
  Infrastructure/
    Persistence/ Migrations/ Modules/ Rendering/ Integrations/ Queues/

Telegram adapters live separately:

games/Games.X.Telegram/
  Handlers/
  Rendering/
  CallbackData/
  Modules/

Transport adapters live separately when the module can run across a process boundary:

games/Games.X.Transport.Grpc/
  Protos/
  Clients/
  Servers/
  Mapping/

BotFramework.Host is feature-first:

Admin/ Analytics/ Commands/ Composition/ Contracts/ Economics/ Events/
Health/ Localization/ Persistence/ Random/ Redis/ Runtime/ Security/

BotFramework.Telegram owns Telegram-specific runtime concerns:

Composition/ Ingress/ Pipeline/ Routing/ UpdateHandling/ Delivery/ Redis/

BotFramework.Sdk is also feature-first and keeps module-facing namespaces stable:

Admin/ Commands/ Configuration/ Domain/ Events/ Health/ Metrics/
MiniGames/ Modules/ Pipeline/ Projections/ Snapshots/

Atomic game execution and effects

For the system-level container, transaction and concurrency diagrams, see docs/arch.md.

Games that mutate wallets or persistent state should implement a pure IGameAction<TCommand, TState, TResult>. The action receives materialized state, wallet/quota snapshots, framework-provided entropy and UTC time. Decide performs no I/O and returns one complete GameDecision.

transport request
  -> command envelope / idempotency / sorted advisory locks
  -> load snapshots
  -> game.Decide(input)
  -> materialize and validate GameEffectPlan
  -> apply effects in deterministic transaction phases
  -> inbox result + commit
  -> asynchronous outbox delivery

Declarative consequences are split into executable effects and domain events. IDomainEvent is intentionally not an IGameEffect: events are persisted and dispatched by the event pipeline, while IGameEffect values are handled by the effect executor. Built-in categories are deliberately explicit:

  • EconomyEffect debits or credits the command wallet;
  • WalletEconomyEffect targets an explicitly declared wallet in a multi-wallet command;
  • QuotaEffect consumes, restores, or grants capacity on a declared quota;
  • IGameRecord writes module-specific history through a registered writer;
  • IDomainEvent is persisted to the transactional event outbox;
  • ScheduleEffect schedules or cancels a durable command through the schedule outbox.

State-only execution and outcome-only wagering

Not every game command needs the economy boundary. IGameStateExecutor<TCommand, TState,TResult> uses the same command envelope, idempotency inbox, aggregate locks, revision checks, PostgreSQL transaction and post-commit outboxes as the atomic executor, but does not load or mutate a wallet, quota, or player-protection state. Its effect pipeline rejects typed economy/quota effects and wallet custom effects. State-only execution is still transactional for the game aggregate; it is not fire-and-forget background work.

Use the executors according to the boundary the command actually needs:

Command needs Executor Commit boundary
Game state, wallet, quota, protection, or legacy payout/refund in one decision IAtomicGameExecutor<,,> Game state + economic effects + inbox/outbox
Game state, records, domain events or local schedules only IGameStateExecutor<,,> Game state + inbox/outbox
Wagered game play after a reservation IOutcomeOnlyGameExecutor<,,> over the state-only executor Game outcome first; Wagering settles economics later

The wager path deliberately keeps stake, balance and payout out of the game aggregate:

Ledger reserve
    -> Wagering sends game command
    -> game commits state and publishes GameOutcomeDeclared
    -> Wagering validates terms and applies the game payout policy
    -> Ledger settles or releases the reservation

The outcome-only adapter is used by the generic wager handler and by specialized Blackjack, Horse, Poker and Secret Hitler wager slices. Challenge and Pick also provide multi-party wager adapters. The game owns rules and deterministic outcome state; Wagering owns the frozen terms snapshot, payout policy and settlement operation ids.

The repository keeps legacy Atomic paths where they are still the public mode or where a command performs dynamic economics, multi-wallet refunds, quotas or legacy payout. For example, the old BlackjackGameState flow remains Atomic while the separate BlackjackWagerState flow is outcome-only. Pure state-only commands such as Poker start/message, Secret Hitler start/nominate/discard/messages, Challenge decline, Redeem issue and PixelBattle updates use IGameStateExecutor.

Generic Ledger, Risk and Case primitives

The framework exposes a domain-neutral operations layer in addition to the Wagering compatibility contracts:

  • BotFramework.Contracts.Ledger provides Hold, Capture, Release, Refund, Transfer and audited Adjustment commands plus the common LedgerOperationCompleted event. Hold/capture/release are separate from payout transfers, and refunds are bounded by captured amount.
  • BotFramework.Contracts.Risk provides IRiskEvaluator<TContext> and RiskDecision (Allow, Deny, Review, Hold). Policies stay in the application; the framework carries policy version, evidence and workflow refs.
  • BotFramework.Contracts.Cases provides the reusable Open -> Evidence -> Review -> Resolved -> Appealed lifecycle and integration commands/events. BotFramework.Host persists it in Postgres and publishes transitions through the normal outbox.

The local Postgres Ledger adapter is idempotent by operation id and uses the same tenant wallet transaction boundary as the existing wallet implementation. A remote Ledger or Case service can consume these contracts without changing game code. The single-player and multi-party wager slices now compose Hold/Capture/Release/Transfer; the old wager-specific messages remain only as a drain-compatible handler for already persisted outbox messages.

The admin BFF exposes the read-only wager workflow timeline by operation id. It joins the wager status with committed module_events (ES) and generic ledger_operations in one read query. No timeline write is added to a game commit, so observability does not add a per-step round trip to the hot path.

Admin mutations use the smaller companion kernel. An admin action produces an AdminEffectPlan<TResult> containing typed IAdminEffect values. The Host opens one transaction, resolves exactly one handler for every effect type, applies the finite materialized list in order, appends admin_audit, and commits. Handlers get the restricted IAdminExecutionContext, not NpgsqlConnection or a transaction. Runtime configuration is the first migrated effect (RuntimeConfigurationPatchEffect), so the JSONB update and its audit record cannot commit separately.

The same kernel now covers the administrative write paths for Users, Ledger, Games.Admin, and the Meta season/quest/alert pages:

Area Typed effects Transactional consequences
Users WalletAdjustmentAdminEffect wallet row, version, ledger line and admin_audit
Ledger LedgerRevertAdminEffect locked source line, compensating wallet/ledger update and audit
Games.Admin ClearChatBetsAdminEffect, DisplayNameOverrideAdminEffect pending-bet deletion/refund or override upsert/delete and audit
Meta seasons MetaSeason*AdminEffect season status/config/plans, reward credits, meta_event_log and audit
Meta alerts MetaAlertStatusAdminEffect risk flag state, meta_event_log and audit
Meta quests MetaQuestCatalogSaveAdminEffect, MetaQuestCatalogReloadAdminEffect validated catalog replacement/reload and audit

Pages keep their existing read models and response contracts. Only the mutation boundary goes through the executor, so the monolith and split BFF paths share the same effect handlers. Reward effects use deterministic operation ids; retrying a season payout therefore cannot create a second ledger entry. The clear-bets effect refunds and removes all supported mini-game rows in the same PostgreSQL transaction, then clears process/distributed session state after commit.

The quest catalog is currently file-backed for compatibility. Its effect writes a temporary file and atomically replaces the catalog before recording the audit row; the filesystem replacement cannot be rolled back by PostgreSQL. A future DB/object store catalog can use the same effect contract for full database atomicity.

Workflow effects and scheduled work

Not every mutation starts as a pure game decision. Quest progress/claims, tournament lifecycle commands and season settlement use IAtomicEffectExecutor with the same finite, typed effect discipline. A module registers one AtomicEffectHandler<T> per effect; the Host supplies only a restricted SQL context, takes the declared locks, checks the inbox, applies all effects, stores the result and commits once. These handlers do not receive NpgsqlConnection, a transaction, or a service locator. Deterministic operation ids make wallet rewards safe to retry.

Quartz remains the trigger and durable schedule store, not the business transaction boundary. ScheduleExecutionPolicy makes background semantics explicit:

  • Misfire: FireOnce, Ignore, or DoNothing;
  • Concurrency: Disallow (the default) or Allow;
  • BatchSize: a bounded amount of work passed as batch-size to the command;
  • MaxAttempts and RetryBackoff: bounded command-level retries.

The scheduler maps these values to Quartz trigger/job settings. The scheduled command itself still enters the atomic/effect executor, so a retry or a process restart cannot partially settle a season. Heavy rendering and analytics remain post-commit consumers; they must not be hidden inside a scheduled effect handler.

Typed configuration validation

Modules register tunable options and their validator explicitly:

services.BindOptions<HorseOptions, HorseOptionsValidator>(HorseOptions.SectionName);

public sealed class HorseOptionsValidator
    : FluentConfigurationValidator<HorseOptions>
{
    public HorseOptionsValidator()
    {
        RuleFor(x => x.HorseCount).InclusiveBetween(2, 16);
        RuleFor(x => x.TimezoneOffsetHours).InclusiveBetween(-14, 14);
    }
}

IConfigurationValidator<TOptions> lives in BotFramework.Sdk and depends only on neutral validation result types. FluentConfigurationValidator<TOptions> is a Host adapter; modules may use it without coupling their domain contracts to FluentValidation. The same validator runs at startup (ValidateOnStart) and for admin preview/apply. Runtime JSON is deserialized strictly: unknown sections, unknown nested properties, invalid shapes, and semantic rule failures are rejected with structured path, code, and message issues. A valid patch is normalized, previewed as effective typed options, and only then handed to the admin effect executor.

Contract at a glance

The SDK portion of the system has no database, DI, transport, outbox, or async dependency. A game action is a synchronous function from an input snapshot to a decision:

public interface IGameAction<TCommand, TState, TResult>
{
    GameDecision<TState, TResult> Decide(
        GameActionInput<TState, TCommand> input);
}

GameActionInput contains the command, loaded state, primary-wallet snapshot, declared quota snapshots, named entropy and the framework UTC timestamp. The action must not read the clock, generate randomness, resolve services, query a database, publish messages, or start background work.

public sealed class CoinFlipAction
    : IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>
{
    public GameDecision<NoGameState, CoinFlipResult> Decide(
        GameActionInput<NoGameState, CoinFlipCommand> input)
    {
        if (input.Wallet.Balance < input.Command.Stake)
            return new(
                DecisionStatus.Rejected,
                input.State,
                CoinFlipResult.InsufficientBalance,
                [], [], [], [], [],
                RejectionReason: "insufficient_balance");

        var won = input.Entropy.GetDouble("coin-flip") >= 0.5;
        var economy = won
            ? new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake"),
                      EconomyEffect.Credit(input.Command.Stake * 2, "coinflip.win") }
            : new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake") };

        return new(
            DecisionStatus.Accepted,
            input.State,
            new CoinFlipResult(won),
            economy,
            [],
            [],
            [new CoinFlipCompleted(input.Command.UserId, won, input.UtcNow)],
            []);
    }
}

The descriptor supplies infrastructure metadata without putting it in the domain command: game id, command id, aggregate id, wallet identity, quota identities, entropy names, and additional lock keys. Lock keys are acquired in stable sorted order.

public sealed class CoinFlipDescriptor
    : GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>
{
    public override string GameId => "coinflip";
    public override string CommandId(CoinFlipCommand command) => command.CommandId;
    public override string AggregateId(CoinFlipCommand command) =>
        $"{command.ChatId}:{command.UserId}";
    public override long ChatId(CoinFlipCommand command) => command.ChatId;
    public override string DisplayName(CoinFlipCommand command) => command.DisplayName;
    public override WalletIdentity Wallet(CoinFlipCommand command) =>
        new(command.UserId, command.ChatId);
    public override IReadOnlyList<string> EntropyNames => ["coin-flip"];
}

Register the action, descriptor and state store explicitly in the backend module. This keeps discovery deterministic and avoids a reflection DSL:

services
    .AddScoped<IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>,
        CoinFlipAction>()
    .AddScoped<GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>,
        CoinFlipDescriptor>()
    .AddScoped<IGameStateStore<CoinFlipCommand, NoGameState>,
        CoinFlipStateStore>();

The compatibility application service depends on IAtomicGameExecutor<CoinFlipCommand,NoGameState,CoinFlipResult> and delegates the complete mutation path to it. Transport handlers continue to depend on the existing logical game contract.

Effect categories and guarantees

Effect Target Transactional behavior
EconomyEffect Descriptor primary wallet Balance check, wallet update and ledger row; protected wager debits pass player-protection checks
WalletEconomyEffect Explicit (userId, balanceScopeId) Multi-wallet balance update and ledger row; every wallet lock must be declared by the descriptor
QuotaEffect.Consume Declared quota Increases used capacity and rejects overflow
QuotaEffect.Restore Declared quota Returns consumed capacity, clamped at zero
QuotaEffect.Grant Declared quota Adds future capacity and may move usage below zero
IGameRecord Module history table Written by exactly one registered typed record writer
custom IGameEffect Module-owned state Applied by exactly one typed handler through IGameExecutionContext
IDomainEvent Game event outbox Written once per command/event index and delivered after commit
ScheduleEffect Schedule outbox Durable schedule/cancel request delivered after commit

WalletEconomyEffect is intentionally separate from primary-wallet EconomyEffect. It supports transfers, challenge stakes, race payouts, and other multi-wallet decisions. It enforces non-negative balances and ledger consistency, but primary-wallet player-protection checks are not implicitly reapplied to arbitrary target wallets. A module introducing protected multi-wallet wager debits must model and test that policy explicitly.

GameEffectSet.MaterializeEffects() materializes executable categories before the first mutation; its Events collection remains separate. GameEffectPlan rejects null effects, mutations on rejected decisions, unknown quotas, missing or duplicate handlers, and built-in effects placed in the custom category. Effects are not represented by IAsyncEnumerable: laziness would make the transaction boundary and retry semantics depend on enumeration progress.

The Host applies effects in a fixed order inside one PostgreSQL transaction:

player protection
  -> economy
  -> quotas
  -> aggregate state
  -> module records
  -> custom typed effects
  -> event outbox
  -> schedule outbox
  -> command inbox result
  -> commit

Economy ledger rows, events and schedules use batch SQL. Quota and custom effects are grouped once while building the plan. PostgreSQL commands on one connection are still awaited sequentially; parallel commands on the same transaction are not supported by Npgsql.

Modules can define additional typed effects without receiving the connection or transaction directly:

public sealed record AchievementEffect(
    long UserId,
    string AchievementId) : IGameEffect;

public sealed class AchievementEffectHandler
    : GameEffectHandler<AchievementEffect>
{
    protected override Task ApplyBatchAsync(
        IReadOnlyList<AchievementEffect> effects,
        IGameExecutionContext context,
        CancellationToken ct) =>
        context.ExecuteAsync(
            """
            INSERT INTO achievements (user_id, achievement_id)
            SELECT item.user_id, item.achievement_id
            FROM unnest(@UserIds, @AchievementIds)
                AS item(user_id, achievement_id)
            ON CONFLICT DO NOTHING
            """,
            new
            {
                UserIds = effects.Select(effect => effect.UserId).ToArray(),
                AchievementIds = effects.Select(effect => effect.AchievementId).ToArray(),
            },
            ct);
}

Register the handler explicitly:

services.AddAtomicGameEffectHandler<AchievementEffectHandler>();

Then include materialized instances in GameDecision.CustomEffects. Handlers of the same effect type receive one batch. Their Order values and effect type names provide stable ordering. A handler failure rolls back every earlier mutation, including wallet, quota, state, records and outbox rows.

External work that must not affect the game commit belongs behind a committed domain event/outbox consumer. Analytics, Telegram delivery and other remote calls must not be implemented as transactional custom effect handlers.

Durable workflow boundary

The framework exposes BotFramework.Host.Workflows as a reusable long-running workflow primitive. A module implements immutable commands with IDurableWorkflowCommand, registers its handler assembly, and calls:

builder.AddDurableWorkflows(typeof(MyWorkflowHandler).Assembly);

The framework owns PostgreSQL-backed durable command delivery, correlation, partitioning, retries, step persistence, replay and generic saga state. The module uses IDurableWorkflowDispatcher at its transport/application boundary and IDurableWorkflowStepExecutor inside a command handler. Domain mutation remains in the module's own AtomicEffect/transaction boundary.

durable_workflow_steps stores workflow/command/causation ids, command type, original command_json, payload, result, status and terminal marker. It is an operator-visible timeline and recovery projection, not the source of domain truth. IDurableWorkflowReplayService requeues a failed or non-terminal command while keeping its original command id, so application inboxes and external operation-id idempotency can protect against duplicate mutations.

The smallest consumer example is samples/CoinFlip/CoinFlip.Workflow/CoinFlipWorkflow.cs. It shows the command, handler and transport/application dispatcher boundary without importing any game-specific workflow code into the framework.

Meta's tournament workflow is the first consumer. Its Wallet mutations are workflow steps, outside the local Backend transaction: join entry fees, final prizes and cancellation refunds use stable Wallet operation ids. Definitive local rejection triggers a compensating .rollback mutation. A process/network failure is retried durably with the same command and operation ids; the client may receive Pending while that retry continues. The existing Telegram and Discord client outboxes remain the delivery mechanism for notifications, and the CAP domain-event outbox remains unchanged.

Integration messaging, inbox and recovery

The framework owns the integration transport boundary. Modules publish typed contracts and implement handlers; they do not instantiate CAP, Kafka clients, Wolverine or a shared database connection. Register the boundary once in the composition root:

builder.AddFrameworkIntegrationMessaging("wallet");
builder.AddDurableWorkflows(typeof(BetWorkflowHandler).Assembly);

AddFrameworkIntegrationMessaging creates the PostgreSQL integration inbox and outbox, schema quarantine store, route resolver and the selected CAP transport. The service name becomes the consumer group, so replicas of one logical service share delivery while different services receive their own copy of events. Use Local for in-process tests/development, Redis for Redis Streams, or Kafka for Kafka/Redpanda:

{
  "Messaging": { "Transport": "Kafka" },
  "Kafka": {
    "Servers": "redpanda:9092",
    "MainConfig": {
      "security.protocol": "SASL_SSL"
    }
  }
}

Kafka:BootstrapServers is accepted as an alias for Kafka:Servers. Broker credentials and TLS settings belong in deployment secrets or provider configuration; they are not part of module code.

Publishing an integration contract

Events implement IIntegrationEvent; addressed requests implement IIntegrationCommand. Prefer the typed publishers:

public sealed record FundsReserved(
    string BetId,
    long Amount,
    DateTimeOffset OccurredAt) : IIntegrationEvent
{
    public string EventType => "wallet.funds_reserved";
}

await eventPublisher.PublishAsync(
    new FundsReserved(betId, amount, clock.UtcNow), ct);

await commandPublisher.SendAsync(
    new ReserveFundsForBet(betId, amount, clock.UtcNow), ct);

The publisher creates a stable envelope containing message id, contract type, schema version, correlation/causation metadata and tenant/scope/player metadata. It writes the complete envelope to integration_outbox_messages; the relay later publishes that retained envelope. Domain code is therefore not run again when a relay retries a message.

When a publisher is called while the framework integration inbox is processing a message, its outbox row uses the same PostgreSQL connection and transaction. The consumed message, local domain mutation and outgoing message then commit or roll back together. A lower-level adapter that already has an outbox record can make the boundary explicit:

await outbox.EnqueueAsync(
    message,
    IntegrationTransactionContext.From(inboxContext),
    ct);

Use IIntegrationInboxContextAccessor.Current when the handler needs the ambient context. Do not open a second connection for a mutation that must be atomic with inbox completion.

Topic and key routing

IIntegrationMessageRouter supplies a default route based on message kind, tenant, scope and aggregate/player identity. The route is persisted with the outbox envelope, and the message key is forwarded to Kafka/Redpanda as cap-kafka-key. This preserves ordering for one aggregate without using a single global partition key.

A contract may override the route for addressable bounded-context delivery:

public sealed record ReserveFundsForBet(
    string BetId,
    long Amount,
    DateTimeOffset OccurredAt) : IIntegrationCommand, IIntegrationMessageRouted
{
    public string CommandType => "wallet.reserve_funds";
    public string? Topic => "wallet.commands.v1";
    public string? MessageKey => $"bet:{BetId}";
}

Commands should use an explicit owning-service topic. Events may use a bounded-context event topic and multiple consumer groups. A command must not be broadcast to unrelated services; the receiving service remains responsible for authorization and idempotent handling.

Inbox and handler atomicity

Every integration consumer is at-least-once. The framework wraps a resolved handler in IIntegrationInbox.ExecuteOnceAsync. The inbox key is the logical consumer plus tenant/scope/message id. A duplicate does not invoke the handler again; a completed result is replayed from the inbox.

Handlers that write service-owned state should use the transaction supplied by the framework's repositories or by IntegrationInboxContext:

public async Task HandleAsync(
    ReserveFundsForBet command,
    CancellationToken ct)
{
    var inbox = inboxContextAccessor.RequireCurrent();

    await walletStore.ReserveAsync(
        command.BetId,
        command.Amount,
        inbox.Connection,
        inbox.Transaction,
        ct);

    await eventPublisher.PublishAsync(
        new FundsReserved(command.BetId, command.Amount, clock.UtcNow), ct);
}

The handler must only mutate its own service database. Cross-service effects are integration commands/events and are committed to the local outbox; there is no distributed transaction.

Schema validation and quarantine

Before handler resolution, the dispatcher validates the schema version, stable contract type, JSON payload and the payload's declared message type. The current supported envelope schema is version 1. Invalid or unsupported messages are written to the service-owned integration quarantine table with a stable error code and acknowledged, preventing an infinite retry loop.

Quarantine is an operational safety boundary, not a successful business transition. Operators must inspect the payload and either deploy a compatible consumer, replay a corrected message, or move the owning workflow to ManualReview according to its domain policy. Financial quarantine must never silently mark a bet or settlement as completed.

Durable workflow timeout recovery

Long-running workflows keep their saga state in the owning module and use the generic framework recovery service for timeout delivery:

await recovery.ScheduleTimeoutAsync(
    new DurableWorkflowTimeoutRequest(
        TimeoutId: $"bet:{betId}:acceptance-timeout",
        WorkflowId: $"bet:{betId}",
        CommandId: $"bet:{betId}:acceptance-timeout:v1",
        Operation: "bet.acceptance_timeout",
        DueAt: clock.UtcNow.AddMinutes(2),
        Command: new AcceptanceTimeout(betId),
        AggregateId: betId,
        MaxAttempts: 10),
    transaction: IntegrationTransactionContext.From(inboxContext),
    ct: ct);

await recovery.CancelTimeoutAsync(
    $"bet:{betId}:acceptance-timeout", ct);

IDurableWorkflowRecoveryService persists timeout rows in durable_workflow_timeouts, claims them with leases and SKIP LOCKED, sends the immutable command through Wolverine, retries failures with bounded backoff, and exposes timeout inspection by workflow id. Schedule a timeout in the same local transaction as the state transition that enters the waiting phase. Cancel it when that phase completes. Timeout handlers must verify the current saga phase and remain idempotent because delivery is at-least-once.

Service database ownership checks

Enable ownership checks after provisioning a dedicated PostgreSQL role for the service:

{
  "ServiceOwnership": {
    "Enforce": true,
    "Schema": "wallet",
    "ExpectedDatabase": "wallet",
    "RequireNonSuperuser": true,
    "RequireSchemaCreate": true
  }
}

At startup the framework verifies the current database, schema existence, USAGE/CREATE privileges and that the runtime role is neither a superuser nor BYPASSRLS. A violation fails readiness/startup and increments service.ownership.violations. Enforce is false by default to preserve existing deployments during migration; it should be enabled per service once the ownership boundary is ready.

The framework exposes counters for outbox enqueue/publish/failure/depth, inbox duplicates/result replays/handler failures, schema rejection/quarantine, workflow timeout schedule/dispatch/retry and ownership violations. The metrics contain service and route dimensions only; tenant, scope and player identifiers remain in logs/traces.

State stores and concurrency profiles

IGameStateStore<TCommand,TState> loads and saves module state through the limited IGameExecutionContext. It never owns the transaction. The framework also provides a JSON aggregate store for ordinary versioned state; modules may provide transaction-aware stores for existing normalized tables.

Choose the smallest aggregate key that protects a real invariant:

  • user game: game:{chatId}:{userId};
  • turn-based table: table:{inviteCode};
  • multi-wallet operation: one aggregate plus every affected wallet lock;
  • realtime board: tile:{index}, so different cells remain parallel;
  • batch settlement: race/table id plus the complete declared payout-wallet set.

Commands with the same aggregate key execute sequentially. Commands with different aggregate keys can run concurrently unless they share another declared lock such as a wallet or quota. Do not use a global game lock merely because the game has a global read model.

Turn-based games can use the SDK types in Execution/TurnBased for common revision, actor, turn and terminal-state validation. They still emit a normal materialized GameDecision; there is no task tree or hidden saga.

For a conventional multiplayer rotation, TurnEngine<TPlayerId> is the opt-in state machine above that validation. It owns only generic mechanics: admission before start, declared cyclic order, current-player rotation, round and turn numbers, deadline-safe timeout handling, suspension/resumption and a player leaving. A game embeds TurnEngineState<TPlayerId> in its own versioned state and continues to own board rules, scoring, win conditions and any wallet effects.

var turns = new TurnEngine<PlayerId>();
var started = turns.Start(
    turns.CreateWaiting([firstPlayer, secondPlayer]),
    turnDeadline: clock.UtcNow.AddMinutes(1));

// Persist this token in the input request and in the scheduled timeout command.
var turn = started.State.CurrentTurn;

var next = turns.Advance(
    started.State,
    turn,
    nextTurnDeadline: clock.UtcNow.AddMinutes(1));

TurnToken<TPlayerId> contains both the monotonic turn number and the player. Pass it back to Advance, Suspend, Resume, or Timeout. A delivery for an earlier token is rejected as stale_turn, so an old timer or input cannot act on a later turn of the same player. Timeout additionally rejects an early delivery as turn_not_expired; it does not create a timer itself because the game alone knows the concrete command that should be scheduled. Use the normal ScheduleEffect for that command and include the token in its payload.

For game rules that need to return state, effects and turn progression together, use TurnResult<TState, TPlayerId> and apply it with the same TurnEngine. This keeps game rules pure while ensuring an old input cannot accidentally commit its state or effects after the FSM rejects its turn token.

TurnResult<TableState, PlayerId> Handle(TableState state, ChooseMove command)
{
    var input = new InputRequestEffect(
        requestId: $"move:{state.Id}:{state.Revision + 1}",
        expectedPlayerId: command.PlayerId.ToString(),
        scopeId: state.Id,
        route: "table.choose-move",
        expiresAt: clock.UtcNow.AddMinutes(1));

    return new(
        State: state with { PendingMove = true },
        Effects: GameEffectSet.Empty,
        Completion: TurnCompletion.WaitFor<PlayerId>(input));
}

var applied = turns.Apply(
    currentState: state,
    turns: state.Turns,
    expectedTurn: command.Turn,
    result: Handle(state, command),
    bindTurns: static (nextState, nextTurns) => nextState with { Turns = nextTurns });

Continue() retains the player and deadline, Complete() finishes the game, and PassTo(player, deadline) starts a new numbered turn for an admitted player. WaitFor(input) retains the current player, uses the input expiry as the turn deadline, and adds that ordinary InputRequestEffect to the effect set. The input request is still consumed and correlated by the generic input runtime; neither the result nor the turn engine knows whether Telegram, web or a test harness delivers it. If a wait has a timeout policy, emit the matching token-bearing ScheduleEffect beside the result as usual.

TurnResultTransition.ToGameDecision(...) maps the applied result back to the ordinary atomic action contract, including all six effect categories. This lets an existing IGameAction use the primitive without a separate effect adapter.

Phases, rounds and deadline commands

RoundEngine<TPhase> is the companion for games whose flow is not merely a cyclic player order: quizzes, card-game stages, voting, draft/pick phases, waves, and multi-round contests. It deliberately has no fixed phase graph. The game chooses Setup -> Play -> Vote -> Resolve (or any other graph), while the engine owns lifecycle, monotonically numbered phase occurrences and stale-input protection.

var rounds = new RoundEngine<TablePhase>();
var started = rounds.Start(
    rounds.CreateWaiting(TablePhase.Deal),
    firstPhaseDeadline: clock.UtcNow.AddMinutes(1));

var phase = started.State.CurrentPhase; // (round: 1, phaseNumber: 1, Deal)
var next = rounds.AdvanceTo(
    started.State,
    phase,
    TablePhase.Betting,
    nextPhaseDeadline: clock.UtcNow.AddMinutes(2));

RoundPhaseToken<TPhase> contains the logical round, a monotonically increasing phase number, and the game-defined phase id. Persist that exact token in input and timeout commands. RoundResult<TState, TPhase> is the phase analogue of TurnResult: a pure rule returns state, a GameEffectSet, and one of Continue, AdvanceTo, NextRound, WaitFor, or Complete. Calling Apply(...) validates the token before returning any new state or effects; ApplyTimeout(...) additionally rejects a timer delivered before its deadline.

The scheduler is already durable: ScheduleEffect is committed into the schedule outbox and only then delivered to the configured scheduler. Use IRoundDeadlineCommand<TPhase> and RoundDeadlineSchedule to bind a scheduled atomic command to the exact phase without a transport dependency:

public sealed record PhaseExpired(RoundPhaseToken<TablePhase> ExpectedPhase)
    : IRoundDeadlineCommand<TablePhase>;

var timeout = new PhaseExpired(next.State.CurrentPhase);
var effect = RoundDeadlineSchedule.Schedule(next.State.PhaseDeadline!.Value, timeout);

// When a phase changes early, atomically cancel the old timer and schedule the new one.
var timerEffects = RoundDeadlineSchedule.Replace(
    phase,
    next.State.PhaseDeadline!.Value,
    timeout);

The AtomicGameScheduledCommand<PhaseExpired, ...> registration remains the same as for any other scheduled command. Its handler passes ExpectedPhase to ApplyTimeout; an old Quartz delivery is therefore harmless even if it races a phase transition or its cancellation. Declare round-based.rounds, round-based.phases, round-based.deadlines, and scheduling.timers in a game manifest when that module exposes these mechanics.

Multiplayer lobby, visibility and rules

LobbyEngine<TPlayerId> is a pure aggregate component for multiplayer setup: players and spectators, deterministic numbered seats, player limits, readiness, teams, roles, start and cancellation. It has no concept of an invite transport, wallet or UI. MatchmakingEngine<TPlayerId, TMatchKey> is a separate pure FIFO queue: it forms the earliest exact compatible bucket of a requested size, then the application creates a regular lobby for those players. The game defines the compatibility key and owns rating windows, invites and bot-fill policy. A game embeds LobbyState<TPlayerId> and uses the started players as the input order for its own TurnEngine, RoundEngine, or another rule model.

var lobby = new LobbyEngine<string>(new LobbyConfiguration(2, 6));
var first = lobby.JoinPlayer(lobby.Create(), "alice", teamId: "red");
var second = lobby.JoinPlayer(first.State, "bob", teamId: "blue");
var ready = lobby.SetReady(lobby.SetReady(second.State, "alice").State, "bob");
var started = lobby.Start(ready.State);

GameAudience selects Public, Participants, Spectators, one Player, a Team, or a Role. LobbyAudienceResolver<TPlayerId> resolves that selector against the durable lobby snapshot for a game projection. Presentation effects can carry the same selector in PresentationAddress.Audience, so a Web or another frontend can deliver a role/team-private effect without any Telegram type entering a game. Direct RecipientId and an audience are intentionally exclusive. This is a routing/visibility primitive, not encryption: do not put a private game state snapshot in a public effect or read model.

Use GameRuleSet<TContext> to keep shared validation ordered and explicit:

var rules = new GameRuleSet<PlayContext>(
[
    GameRule.Require<PlayContext>("active", c => c.IsActive, GameRuleRejection.GameNotActive),
    GameRule.Require<PlayContext>("member", c => c.IsMember, GameRuleRejection.PlayerNotJoined),
    GameRule.Require<PlayContext>("turn", c => c.IsCurrentPlayer, GameRuleRejection.NotYourTurn),
]);

var check = rules.Evaluate(context); // first stable Code, e.g. "not_your_turn"

Standard reasons include not_your_turn, insufficient_players, phase_closed, stale_input, deadline_expired and invalid_input; games can also create a namespaced GameRuleRejection.Custom(...) without losing a common shape.

Cards and grid boards

Card<TCardId, TFace>, Deck<TCardId, TFace> and Hand<TCardId, TFace> are immutable card-state primitives. A card id identifies a physical copy, so a deck can contain several equal faces. Deck.Draw returns both the drawn cards and its remaining deck; Deck.Shuffle accepts IGameRandom, preserving the normal entropy/replay guarantees. A game owns dealing order, discard piles, hand visibility and card-specific rules.

var draw = deck.Shuffle(random, "deal").Draw(2);
var nextHand = hand.Add(draw.Cards);
var nextDeck = draw.RemainingDeck;

GridBoard<TPieceId, TValue> combines GridBounds, GridPosition and unique BoardPiece values. A BoardMove includes its expected source location, so TryMove detects stale input. It handles empty-destination moves and an explicit generic capture mode; chess/poker-style ownership, legal paths, promotion, score and win conditions remain game rules.

var move = new BoardMove<string>("white-knight", new(0, 1), new(2, 2));
var result = board.TryMove(move, BoardMoveMode.RequireEmptyDestination);
if (result.Applied)
    board = result.Board;

Deterministic randomness and execution history

An executor supplies named cryptographic EntropyValue values and persists them with command idempotency. Use EntropyGameRandom inside the pure action rather than Random.Shared: it consumes each declared name at most once, supports integers, chance, picks and Fisher-Yates shuffle, and exposes a GameRandomTrace for game-level audit records. EntropyNames.ForDraws and ForShuffle generate the matching descriptor names.

var random = new EntropyGameRandom(input.Entropy);
var card = random.Pick("deal:0", deck);
var shuffled = random.Shuffle("deck", deck);

For commit/reveal outcomes exposed to players, Host's existing IRandomOutcomeGenerator remains the verifiable SHA-256 fairness service. The new random facade does not generate seeds or bypass that audit; it makes normal game rules deterministic from the executor-provided entropy.

Host now records every normal atomic or state-only decision in game_execution_history in the same transaction as state, inbox and outboxes. Each GameExecutionHistoryEntry contains command and result payloads, before/ after state snapshots, decision/rejection, entropy and every declared effect or domain event. IGameExecutionHistoryReader exposes lookup by command and a bounded aggregate timeline. Rollback writes no entry. Raw payloads can include private cards, roles or input, therefore the reader is for an authorized internal/admin projection only; the normal tenant RLS boundary applies to its rows and public clients must receive a separate safe view.

Idempotency and retry behavior

The transport supplies a stable command id. Before loading state, the executor locks the command and aggregate and checks game_command_idempotency:

  • a new command stores entropy, applies the decision and persists the result;
  • a duplicate returns the previously serialized result without reapplying effects;
  • a failure or cancellation before commit rolls back state, ledger, records, inbox completion and outbox rows together;
  • a response lost after commit is recovered by retrying the same command id.

Transport payloads do not need to expose the id publicly. Telegram message or callback identity, gRPC metadata, and HTTP idempotency headers can populate the framework envelope. Generating a fresh id inside a retrying facade does not provide lost-response recovery.

When adding or migrating a mutation:

  1. Put rules and calculations in a synchronous IGameAction.
  2. Pass time and named entropy through GameActionInput.
  3. Return every database consequence as state or a materialized effect list.
  4. Declare every quota and every additional wallet/aggregate lock up front.
  5. Keep the state store and custom handlers transaction-aware and transport-free.
  6. Publish analytics and remote notifications from committed events.
  7. Test deterministic decisions, rejection without effects, rollback, duplicate command ids, concurrent same/different aggregates, and lost-response replay.
  8. Keep read-only queries outside the executor; the effect system is for commands.

Rendering, batching and media history

Heavy GIF/image generation is runtime work, not an atomic game effect. A game first commits its decision, then submits an immutable render specification to IRenderQueue. BotFramework.Rendering describes the specification with a content-addressed RenderKey, checks the shared artifact store, deduplicates identical in-flight work, and runs a registered IRenderJob<TSpec> in a bounded TPL Dataflow worker.

committed game result
  -> immutable render spec
  -> content key (renderer + version + input hash)
  -> memory/in-flight dedup
  -> MinIO cache hit -------------------------------> artifact
  -> bounded TPL queue -> CPU render -> MinIO put --> artifact
  -> optional RenderHistoryEntry manifest

There are separate interactive and background/prewarm queues. They share one global MaxParallelism semaphore, so together they cannot exceed the configured CPU budget. Queue capacity provides backpressure; PrewarmAsync may accept a whole matrix, but it cannot create unbounded active renders. This is intentionally not IAsyncEnumerable: one render is one finite, awaitable artifact.

A renderer is explicit and versioned:

public sealed class BoardRenderJob : IRenderJob<BoardRenderSpec>
{
    public RenderKey Describe(BoardRenderSpec spec) =>
        new("board", "3", HashCanonical(spec), "png", "image/png");

    public ValueTask<RenderOutput> RenderAsync(BoardRenderSpec spec, CancellationToken ct) =>
        ValueTask.FromResult(RenderOutput.FromBytes(RenderBoard(spec), "board.png"));
}

services.AddRenderJob<BoardRenderSpec, BoardRenderJob>();

RendererVersion must change whenever pixels, encoding, fonts, localization or canonicalization change. Specifications must contain every input that affects the bytes and must not read mutable game state during rendering.

Horse registers deterministic winner/variant GIF specifications. Quartz runs horse.render-prewarm at 03:15 UTC and feeds the complete winner × variant matrix through the bounded prewarm queue; a cache miss is still rendered on demand. Poker board PNGs use the same queue and content deduplication.

When MinIO is enabled, artifacts live under artifacts/{renderer}/{version}/{hash}.{extension}. Match history stores small JSON manifests under history/{game}/{aggregate}/...; manifests point to the same immutable artifact instead of copying GIF/PNG bytes. Authenticated admin inspection is available through:

  • GET /admin/render-history/{gameId}/{aggregateId}?take=50;
  • GET /admin/render-artifact/{rendererId}/{version}/{hash}.{extension}.

Configuration is under Rendering:

{
  "Rendering": {
    "QueueCapacity": 64,
    "MaxParallelism": 0,
    "MaxArtifactBytes": 33554432,
    "Minio": {
      "Enabled": true,
      "Endpoint": "minio:9000",
      "AccessKey": "minioadmin",
      "SecretKey": "change-me",
      "Bucket": "casinoshiz-media",
      "Secure": false
    }
  }
}

MaxParallelism = 0 selects half of the available logical processors (at least one). MinIO is a cache/history dependency, not a transaction participant. Read or write failure falls back to a transient artifact for the current response; history writes are retried and logged without changing an already committed game result. Production credentials must come from secrets, not committed settings.

Host composition

The combined compatibility host can compose backend and channel adapters in one process. The example below enables Telegram; Discord can be enabled by adding the Discord composition and the corresponding channel modules.

var builder = WebApplication.CreateBuilder(args);

builder.AddBotFramework()
    .AddModule<DiceModule>()
    .AddModule<PokerModule>()
    .AddModule<SecretHitlerModule>();

builder.AddTelegramFramework()
    .AddTelegramModule<DiceTelegramModule>()
    .AddTelegramModule<PokerTelegramModule>()
    .AddTelegramModule<SecretHitlerTelegramModule>();

var app = builder.Build();

app.UseBotFramework();
app.UseTelegramFramework();

app.Run();

A split deployment composes the backend and each channel BFF separately. The backend-facing game contracts stay the same in both cases.

Backend process:

var builder = WebApplication.CreateBuilder(args);

builder.AddBackendFramework()
    .AddModule<DiceModule>()
    .AddModule<PokerModule>()
    .AddModule<SecretHitlerModule>();

var app = builder.Build();

app.UseBackendFramework();

app.Run();

Discord BFF process:

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddDiscordBackend();

// Each client uses Backend:GameAddresses:<GameId> when it is set;
// Backend:GrpcAddress remains the monolith/single-backend fallback.
builder.Services.AddDiceGrpcClient(/* resolved Dice address */);
builder.Services.AddPokerGrpcClient(/* resolved Poker address */);
builder.Services.AddDiceDiscord();
builder.Services.AddPokerDiscord();

var app = builder.Build();
app.UseDiscordBackend();
app.Run();

A channel BFF owns ingress, presentation and remote client wiring. It must not open a game or service database directly.

Telegram BFF process:

var builder = WebApplication.CreateBuilder(args);

builder.AddTelegramBff()
    .AddTelegramModule<DiceTelegramModule>()
    .AddTelegramModule<PokerTelegramModule>()
    .AddTelegramModule<SecretHitlerTelegramModule>();

var app = builder.Build();

app.UseTelegramFramework();

app.Run();

The composition root decides whether a contract is implemented locally or through a transport adapter such as gRPC. Application-facing interfaces do not change when a module crosses the process boundary.

Module registration

Backend modules implement the framework module contract and register their services:

public sealed class MyGameModule : IModule
{
    public void ConfigureServices(IModuleServiceCollection services)
    {
        services.AddApplicationService<MyGameService>();
        services.AddProjection<MyProjection>();
        services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);
        services.AddMigrations<MyGameMigrations>();
    }
}

Telegram modules register handlers and presentation services:

public sealed class MyGameTelegramModule : ITelegramModule
{
    public void ConfigureServices(ITelegramModuleServiceCollection services)
    {
        services.AddHandler<MyGameCommandHandler>();
        services.AddRenderer<MyGameRenderer>();
    }
}

Discord modules follow the same rule with channel-specific handlers:

public static class MyGameDiscordModule
{
    public static IServiceCollection AddMyGameDiscord(
        this IServiceCollection services) => services
        .AddScoped<IDiscordMessageHandler, MyGameDiscordHandler>()
        .AddScoped<IDiscordInteractionHandler, MyGameDiscordInteractionHandler>();
}

IDiscordMessageHandler and IDiscordInteractionHandler call logical game contracts. They do not move game state or persistence into the channel adapter.

A new module should be able to start with a small set of contracts and grow only when it needs persistence, projections, transport adapters or Telegram presentation.

Telegram update pipeline and routing

Every Telegram update flows through the Telegram update pipeline and then the attribute router:

Exception → Deduplication → Logging → RateLimit → KnownChats → UpdateRouter

Routing is attribute-driven. A Telegram adapter adds a handler by implementing IUpdateHandler, decorating it with route attributes and registering it in the Telegram module:

services.AddHandler<MyHandler>();

Supported route attributes include:

[Command]
[CallbackPrefix]
[CallbackFallback]
[MessageDice]
[ChannelPost]

Only the Telegram layer knows Telegram update types, bot clients and rendering details. Backend services receive application commands through logical contracts.

Discord interaction pipeline and UX

Discord has a separate adapter runtime, but the same application boundary:

Discord Gateway
  -> DiscordHostedService
  -> message/interaction router
  -> UX rate limit and cooldown
  -> channel handler
  -> logical game contract / gRPC client

The interaction router supports slash commands, autocomplete, buttons/selects and modals. Input that is naturally multi-field (for example a table code, bet and raise amount) belongs in a modal rather than in a plain-text command. Handlers should use DiscordEmbeds for structured responses and DiscordLocalization for ru/en text. The adapter owns Discord-specific formatting; game results remain channel-neutral.

Component custom IDs are backed by persistent component tokens. A token is validated before a component or modal handler runs, so a button from before a restart is rejected as stale instead of being interpreted against new state. Rate limiting and cooldown decisions happen at the UX layer before the handler is invoked. This protects both local and horizontally scaled BFF replicas; the underlying game executor still remains the authority for idempotency and state concurrency.

Deployment shapes

The framework supports one codebase across four operational shapes. The composition root and environment select the shape; game/application contracts do not change.

Combined compatibility host

CasinoShiz.Host
  backend modules
  Telegram modules
  admin UI
  persistence
  eventing
  jobs

This mode is useful for local development, compatibility and simple deployment.

The monolith keeps the existing shared postgres/cazino database and loads all registered game modules. Backend:ServiceName may be omitted; the framework keeps the legacy Quartz scheduler partition for existing schedules.

Legacy microservices profile

The compatibility split keeps one all-games Backend while separating service and channel processes:

CasinoShiz.Backend
  backend modules
  event store
  projections
  jobs
  admin compatibility surface

CasinoShiz.TelegramBff
  Telegram ingress
  update routing
  Telegram handlers
  gRPC/local contract clients

CasinoShiz.AdminBff
  browser auth/session
  reverse proxy / operations calls
  no direct database access

CasinoShiz.IdentityService
  identity-owned storage and contracts

CasinoShiz.WalletService
  wallet, ledger, limits and protection

The service databases are separate physical ownership boundaries:

Backend  -> backend-postgres/backend
Identity -> identity-postgres/identity
Wallet   -> wallet-postgres/wallet

Identity and Wallet run only their own migrations. Backend never reads their tables; composed admin/player views call Identity, Wallet and Backend APIs.

Distributed game profile

The distributed Compose profile uses the same CasinoShiz.Backend image for each selected game service. Backend:Modules controls which module is loaded:

Backend__Modules=dice
Backend__ServiceName=game-dice
ConnectionStrings__Postgres=...backend...

Each channel BFF resolves a game independently:

Backend__GrpcAddress=http://game-admin:8081       # fallback/control plane
Backend__GameAddresses__Poker=http://game-poker:8081
Backend__GameAddresses__Dice=http://game-dice:8081

If a per-game address is absent, the BFF falls back to Backend:GrpcAddress, preserving the monolith and legacy microservices path. Native dice transports use named gRPC clients, so DiceCube, Darts, Football, Basketball and Bowling can also be routed independently.

The service DNS names are stable, so scaling changes only replica count:

docker compose --profile distributed up --scale game-poker=3

The game service owns only its selected module migrations and schedule rows. Other game services may share the Backend PostgreSQL instance, but they do not perform cross-module table reads.

Kubernetes / Helm

The Helm chart deploys the same image and configuration model as the distributed Compose profile. Each game is a Deployment/Service pair; Identity, Wallet and Backend have separate PostgreSQL StatefulSets, and Redis carries CAP/event transport. A game can be scaled without a code or image variant:

helm upgrade --install cazinoshiz ./deploy/helm/cazinoshiz
kubectl scale deployment game-poker --replicas=3

Production installations can replace the chart's PostgreSQL/Redis templates with managed services by overriding addresses, credentials and storage values.

Transport choice belongs to composition. Modules should depend on logical contracts, not on whether the target is local, gRPC or another transport.

Persistence styles

The framework supports two aggregate persistence styles.

Classical aggregates

Classical aggregates are persisted by module-owned stores/repositories, usually with Dapper and module-specific tables. Most simple game modules should use this style.

Event-sourced aggregates

Event-sourced aggregates implement IEventSourcedAggregate and are registered by the module:

services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);

This registration wires:

  • IRepository<MyAggregate> → EventSourcedRepository<MyAggregate>
  • IAggregateFactory<MyAggregate> → DefaultAggregateFactory<MyAggregate> unless the module already registered a custom factory

The event-sourced repository owns the aggregate lifecycle:

  1. Load stored events from IEventStore.
  2. Deserialize and replay them through LoadFromHistory.
  3. Append PendingEvents with optimistic concurrency.
  4. Dispatch appended events through EventDispatcher.
  5. Call MarkEventsCommitted after successful append.

DefaultAggregateFactory<T> tries to create aggregates through DI, first with the stream id as a string constructor argument and then without it. A module can override this by registering its own IAggregateFactory<T> before calling RegisterAggregate<T>(PersistenceStrategy.EventSourced).

Event flow

The current event-sourced flow is intentionally post-commit:

Application service
  ↓
IRepository<TAggregate>
  ↓
EventSourcedRepository<TAggregate>
  ↓
IEventStore.AppendAsync(...)        // commits module_events
  ↓
EventDispatcher.DispatchAsync(...)  // projections, domain bus, analytics

IEventStore remains a small storage primitive: load stream and append events. It does not know about projections, subscribers or analytics.

EventDispatcher fans each committed event into:

  • matching IProjections;
  • IDomainEventBus subscribers, including framework-wide subscribers;
  • IAnalyticsService.Track(...).

Post-commit dispatch means a dispatch failure does not roll back the already committed event append. Projection handlers and subscribers must be idempotent. Recovery should be done with replay/rebuild jobs or targeted retries.

ProjectionContext.Transaction is nullable. It is normally null in the current implementation. The field exists so a future same-transaction unit-of-work implementation can pass a provider-specific transaction object without changing module contracts.

Cross-module domain events

IDomainEventBus lets modules react to events without referencing each other. Subscriptions are pattern-based:

Pattern

Meaning

sh.game_ended

exact event

sh.*

every event from one module

*.game_ended

one action from any module

*

every event

At startup, EventSubscriptionInitializer subscribes framework listeners to *:

  • EventLogSubscriber writes events to event_log for admin/history views.
  • ClickHouseEventMirror mirrors events to ClickHouse when analytics is enabled.

Modules add their own subscribers with:

services.AddDomainEventSubscription<MySubscriber>("poker.*");

IDomainEventBus is an abstraction. The active implementation can be in-process or backed by CAP with Redis Streams or Kafka/Redpanda depending on composition and Messaging:Transport.

When a broker-backed CAP transport is enabled, the event path is:

game transaction
  -> game_event_outbox (PostgreSQL)
  -> lease-based outbox dispatcher
  -> CAP PostgreSQL outbox + Redis Streams or Kafka/Redpanda
  -> CapEventConsumer
  -> local projections/subscribers

CAP consumer groups are derived from Backend:ServiceName. Replicas of one logical service use the same group and load-balance delivery. Different game services use different groups, so a subscriber in game-meta is not skipped by game-poker. Consumers and projections must still be idempotent because the transport is at-least-once.

Projections

Projections are module-owned read models. A projection declares which event types it handles:

public sealed class MyProjection : IProjection
{
    public IReadOnlySet<string> SubscribedEventTypes { get; } =
        new HashSet<string> { "poker.hand_won" };

    public Task ApplyAsync(IDomainEvent ev, ProjectionContext ctx, CancellationToken ct)
    {
        // update read model table
    }
}

Register it in the module:

services.AddProjection<MyProjection>();

Projections run after the event append commits. They should be idempotent and safe to replay.

Event tables

module_events stores event-sourced aggregate streams. It is the source of truth for replayable aggregate state.

event_log is a flat audit/history stream populated by EventLogSubscriber. It can contain events from event-sourced modules and other published domain events. Admin history pages should read this table, not replay aggregate streams.

module_snapshots stores optional aggregate snapshots through ISnapshotStore<T>.

event_dispatch_failures stores failed post-commit dispatch attempts for targeted retry and operational recovery.

Outbox and side effects

The framework distinguishes event delivery from external side effects.

Domain/integration events can flow through the configured event bus. External effects such as Telegram messages emitted from subscribers and background jobs should use a durable outbox.

Telegram outbox records are persisted before sending. In the monolith, the local dispatcher claims due rows and sends them to Telegram. In the split deployment, the Backend relay claims rows, publishes a CAP command through the configured broker to Telegram BFF, and marks a row sent only after the BFF confirms the Telegram message id. Both modes reclaim expired leases and retain retry metadata on failure.

Handlers that respond immediately to live Telegram updates may still send direct Telegram responses. Critical asynchronous notifications should use the outbox.

The same failure model applies to Discord: discord_outbox is persisted in the owning PostgreSQL database and Discord BFF replicas claim rows with leases. Message delivery is therefore independent from the update/interaction request that produced it.

Distributed outbox and scheduler ownership

game_event_outbox is a fan-out source for the configured event bus. Its SKIP LOCKED lease means any healthy Backend replica can publish an event once; CAP then fans it out to the logical consumer groups.

game_schedule_outbox is different: a schedule must be applied by the service that registered its command. Every row stores game_id, and a distributed worker claims only rows for its loaded modules. Migration 027_schedule_outbox_ownership backfills older scoped schedule IDs.

Quartz uses Backend:ServiceName as the persistent sched_name partition. Replicas of game-poker therefore cluster together, while a game-dice replica cannot acquire Poker triggers. The monolith falls back to the legacy CasinoShiz scheduler name for upgrade compatibility.

Wallet and identity boundaries

Wallet and identity are logical service boundaries.

Backend modules should not directly query wallet or identity tables. They should use framework contracts such as wallet read/write ports, player directory ports and service adapters selected by composition.

In a combined host those ports may be implemented locally. In a split deployment they may be implemented by gRPC clients pointing at CasinoShiz.IdentityService and CasinoShiz.WalletService.

The Admin BFF is the composition boundary for cross-service read models. For example, player and admin views call Identity, Wallet and Backend Operations over gRPC and return one response to the browser:

/api/aggregation/players/{userId}
  -> Identity player data
  -> Wallet balance/protection data

/api/aggregation/admin
  -> Backend operations data
  -> Wallet analytics data

These are composed read models, not database joins. No BFF or Backend process may connect to another service's PostgreSQL database or read another service's tables directly.

Wallet invariants such as idempotent debits/credits, balance scopes, ledger append, limits, cooldowns and self-exclusion belong to the wallet owner, not to individual game modules.

Migrations

Framework-owned tables are created by framework migrations first. Module migrations are then applied through IModuleMigrations and tracked in __module_migrations.

Migrations are forward-only raw SQL migrations executed via Dapper.

Startup uses a PostgreSQL advisory lock around the migration phase. This is required when Kubernetes starts many game replicas simultaneously. In the monolith, framework and all loaded module migrations run against cazino. In the legacy microservices profile, Backend, Identity and Wallet each run only their own migration set against their own connection string. In the distributed profile, every game process runs framework migrations plus the selected module's migrations against the Backend database.

Backend migrations must not create or mutate Wallet/Identity-owned tables except during explicitly marked compatibility transitions. A module that is not selected must not be required for a selected service to start.

Adding a new backend module

A new backend module should:

  1. Define its application service contract or use an existing framework contract.
  2. Define domain model, commands, results and events.
  3. Choose classical persistence or event-sourced persistence.
  4. Register services through ConfigureServices(IModuleServiceCollection).
  5. Register migrations through IModuleMigrations.
  6. Register projections and domain-event subscribers when needed.
  7. Keep Telegram, HTTP and transport-specific code out of the backend module.

Adding a new Telegram adapter module

A new Telegram adapter should:

  1. Reference BotFramework.Telegram.
  2. Reference the game contract or backend-facing interface it needs.
  3. Implement Telegram handlers using route attributes.
  4. Parse Telegram input and render Telegram responses.
  5. Call logical application contracts, not concrete backend services.
  6. Register handlers through ConfigureServices(ITelegramModuleServiceCollection).

The adapter should not own game state, wallet state or persistence.

Adding a new event-sourced module

A new module that wants event sourcing should:

  1. Define domain events implementing IDomainEvent with stable EventType strings, e.g. mygame.round_started.
  2. Define an aggregate implementing IEventSourcedAggregate.
  3. Register it with RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced).
  4. Optionally register IAggregateFactory<MyAggregate> if the default DI/id constructor creation is not enough.
  5. Register projections with AddProjection<TProjection>().
  6. Register cross-module subscribers with AddDomainEventSubscription<TSubscriber>(pattern).

After that, appended aggregate events automatically flow through projections, event log, ClickHouse mirror, module subscribers and analytics.

Framework rules

  • BotFramework.Sdk should remain transport-neutral.
  • BotFramework.Telegram is the framework project that owns Telegram runtime types.
  • Backend modules should not depend on Telegram adapters.
  • Telegram adapters should not own persistence.
  • Transport adapters should live outside domain/application modules.
  • Composition roots decide local vs remote implementation.
  • Event handlers, projections and subscribers must be idempotent.
  • External side effects that must survive process failure should use an outbox.
  • Wallet and identity tables are owned by their services, not by games.
  • Game modules should communicate through contracts and events, not by importing another game's internals.