🚨 IMPORTANT / ВАЖНО 🚨 THIS BOT DOES NOT AND WILL NEVER SUPPORT REAL MONEY. Этот бот НЕ поддерживает и НЕ БУДЕТ поддерживать реальные деньги. Any fork or modification that adds real-money functionality violates the spirit of this project and may violate laws in your jurisdiction. The author is not responsible for such misuse. (Любой форк или модификация, добавляющая реальные деньги, нарушает дух проекта и может нарушать законы. Автор не несет ответственности за такое использование.)
A Telegram mini-game bot. Russian-language UI, ASP.NET Core 10. Implements:
Diagram-first architecture reference: arch.md.
| Family | Commands | Chips |
|---|---|---|
| Telegram-dice family | 🎰 (slots) · /dice 🎲 · /darts 🎯 · /football ⚽ · /basket 🏀 · /bowling 🎳 |
Native Telegram dice rolls, optional drop of /redeem codes |
| Multi-player table games | /poker · /blackjack · /sh (Secret Hitler) |
Layered split (Domain / Application / Infrastructure) |
| Race | /horse (bet / info / result) · admin /horserun |
SkiaSharp GIF renderer + scheduled daily auto-run |
| Casino-style pick games | /pick · /picklottery + /pickjoin · /dailylottery |
Random outcome with configurable house edge, double-or-nothing chain, streak bonus, multi-user pools |
| Non-betting fun commands | /roll · /choose · /ben |
Random forecasts, choice selection, and weighted Talking Ben GIFs |
| PvP | /challenge |
1v1 stake duel layered on top of every other game |
| Webapp | /pixelbattle |
Telegram WebApp shared pixel canvas |
| Economy | /balance · /daily · /transfer · /redeem · /top · /mystats |
Per-chat wallets, daily bonus drip, peer transfer with fee, player statistics and global protection controls |
| Seasonal meta | /menu · /profile · /quests · /achievements · /clan · /tournament |
Levels, XP, rating, achievements, quests, clans, tournament brackets |
| Admin Telegram commands | /topall · /analytics · /chats · /codegen · /run … · /rename … · /horserun |
Restricted to Bot:Admins |
| Layer | Tech |
|---|---|
| Runtime | ASP.NET Core, .NET 10 |
| Telegram | Telegram.Bot 22.x (polling + webhook) |
| Persistence | PostgreSQL 16 via Dapper on the live game/balance paths (SELECT ... FOR UPDATE on the wallet hot path). EF Core packages and EfRepository<T> exist for optional module-owned repositories. |
| Migrations | Dapper-based, tracked in __module_migrations, applied at startup by ModuleMigrationRunner |
| Event bus | DotNetCore.CAP 10.x — PostgreSQL outbox + selected Messaging:Transport (Redis, Kafka, or Local) |
| Update fan-out | Redis Streams (opt-in via Redis:Enabled) — partitioned by chatId % N, consumer groups |
| Analytics | ClickHouse 24.x via ClickHouse.Client 7.x (buffered, degrades gracefully) |
| Dashboards | Grafana 11 with auto-provisioned ClickHouse + Prometheus datasources |
| Graphics | SkiaSharp 3.x (horse race GIF renderer, offloaded to thread pool) |
| Tests | xUnit, 1,462 tests covering domain, application, infrastructure, adapters, boundaries and framework; PostgreSQL integration tests use Testcontainers |
| Deploy | Docker Compose (bot + postgres + redis + clickhouse + prometheus + grafana) / Helm chart |
CasinoShiz/
├── docker-compose.yml — bot + db/cache/analytics + monitoring stack
├── Dockerfile — dotnet/sdk:10.0 multi-stage
├── CasinoShiz.slnx — solution manifest
├── .env / .env.example — local env file consumed by compose (.env is git-ignored)
├── prometheus/ — scrape config for exporters, cAdvisor, dotnet-monitor
├── grafana/ — datasource + dashboards provisioning
├── helm/ — Kubernetes Helm chart
│ ├── values.yaml
│ ├── values.secret.yaml — secrets (git-ignored, never commit)
│ └── templates/
├── docs/docs.md — this document
├── db/clickhouse/ — manual ClickHouse migration scripts
├── framework/
│ ├── BotFramework.Contracts/ — transport-neutral request/event contracts
│ ├── BotFramework.Sdk/ — module, domain, event and update-handler abstractions
│ ├── BotFramework.Sdk.Testing/ — test repositories and fakes
│ ├── BotFramework.Host/ — backend infrastructure: DB, economics, events,
│ │ analytics, jobs, admin auth and composition
│ └── BotFramework.Telegram/ — Telegram client, polling/webhook, middleware,
│ update routing, Redis streams and outbox delivery
├── games/
│ ├── Games.<Context>.Contracts/ — interfaces and DTOs; no Telegram, DB or gRPC
│ ├── Games.<Context>/ — application/domain backend, stores, jobs, migrations
│ ├── Games.<Context>.Telegram/ — commands, callbacks, keyboards and Telegram rendering
│ ├── Games.<Context>.Rest/ — authenticated HTTP route modules for the API BFF
│ ├── Games.<Context>.Transport.Grpc/ — generated wire types and gRPC adapters
│ ├── Games.NativeDice.Rest/ — REST adapters for the five native-dice games
│ ├── Games.NativeDice.Transport.Grpc/ — shared transport for the five native-dice games
│ ├── Games.Darts.Telegram.Delivery/ — legacy direct Telegram roll delivery adapter
│ └── Games.Horse.Rendering/ — reusable transport-neutral GIF renderer
├── host/
│ ├── CasinoShiz.Host/ — legacy all-in-one composition + Razor admin UI
│ ├── CasinoShiz.Backend/ — Telegram-free backend composition and gRPC servers
│ └── CasinoShiz.TelegramBff/ — Telegram runtime, adapters and gRPC clients
└── tests/
└── CasinoShiz.Tests/ — domain, application, infrastructure and boundary tests
Each bounded context is a project family rather than one project containing every layer:
Games.<Context>.Contracts/
interfaces and public request/result/domain records
Games.<Context>/
Application/
Services/ application services and backend-facing interfaces
Jobs/ background jobs and timeout workers
Projections/ event projections
Results/ backend-only use-case records
Analytics/ read-model snapshots and analytics DTOs
Domain/
Commands/ backend-only commands
Events/ domain events
Rules/ backend-only rules and registries
Infrastructure/
Persistence/ Dapper stores, rows and store interfaces
Migrations/ module migrations
Modules/ backend `IModule` registration
Integrations/ backend HTTP/SSE or event adapters
Queues/ Redis/queue adapters
Games.<Context>.Telegram/
Telegram handlers, keyboards, immediate replies and client-specific rendering
Games.<Context>.Transport.Grpc/
protobuf files, generated wire types, server endpoints and remote clients
Not every context needs every project. PixelBattle uses backend HTTP/SSE instead of gRPC for its WebApp. Native-dice contexts share one transport project. Backend-only registries and persistence rows stay in the backend project. Empty legacy layer directories are intentionally removed after files move to their owning project.
Games.Meta is additionally grouped by feature inside layers (Achievements, Clans, Quests, Risk, Seasons, Streaks, Tournaments, Catalog, History) because it owns several progression subsystems.
Casino coins are scoped to the current Telegram chat, while seasonal profile progression connects play to longer-lived RPG goals. Start with /menu for the interactive hub or use the direct commands below.
| Goal | Commands | What it does |
|---|---|---|
| Check resources | /balance, /daily, /mystats |
Shows the current chat wallet, claims the daily faucet, and opens private statistics/protection controls. |
| Build a profile | /profile, /achievements |
Shows level, XP, rating, seasonal progress, and unlocked achievement titles. |
| Complete objectives | /quests |
Shows rotating objectives and their progress/rewards. |
| Play socially | /clan, /challenge, /tournament |
Clan progression, direct 1v1 stakes, and tournament brackets. |
| Compete | /top, /picklottery, /dailylottery |
Wallet leaderboard and chat-scoped lottery pools. |
Game stakes and payouts change the current chat wallet through the append-only economics ledger. Eligible completed game events also feed XP, rating, quest, achievement, and tournament projections. Use /help for the player command summary and the full command reference for syntax.
AddBackendFramework() registers backend services (IEconomicsService, IDailyBonusService, request/event buses, persistence, admin auth, health and migrations) without referencing Telegram.Bot. BotFramework.Telegram adds the client adapter runtime: Telegram client, update router/pipeline/middleware, polling/webhook, Redis update transport, and outbound dispatcher. The legacy AddBotFramework() composes both halves during migration. Each .AddModule<T>() instantiates the module, runs ConfigureServices(IModuleServiceCollection), and folds its handlers/locales/migrations/commands/admin pages into the shared aggregate.
Bounded-context consumers depend only on contract interfaces, request/response records, and integration events. The monolith resolves those interfaces to in-process dispatchers; separate BFF/services resolve the same interfaces to gRPC or broker adapters in their composition roots. Generated wire models and transport clients must not enter application or contract projects.
monolith runs CasinoShiz.Host against the existing shared postgres/cazino
database. microservices runs Backend against backend-postgres/backend,
Identity against identity-postgres/identity, and Wallet against
wallet-postgres/wallet. Each service starts only its own migration runner;
Backend does not run Wallet or Identity migrations.
The REST BFF is rest-api in the microservices Compose profile and the
rest-api Helm deployment. In the distributed profile it uses the same
per-game addresses as Telegram/Discord and is exposed separately from the bot
BFFs. The monolith exposes the same REST routes from CasinoShiz.Host.
The optional distributed Compose profile uses the same Backend image with a
configuration-selected module set (Backend__Modules). It creates one
game-* gRPC service per game and routes each Telegram/Discord client to that
service through Backend__GameAddresses__*. Thus game-poker can be scaled
independently of game-dice, while the old microservices profile remains a
single all-games Backend deployment. Kubernetes uses the equivalent Helm chart
under deploy/helm/cazinoshiz.
The Backend process has no Wallet/Identity database connection. Wallet writes,
including game payouts, admin adjustments, and responsible-gaming checks, go
through the Wallet gRPC contract. Read models are composed by the database-free
Admin BFF: /api/aggregation/players/{userId} combines Identity + Wallet, and
/api/aggregation/admin combines Backend Operations + Wallet analytics. These
are API calls, never SQL joins or shared-table reads.
Use docker compose --profile monolith run --rm db-backup for the monolith or
docker compose --profile microservices run --rm db-backup-microservices for
three independent dumps. Dedicated microservice volumes are the migration
boundary and should be backed up/restored independently.
The native Telegram dice games (DiceCube, Darts, Football, Basketball, and Bowling) are split into dependency-free Games.*.Contracts, backend Games.*, and Games.*.Telegram adapter projects. The legacy host loads both backend and Telegram modules, so this separation does not require a distributed deployment. Darts background delivery crosses an IDartsRollDelivery port; only its Telegram adapter knows ITelegramBotClient.
The native-dice gRPC client uses named channels, so those five services can be
addressed independently as well. A Backend instance that does not own a
native-dice module returns gRPC UNIMPLEMENTED instead of resolving another
game's service from its database.
Migration startup is protected by a PostgreSQL advisory lock. Game outbox and
schedule rows use lease-based SKIP LOCKED claims, and command inbox rows are
idempotent. CAP domain-event groups are derived from Backend:ServiceName:
replicas of one service share a group, while different game services each
receive the event stream for their own projections. This is the event-system
rule required for safe horizontal scaling.
For separate deployment, Games.NativeDice.Transport.Grpc maps the same five interfaces to gRPC. Protobuf/generated types and channel setup remain inside that transport project. CasinoShiz.Backend loads backend modules and maps the service; CasinoShiz.TelegramBff loads Telegram modules and resolves their interfaces to remote clients.
Transfer follows the same layout. Telegram resolves the recipient and formats replies; the backend contract performs the atomic balance transfer. Both local and gRPC compositions implement the same ITransferService interface.
CasinoShiz.Api exposes the same contract interfaces over authenticated minimal
API routes. The route shape is:
/api/{version}/tenants/{tenantId}/scopes/{scopeId}/{module}
Bearer JWT sub is the opaque player identifier. The token must contain the
requested tenant_id and scope_id claims. State-changing requests require a
string Idempotency-Key; request and correlation identifiers are carried in
the standard headers and RFC 7807 errors.
For local REST profiling only, CasinoShiz.Api supports an explicit
development bearer scheme. It is active only when the host environment is
Development and Rest:DevelopmentAuthentication:Enabled=true; its token is
read from configuration and must not be committed. Enabling the flag in any
other environment fails startup. Production uses the configured JWT authority
as before.
REST adapters are registered for every current module: dice, dicecube,
darts, football, basketball, bowling, blackjack, horse, poker,
leaderboard, transfer, challenges, pick, redeem, pixelbattle,
secrethitler, meta, and admin. Native-dice /play routes generate the
server-side outcome and call the same bet/settle path as the Discord adapter;
/bet and /roll are available where a client needs a two-step flow.
OpenAPI is available at /openapi/v1.json in development (or when explicitly
enabled outside development). The REST BFF also serves Swagger UI at
/swagger; in the Compose microservices profile the public Nginx edge exposes
it at http://localhost/swagger and proxies to rest-api:8080. The direct
REST BFF port remains 5090 for local diagnostics. Health probes remain
/health/live and /health/ready.
flowchart TD
A[Telegram] -->|Polling / Webhook| B(BotHostedService)
B --> C{Messaging:Transport?}
C -- Yes --> D[UpdateStreamPublisher]
D -->|Redis Stream| E[UpdateStreamWorkerService]
E --> F[UpdatePipeline]
C -- No --> F
F --> G[ExceptionMiddleware]
G --> H[UpdateDeduplicationMiddleware]
H --> I[UpdateAnalyticsMiddleware]
I --> J[LoggingMiddleware]
J --> K[RateLimitMiddleware]
K --> L[KnownChatsMiddleware]
L --> M[UpdateRouter]
M -->|Attribute Match| N[IUpdateHandler.HandleAsync]
N --> O[Feature Service e.g. DiceService]
O --> P[(PostgreSQL / Dapper)]
O --> Q[IEconomicsService]
O --> R[IAnalyticsService]
R --> S[(ClickHouse)]
When Redis:Enabled=true, the polling loop and webhook both publish updates to Redis Streams instead of dispatching inline. UpdateStreamWorkerService reads from N partitioned streams (keyed by chatId % N) in consumer groups and invokes the same UpdatePipeline.
IDomainEventBus.PublishAsync(IDomainEvent) — cross-module domain events.
flowchart LR
A[Publisher Service] -->|PublishAsync| B(IDomainEventBus)
B --> C{Redis:Enabled?}
C -- Local --> D[InProcessEventBus]
D --> E[Subscribers]
C -- Redis/Kafka --> F[CapEventBus]
F --> G[(PostgreSQL Outbox)]
G -->|CAP Relay| H((Configured CAP transport))
H -->|CAP Consumer| I[CapEventConsumer]
I --> E
- Broker-backed transport: DotNetCore.CAP with PostgreSQL outbox + the configured Redis Streams or Kafka transport. Delivery across pods is at-least-once.
CapEventBusresolves a scopedICapPublisherper publish call viaIServiceScopeFactory.CapEventConsumerreceives all events on the"domain.event"topic and dispatches to pattern-matched subscribers. - Local transport:
InProcessEventBus— in-memory, sync dispatch, single-process only. Fine for dev / single-pod.
ITelegramOutbox.EnqueueAsync(TelegramOutboxMessage) persists critical async outbound Telegram text messages in telegram_outbox. The TelegramOutbox:Transport setting chooses delivery:
Local(default, monolith):TelegramOutboxDispatcherServiceclaims due rows withFOR UPDATE SKIP LOCKED, sends throughITelegramBotClient.SendMessage, recordstelegram_message_idon success, and schedules exponential retry on failure.Cap(split deployment):TelegramOutboxCapRelayServicein Backend claims rows and publishes a CAP command through the configured broker. Telegram BFF sends it, then publishes a delivery receipt; Backend marks the row sent only after that receipt.CaprequiresConnectionStrings:Postgresand the sameMessaging:Transportconfiguration in both processes.
Both modes reclaim expired sending leases. Delivery remains at-least-once because Telegram sendMessage has no idempotency key.
Use it for business-important messages emitted outside the live update response path: event subscribers, scheduled settlement announcements, reward/drop notifications, and similar DB/event -> Telegram side effect flows. Normal interactive replies from handlers (/help, validation errors, balance/menu views, immediate game feedback) should stay direct ctx.Bot.SendMessage(...); delayed retries there would produce stale or confusing messages.
dedupe_key is optional but recommended for subscriber/job sends. It suppresses duplicate enqueues from repeated event handling. Telegram's Bot API still has no end-to-end idempotency for sendMessage, so callers should keep retried notification text/buttons safe to receive more than once.
Routes are declared on handler classes, not in a central table. UpdateRouter scans for IUpdateHandler implementations at startup and builds a priority-sorted dispatch list.
| Attribute | Matches | Priority |
|---|---|---|
[ChannelPost] |
Update.ChannelPost != null |
300 |
[MessageDice(emoji)] |
native Telegram dice with given emoji | 250 |
[CallbackPrefix(prefix)] |
CallbackQuery.Data starts with prefix |
200 |
[Command(prefix)] |
Message.Text starts with prefix |
100 + prefix.Length |
[CallbackFallback] |
any remaining CallbackQuery |
1 |
Longer command prefix wins (/horserun > /horse, /picklottery > /pick). Adding a handler: implement IUpdateHandler, decorate with route attribute, register via services.AddHandler<T>() in the owning module's ConfigureServices. No host edit required.
Stateful game writes are serialized with SemaphoreSlim(1,1) per game instance stored in a ConcurrentDictionary<TKey, Gate>. The Gate wrapper tracks LastUsedTick for cleanup. Stale gates are pruned by sweeper jobs:
- Poker:
PokerTurnTimeoutJob.SweepAsynccallsPokerService.PruneGates(3 × turnTimeoutMs) - Blackjack:
BlackjackHandTimeoutJob.SweepAsynccallsBlackjackService.PruneGates(handTimeoutMs) - Secret Hitler:
SecretHitlerGateCleanupJobruns every 10 min, prunes gates idle > 1 hour - Pick lottery:
PickLotterySweeperJobpollspick_lotteryfor expired open pools - Daily lottery:
PickDailyLotterySweeperJobpollspick_daily_lotteryfor pools whose draw deadline has passed - Operations reporting:
OperationsReportingJobchecks every 15 minutes for a completed weekly summary and economy anomalies
Balance mutations acquire a Postgres row lock via SELECT ... FOR UPDATE inside EconomicsService.
Wallets are scoped per Telegram chat (balance_scope_id = Message.Chat.Id in public groups/supergroups; in private DMs it equals the user id). The same person has separate coin balances in different chats.
IEconomicsService is the only place routine balance mutations go. All game and admin pay flows use it; every successful change appends a row to economics_ledger in the same transaction as users.coins / version.
await economics.EnsureUserAsync(userId, balanceScopeId, displayName, ct);
await economics.TryDebitAsync(userId, balanceScopeId, amount, "horse.bet", ct);
await economics.CreditAsync(userId, balanceScopeId, payout, "dice.prize", ct);
await economics.AdjustUncheckedAsync(userId, balanceScopeId, delta, ct); // admin / recovery
// Atomic two-party move (used by /transfer): debit sender and credit recipient in one transaction.
await economics.TryPeerTransferAsync(fromId, toId, balanceScopeId, debitTotal, creditNet, "transfer.send", "transfer.receive", ct);EnsureUserAsync seeds new (user, scope) rows with Bot:StartingCoins (default 100) and caches existence for 24 h in IMemoryCache.
Direct writes to users.coins via raw SQL are forbidden; EconomicsService uses SELECT … FOR UPDATE and bumps version on every mutation. Admin UI writes still go through IEconomicsService; admin_audit records admin web actions.
Economics ledger reasons (used as a low-cardinality string tag — also useful for analytics):
| Reason | Source |
|---|---|
dice.stake / dice.prize |
Slots (🎰) |
dicecube.stake / dicecube.payout |
/dice 🎲 |
darts.stake / darts.payout |
/darts |
football.stake / football.payout |
/football |
basketball.stake / basketball.payout |
/basket |
bowling.stake / bowling.payout |
/bowling |
horse.bet / horse.payout |
/horse + /horserun |
poker.buyin / poker.payout / poker.refund |
/poker |
blackjack.stake / blackjack.payout / blackjack.refund |
/blackjack |
sh.buyin / sh.winnings / sh.refund |
/sh |
pick.stake / pick.payout |
/pick (single) |
pick.lottery.stake / pick.lottery.payout / pick.lottery.refund |
/picklottery, /pickjoin |
pick.daily.stake / pick.daily.payout / pick.daily.refund |
/dailylottery |
challenge.stake / challenge.payout / challenge.tie_refund / challenge.refund |
/challenge |
transfer.send / transfer.receive |
/transfer |
daily.bonus |
/daily |
admin.adjust |
Admin UI / /run pay |
The bot has only three coin sources outside game payouts:
Bot:StartingCoins(default100) — once per(user, balance_scope)row on first observation. Capped naturally by user count./daily—floor(balance × PercentOfBalance / 100)capped byMaxBonus. With the defaultPercentOfBalance = 0.35this is 0.35% of balance per day (the formula divides by 100 internally — see "Daily bonus" below). Once per local day per(user, balance_scope)./redeem— does not mint coins. It only grants one extra Telegram-dice roll for the same game id by decrementingtelegram_dice_daily_rolls.roll_countby1. Net effect:+1to the daily roll cap.
There is no other inflow. Game payouts move existing coins between players or out to the house (loss → coins leave circulation).
Mini-games using Telegram's random dice (🎲 🎯 🎳 🏀 ⚽) are tuned so that, if each face is roughly uniform, expected return per unit bet is below 100% in the long run. Concrete multipliers and slot stake live in the per-game service code or appsettings.json under Games:<game>. Default RTP for the shipped configuration:
| Game | Payout map | Distribution | RTP |
|---|---|---|---|
🎰 slots (/dice) |
64-outcome prize table at Cost = 10 (gas tax adds 0 at this stake) |
uniform 1..64 | ~95.3% |
/dice 🎲 (DiceCube) |
Mult4 = 1, Mult5 = 2, Mult6 = 2, others 0 |
uniform d6 | ~83.3% |
/darts |
4 → ×1, 5,6 → ×2, others 0 |
uniform d6 | ~83.3% |
/bowling |
same as darts | uniform d6 | ~83.3% |
/football |
4,5 → ×2, others 0 |
uniform d5 | 80% |
/basket |
same as football | uniform d5 | 80% |
/pick |
bet × N/k × (1 − HouseEdge) floored |
uniform among variants | ≤ 95% (default HouseEdge = 0.05) |
/picklottery, /dailylottery |
winner takes pot − floor(pot × HouseFeePercent) |
uniform among entries | 95% (default fee = 5%) |
/horse |
parimutuel with 1.1 divisor + integer floor |
RNG winner | ~9% structural skim |
/challenge |
winner takes pot − HouseFeeBasisPoints / 10000 × pot |
game-dependent | ~98% (default fee = 200 bps = 2%) |
/poker |
zero-sum among seated players | shuffled deck | 100% (no rake) |
/sh |
zero-sum among players (winners split pot) | shuffled deck | 100% (no rake) |
/blackjack |
rules-driven (3:2 BJ, push, double, dealer hits to 17) | shuffled deck | strategy-dependent |
DailyBonus is a controlled drip of coins (small % with cap), so it does not erase the house edge in aggregate.
IDailyBonusService (framework) credits a small once-per-local-day bonus.
floor(balance × PercentOfBalance / 100), capped by MaxBonus
Note the /100 in the formula: Bot:DailyBonus:PercentOfBalance = 0.35 means 0.35%, not 35%. Ledger reason daily.bonus. The "day" boundary uses Bot:DailyBonus:TimezoneOffsetHours (default 7, aligned with horse racing). Column users.last_daily_bonus_on (migration 008_users_last_daily_bonus) stores the last claim.
- If the formula rounds to
0(tiny balance) but the user is non-empty, the day is not marked, so they can run/dailyagain after their balance moves. - Balance 0 → no credit, day not marked.
- Already claimed for this day → friendly refusal.
Config: Bot:DailyBonus — Enabled, PercentOfBalance, MaxBonus, TimezoneOffsetHours. Handler: LeaderboardHandler on /daily.
Bot:TelegramDiceDailyLimit — per-game daily roll caps (MaxRollsPerUserPerDayByGame, default 5 for each of the six dice games; 0 = unlimited). The fallback MaxRollsPerUserPerDay only applies if a game id has no per-game entry. The cap is per wallet (balance_scope), so a user has separate quotas in each chat.
Implementation: TelegramDiceDailyRollLimiter against the telegram_dice_daily_rolls table (composite PK (telegram_user_id, balance_scope_id, game_id)). The day boundary uses TimezoneOffsetHours (default 7).
TryConsumeRollAsync is called before each paid roll; LimitExceeded blocks the throw. Admins (those with userId == chatId in private DM, i.e. talking to the bot in their own private chat) bypass the cap so they can test.
GrantExtraRollAsync is what /redeem calls on success — it decrements the counter so the next throw goes through, effectively granting one extra roll for the redeemed game.
User sends a Telegram 🎰 dice in any chat. DiceHandler rejects forwards (would otherwise let users replay frozen wins).
DiceService.PlaceBetAndResolveAsync:
- Debits
Cost + GetGasTax(Cost)with reasondice.stake. AtCost = 10the gas function rounds to0, so net loss per spin is10. - Decodes the 1..64 Telegram outcome into three reels of four symbols (bar / cherry / lemon / seven, each with its own stake price).
- Looks up the prize from a fixed table: triple seven
→ 77, triple lemon→ 30, triple cherry→ 23, triple bar→ 21; pair seven→ 10 + sum, pair lemon→ 6 + sum, other pair→ 4 + sum; otherwisesum − 3. - Credits the prize with reason
dice.prizeif> 0. - Optionally drops a
/redeem <uuid>code viaTelegramMiniGameRedeemDropswith probabilityGames:dice:RedeemDropChance(default0.02).
Config: Games:dice:Cost, Games:dice:RedeemDropChance. Telegram-dice cap: dice key.
Two-step game: /dice bet <amount> → dicecube_bets row → user (or bot) sends 🎲 → resolution.
DiceCubeService.PlaceBetAsync:
- Validates amount in
[1, MaxBet]. - Enforces
MinSecondsBetweenBetsper chat through Redis when available, with an in-memory fallback for a single-node deployment. The distributed marker has an explicit TTL. - Debits stake, inserts a pending row.
DiceCubeService.ResolveAsync on the user's 🎲:
- Reads pending bet.
mults[face]table fromGames:dicecube:Mult4..6; faces 1–3 pay0.- Credits
bet × multiplierwith reasondicecube.payout(no stake return on win — net is just the payout).
Pending rows snapshot the multiplier in case the rule changes mid-flight (admin tuning).
Config: Mult4, Mult5, Mult6, MaxBet, DefaultBet, MinSecondsBetweenBets, CooldownCacheTtlSeconds, RedeemDropChance.
All four follow the same pattern as /dice: [Command] for the bet path (bot throws on the user's behalf) plus [MessageDice("🎯/⚽/🏀/🎳")] for the quick-play path (the user throws their own emoji and the bot resolves against the configured multipliers).
Multiplier maps:
/darts,/bowling:4 → ×1,5,6 → ×2,1..3 → 0(uniform d6 → 5/6 RTP)/football,/basket:4,5 → ×2,1..3 → 0(Telegram returns 1..5 → 4/5 RTP)
Config per game: MaxBet, DefaultBet, RedeemDropChance. The Telegram-dice cap key matches the game id (darts, football, basketball, bowling).
User commands:
| Command | Effect |
|---|---|
/horse bet <horse> <amount> |
Bet on horse 1..HorseCount for today's pool in this chat |
/horse info |
Show pool stakes and parimutuel-style koefs |
/horse result |
Show this chat's last race GIF (or today's global race) |
/horse help |
Rules text |
Race "day" uses Games:horse:TimezoneOffsetHours (default 7). HorseTimeHelper.GetRaceDate(offsetHours) builds the MM-dd-yyyy pool key.
Payout math (HorseService.GetKoefs):
koef[h] = floor((sum − stake[h]) / (1.1 × stake[h]) × 1000) / 1000 + 1
This is parimutuel with a structural 1.1 divisor + integer floor — roughly a 9% rake plus truncation. The losing-stake mass redistributes to winners proportional to their stake.
Race winner uses RandomNumberGenerator.Fill for cryptographic-grade randomness; horses are drawn uniformly. The renderer (HorseRaceRenderer) draws SkiaSharp frames into an LZW GIF89a, offloaded with Task.Run so the async scheduler stays free. HorseGifCache keeps the latest race GIF in memory for /horse result.
Place labels are baked into the GIF: each horse gets a place when its progress first reaches 100%, and the renderer draws a high-contrast badge (1st, 2nd, …) on the horse plus a status column on the right. FinishHoldFrames keeps the final state visible at the end of the loop. Ordinal suffix logic: (place % 100) ∈ {11, 12, 13} → th; otherwise place % 10 picks st, nd, rd, th.
Auto-run. Games.Horse.Telegram.HorseRaceScheduledCommand is registered through the framework IRecurringScheduledCommand Quartz integration. Quartz wakes it every minute; it runs one global race at the configured local time every AutoRunEveryDays calendar days (1 = daily, 7 = weekly), when AutoRunEnabled = true and there are at least MinBetsToRun bets across all chats. In microservices the Telegram edge owns this command so the settled result and GIF are delivered to the chats that placed bets. The schedule is editable at /admin/horse and is reloaded from runtime tuning without a restart. Default: daily at 21:00 in UTC+7.
Manual run. /horserun (admin only):
- In a group/supergroup → run that chat's pool only.
- In a private chat → global merged race.
/horserun global(orall) → explicitly global, even from a group.
Texas Hold'em over multiple chats. Full DDD split:
games/Games.Poker/
├── Domain/ — pure hand-level logic, no DB / no I/O
│ ├── Commands/ PokerCommand, PokerCommandParser, PokerAction, AutoAction
│ ├── Configuration/ PokerOptions
│ ├── Entities/ PokerTable, PokerSeat
│ ├── Events/ table/player/hand events
│ ├── Results/ PokerError, TableSnapshot, typed result records
│ └── Rules/ Deck, HandEvaluator, transitions, PokerDomain
├── Application/
│ ├── Handlers/PokerHandler.cs
│ ├── Jobs/PokerTurnTimeoutJob.cs
│ └── Services/PokerService.cs (stores + gate + domain calls + EconomicsService)
└── Infrastructure/
├── Persistence/ table/seat stores
├── Rendering/ PokerStateRenderer, PokerBoardRenderer
└── Modules/Migrations/ module registration and DDL
Subcommands: create, join, start, fold, call, raise <n>, check, leave. Buy-in is debited on join with reason poker.buyin; entry.Won is credited at showdown with poker.payout. No house rake — the game is zero-sum among seated players.
PokerTurnTimeoutJob (IBackgroundJob) polls every 10 s for stuck hands and runs DecideAutoAction (check if possible, else fold). Same path handles restart recovery.
Config: Games:poker:BuyIn, blinds (1/2 default), MaxPlayers (default 8).
Single-player turn-based hand against the dealer. /blackjack <bet> opens a hand, the bot DMs an inline keyboard for Hit / Stand / Double. The deck is Deck.BuildShuffled() per hand (52-card shoe re-shuffled each time).
Settlement (BlackjackService.Resolve):
| Outcome | Player credit |
|---|---|
| Player bust | 0 (loses stake) |
| Player BJ vs non-BJ dealer | bet + bet × 3 / 2 (3:2 on natural) |
| Player BJ vs dealer BJ | bet (push) |
| Dealer bust | bet × 2 |
| Player total > dealer total | bet × 2 |
| Player total < dealer total | 0 |
| Tie (no BJ) | bet (push) |
Dealer hits while total < 17. Splits are not implemented; only Double on a two-card hand. There is no extra house rake — the edge comes from BJ rules + dealer-hits-to-17.
BlackjackHandTimeoutJob cleans up stale hands using BlackjackService.PruneGates(handTimeoutMs).
Config: Games:blackjack:MinBet, MaxBet, HandTimeoutMs.
5–10 player hidden-role social deduction game. Buy-in joins a pot; winners (liberals or fascists+Hitler depending on game.Winner) split the pot evenly with the remainder distributed 1 coin at a time to the first winners in enumeration order until exhausted.
games/Games.SecretHitler/
├── Domain/
│ ├── Commands/ SecretHitlerCommand, SecretHitlerCommandParser
│ ├── Configuration/ SecretHitlerOptions
│ ├── Entities/ SecretHitlerGame, SecretHitlerPlayer
│ ├── Events/ game/player lifecycle events
│ ├── Results/ ShError, ShGameSnapshot, typed result records
│ └── Rules/ ShPolicyDeck, ShRoleDealer, ShTransitions, ShValidation
├── Application/
│ ├── Handlers/SecretHitlerHandler.cs
│ ├── Jobs/SecretHitlerGateCleanupJob.cs
│ └── Services/SecretHitlerService.cs (Dapper + Gate + EconomicsService for buy-ins/refunds)
└── Infrastructure/
├── Persistence/ game/player stores
├── Rendering/ SecretHitlerStateRenderer
└── Modules/Migrations/ module registration and DDL
Subcommands: create, join, start, nominate <user>, vote yes/no, leave. Ledger reasons: sh.buyin, sh.winnings, sh.refund (cancelled / disbanded games).
Zero-sum — no rake; everything joining the pot eventually reaches winners.
SecretHitlerGateCleanupJob (IBackgroundJob) prunes idle gates every 10 min.
Config: Games:sh:BuyIn.
For the policy/deception probability model, assumptions, recurrence, and numerical results, see Secret Hitler strategy model.
User-supplied variant list. Bot picks one variant uniformly at random; if it matches the user's backed variant(s), pay out with house edge.
Command shapes (handled by PickCommandParser):
| Form | Effect |
|---|---|
/pick a, b, c |
Default bet; back the first variant |
/pick 50 a, b, c |
Bet 50; back the first variant |
/pick bet 50 a, b, c |
Same, explicit form |
/pick a | a, b, c |
Back by name (the part before |) |
/pick 2 | a, b, c |
Back by position 1..N |
/pick a, c | a, b, c, d |
Multi-back parlay (two variants) |
/pick 50 a, c | a, b, c, d |
Bet 50, multi-back |
/pick help |
Usage |
Payout (PickService.ComputePayout):
gross = stake × N / k (fair odds: pay back the inverse of P(win) = k/N)
net = gross × (1 − HouseEdge)
payout = floor(max(0, net)) (truncation favours house)
Where N = number of variants, k = number of variants the user backed.
Streak bonus (PickStreakStore, in-memory ConcurrentDictionary keyed on (userId, chatId)):
streak = consecutive wins (capped at StreakCap)
bonus = floor(stake × StreakBonusPerWin × min(streak − 1, StreakCap))
Defaults: StreakBonusPerWin = 0.5, StreakCap = 4 → bonus tops out at the 5th consecutive win for +2 × stake. A loss resets the streak.
Double-or-nothing chain (PickChainStore, in-memory). After a win, the bot offers an inline 🔥 Удвоить ×2 ({stake_for_next}) button valid for ChainTtlSeconds. Clicking it re-stakes the entire payout against the same variant list. The chain depth is capped at ChainMaxDepth (default 5). Only the original user can click; the button is single-shot per offer. When the chain breaks (loss or expiry) the streak counter is also reset.
Suspense reveal (when RevealAnimation = true). The bot edits the message and eliminates losing variants one by one, with RevealStepDelayMs between steps and total cap RevealMaxTotalMs. For very many variants the cap shortens each step.
Config (section Games:pick):
| Key | Default | Notes |
|---|---|---|
DefaultBet |
10 |
Used when /pick a, b has no number |
MaxBet |
5000 |
Hard cap; 0 would mean no cap |
MinVariants, MaxVariants |
2, 10 |
Validation |
MaxVariantLength |
64 |
Trims excess characters per variant |
HouseEdge |
0.05 |
Fraction of gross win the house keeps |
StreakBonusPerWin, StreakCap |
0.5, 4 |
Per-win bonus and cap |
ChainMaxDepth, ChainTtlSeconds |
5, 60 |
Double-or-nothing limits |
RevealAnimation, RevealStepDelayMs, RevealMaxTotalMs |
true, 600, 4500 |
Eliminate-losers animation |
Ledger reasons: pick.stake, pick.payout. No rows in the DB; streak and chain state live in process memory and are safe to lose on restart (worst case: reset of a streak / chain that was in flight).
Time-bounded multi-user lottery in one chat. The opener stakes a fixed amount; other players join with the same stake until the deadline; sweeper draws a random entrant.
| Subcommand | Effect |
|---|---|
/picklottery <stake> |
Open a pool with this stake; opener auto-enters |
/picklottery info |
Snapshot of the open pool (entrants, pot, time left) |
/picklottery cancel |
Cancel (only the opener); refunds everyone |
/picklottery help |
Usage |
/pickjoin |
Join the open pool |
Settlement (PickLotteryService.SettlePoolAsync, called by sweeper):
stateDiagram-v2
[*] --> Open: /picklottery <stake>
Open --> Open: /pickjoin (adds entrant)
Open --> Settled: SweeperJob (deadline reached, ≥2 entrants)
Open --> Cancelled: SweeperJob (deadline reached, <2 entrants)
Open --> Cancelled: /picklottery cancel (opener only)
Settled --> [*]: Winner gets pot minus fee
Cancelled --> [*]: All stakes refunded
fee = floor(pot × HouseFeePercent)
payout = max(0, pot − fee)
winner = entries[Random.Shared.Next(entries.Count)]
If at the deadline entries.Count < MinEntrantsToSettle (default 2), the pool is cancelled and all stakes are refunded. Same for explicit cancel by the opener.
Storage: pick_lottery (one open pool per chat, partial unique index WHERE status = 'open') + pick_lottery_entries (PK (lottery_id, user_id)). The opener's stake is debited atomically with the pool insertion; subsequent joins debit + insert in their own transactions.
PickLotterySweeperJob polls every Games:pick:Lottery:SweeperIntervalSeconds (default 15s) and settles or cancels expired pools.
Config (section Games:pick:Lottery):
| Key | Default | Notes |
|---|---|---|
DurationSeconds |
300 |
Pool lifetime (5 min) |
MinEntrantsToSettle |
2 |
Below this → cancel + refund |
HouseFeePercent |
0.05 |
Fraction of pot the house keeps |
MinStake, MaxStake |
1, 10000 |
Stake bounds (MaxStake = 0 → unlimited) |
SweeperIntervalSeconds |
15 |
How often the sweeper polls |
Ledger reasons: pick.lottery.stake, pick.lottery.payout, pick.lottery.refund.
Per-chat suite of independent daily pools. Each chat has its own daily pool; tickets cost a fixed amount; the draw happens at a configurable local hour each day (default 18:00 in UTC+7).
| Subcommand | Effect |
|---|---|
/dailylottery |
Status of today's pool in this chat |
/dailylottery buy [N] |
Buy N tickets (default 1) |
/dailylottery <N> |
Shortcut: same as buy N |
/dailylottery history |
Last HistoryLimit settled draws in this chat |
/dailylottery help |
Usage |
"Today's pool". The cycle key is the calendar date of the draw. If the draw hour for the current local day has already passed, buys land in the next day's pool — there is no dead window. Concretely: with DrawHourLocal = 18 and offset +7, anything bought 18:00..23:59 local goes into tomorrow's pool, which is settled at 18:00 local tomorrow.
Settlement (PickDailyLotteryService.SettlePoolAsync):
pot = sum(price_paid) from pick_daily_lottery_tickets
fee = floor(pot × HouseFeePercent)
payout = max(0, pot − fee)
winner = ORDER BY random() LIMIT 1 weighted by ticket count (one row per ticket)
If a draw fires with zero tickets → cancelled, no draw. If a chat has tickets bought but the draw deadline arrives, settlement always picks one ticket-winner, weighted by ticket count.
Storage: pick_daily_lottery (one pool per (chat_id, day_local) via a unique index) + pick_daily_lottery_tickets (one row per ticket, simple BIGSERIAL PK). Random draw is ORDER BY random() LIMIT 1 over the tickets table, which is naturally weight-by-ticket-count.
PickDailyLotterySweeperJob polls every Games:pick:Daily:SweeperIntervalSeconds (default 60s) for pools whose deadline_at has passed and settles or cancels them.
Config (section Games:pick:Daily):
| Key | Default | Notes |
|---|---|---|
TicketPrice |
50 |
Coins per ticket |
MaxTicketsPerUserPerDay |
0 (unlimited) |
Hard cap on a user's tickets in one daily pool |
MaxTicketsPerBuyCommand |
100 |
Throttle one-shot purchases |
HouseFeePercent |
0.05 |
Fraction of pot the house keeps |
SweeperIntervalSeconds |
60 |
How often the sweeper polls |
HistoryLimit |
7 |
How many history entries /dailylottery history shows |
DrawHourLocal |
18 |
0..23, local draw hour |
TimezoneOffsetHoursOverride |
0 |
Non-zero overrides the global Bot:TelegramDiceDailyLimit:TimezoneOffsetHours |
Ledger reasons: pick.daily.stake, pick.daily.payout, pick.daily.refund.
1v1 PvP layer over every other game. Owns its own challenge_duels table and settlement flow, but balance moves still go through IEconomicsService.
Command shapes:
/challenge @username 500 dicecube
/challenge 500 darts # reply to the target user's message
/challenge help # usage
Target resolution: explicit @username first, else reply-to-message. Username lookup uses the shared users table scoped to the current chat (balance_scope_id = chat_id), so the target generally needs prior bot interaction in that chat. Reply-based challenges always work because Telegram gives the target user id directly.
Supported games and aliases:
| Game enum | User aliases | Telegram / rendering path |
|---|---|---|
DiceCube |
dice, die, dicecube, cube, кубик, 🎲 |
Telegram SendDice with 🎲 |
Darts |
darts, dart, дартс, 🎯 |
Telegram SendDice with 🎯 |
Bowling |
bowling, bowl, боулинг, 🎳 |
Telegram SendDice with 🎳 |
Basketball |
basket, basketball, баскетбол, 🏀 |
Telegram SendDice with 🏀 |
Football |
football, soccer, футбол, ⚽ |
Telegram SendDice with ⚽ |
Slots |
slots, slot, casino, слоты, 🎰 |
Telegram SendDice with 🎰 |
Horse |
horse, horses, race, лошади, скачки, 🐎 |
2-horse SkiaSharp GIF |
Blackjack |
blackjack, bj, 21, блекджек, 🃏 |
Auto-resolved crypto-shuffled BJ duel |
Lifecycle:
CreateAsyncvalidates[MinBet, MaxBet], rejects self-challenge, checks the challenger balance, rejects duplicate pending challenges between the same two users in the same chat, inserts aPendingrow, sends the inline keyboard.- Only the target can accept/decline. Decline →
Declined. Accept atomically transitionsPending → Accepted. - On accept, both players are ensured in the chat wallet and debited with reason
challenge.stake. If the second debit fails, the first stake is refunded and the row becomesFailed. - The handler resolves the game. Telegram-dice modes send two
SendDicemessages and compare their numeric values. Horse and blackjack take dedicated paths described below. CompleteAcceptedAsync: equal scores → refund both stakes withchallenge.tie_refund; otherwisepot − feeto the winner withchallenge.payout.- Any exception after accept calls
FailAcceptedAsync→ refund both stakes + markFailed.
Fee math (basis points, integer):
fee = clamp(HouseFeeBasisPoints, 0, 10000) × (amount × 2) / 10000
payout = (amount × 2) − fee
Tiny pots can produce fee = 0 because of integer truncation (e.g. 10-vs-10 at 2% rounds the fee from 0.4 to 0).
Horse challenges: bypass Telegram dice. SpeedGenerator.GenPlaces(2) picks the winner; HorseRaceRenderer.DrawHorses renders a 2-horse GIF; uploaded as challenge-horse.gif. The settlement message is delayed by frameCount × 60ms + 1s so the text result doesn't spoil the animation. Scores are artificial (2 for winner, 1 for loser) so the shared settlement method can still compute a winner.
Blackjack challenges are intentionally instant auto-play duels, not a turn-based table. Reuse Games.Blackjack.Domain.Deck and BlackjackHandValue:
- One crypto-shuffled 52-card deck.
- Challenger draws 2, auto-hits to ≥17.
- Target draws 2 from the same deck, auto-hits to ≥17.
- Score =
0for bust,22for natural blackjack, otherwise hand total. - Equal scores = push, both stakes returned.
- Cards rendered with friendly suit symbols (
Q♥ K♠ 10♦…).
Callback data: ch:a:<challengeIdN> for accept, ch:d:<challengeIdN> for decline (UUID in compact N format).
Analytics events: challenges.created, challenges.accepted, challenges.declined, challenges.tie, challenges.completed, challenges.failed_refunded with tags for challenge id, chat id, both user ids, amount, game, scores, winner, payout, fee.
Config (section Games:challenges):
| Key | Default | Notes |
|---|---|---|
MinBet, MaxBet |
1, 5000 |
Stake bounds |
HouseFeeBasisPoints |
200 |
2% of pot |
PendingTtlMinutes |
10 |
Clamped to 1..60 by ChallengeOptions.PendingTtl |
Lets a user send coins to another user in the same group/supergroup wallet (balance_scope_id = that chat's Chat.Id). Not available in private DMs (no shared recipient context).
Semantics:
- The last whitespace-separated token in the message is the net amount the recipient receives.
- Fee is computed on that net from
Games:transfer:FeePercent × net, rounded to nearest 0.5, then to a whole coin (MidpointRounding.AwayFromZero), then clamped to at leastMinFeeCoins. OptionalMinNetCoins,MaxNetCoins(0= no cap). - Sender debited
net + feewith reasontransfer.send; recipient creditednetwithtransfer.receive. The fee is not credited anywhere (coins leave circulation).
Atomicity. IEconomicsService.TryPeerTransferAsync does it in one transaction: SELECT … FOR UPDATE on both (user, scope) rows in ascending telegram_user_id order (deadlock-safe), then two ledger inserts.
Recipient resolution (TransferHandler) — explicit recipient first, then reply:
TextMentionentity (inline user picker).Mentionentity (@username) — distinct fromTextMention. Mentions fully contained in a normalBotCommandspan (e.g./transfer@YourBot) are ignored, but aBotCommandspan covering the entire message is not used to hide mentions.- Whitespace-split args (any Unicode whitespace, not only ASCII space — guards against NBSP gluing tokens).
- Text between command and amount: numeric Telegram user id, or
@username/ handle resolved viagetChat(private user only). Transfers toBot:Username/Bot:BotUsernameare rejected. - Reply to the target user's message — only if none of the above matched.
Success message shows sender + recipient balances after the transfer.
Config (section Games:transfer):
| Key | Default |
|---|---|
FeePercent |
0.03 |
MinFeeCoins |
1 |
MinNetCoins |
1 |
MaxNetCoins |
0 (no cap) |
Static assets live under host/CasinoShiz.Host/wwwroot/pixelbattle. /pixelbattle and /pixelbattle/ redirect to /pixelbattle/index.html, served by ASP.NET static-file middleware.
Telegram entry point. /pixelbattle sends a Telegram WebApp button. Games:pixelbattle:WebAppUrl must be a public HTTPS URL, e.g. https://your-public-host/pixelbattle/index.html. Telegram WebApps do not work from private LAN URLs for other users; expose the bot with a tunnel (Cloudflare Tunnel, ngrok) for local testing. Stale message buttons may keep old tunnel URLs and stale Telegram init data — re-send /pixelbattle after URL changes.
Auth. API writes require Telegram WebApp initData in the X-Telegram-Init-Data header. TelegramWebAppInitDataValidator verifies the HMAC against the bot token and enforces Games:pixelbattle:MaxInitDataAgeSeconds (default 86400, 24 h). Invalid/expired init data → 401. Valid init data still must belong to a known user in the bot DB; unknown users → 403.
Grid model:
- 200 × 160 pixels = 32 000 logical tiles.
- Colors restricted to the configured palette in
PixelBattleConstants. pixelbattle_tiles.index= primary key.- Each update stores
color, updater Telegram user id, updated timestamp, and a monotonically increasingversionfrompixelbattle_version_seq. PixelBattleGridreturns parallelTilesandVersionstampsarrays.PixelBattleUpdate={ index, color, versionstamp }.
Frontend rendering: a single <canvas> (originally one DOM element per tile, replaced for perf). The JS keeps tiles and versionstamps arrays in memory, draws each tile with drawPixel, and maps click coordinates back to a tile index.
Live updates (SSE on /pixelbattle/api/listen):
- Writes
retry: 1000. - Streams a full-grid update immediately.
- Subscribes to in-process
PixelBattleBroadcasterand writes small update arrays as users paint. - Periodically sends a full grid again based on
Games:pixelbattle:FullUpdateIntervalMsso clients self-heal if a small update is missed.
The update endpoint (POST /pixelbattle/api/update) validates init data, body shape, tile index, color, and known-user status, persists the tile, broadcasts the update, and returns the new versionstamp.
Config (section Games:pixelbattle):
| Key | Default | Notes |
|---|---|---|
WebAppUrl |
empty | Public HTTPS WebApp URL. Must point to /pixelbattle/index.html in practice |
FullUpdateIntervalMs |
10000 |
Periodic full-grid SSE refresh |
MaxInitDataAgeSeconds |
86400 |
Maximum age for init data (401 after) |
Redeem code = single-use UUID that grants one extra Telegram-dice roll (not coins) for a specific game.
| Command | Effect |
|---|---|
/redeem <uuid> |
Activate a code. Private chat only. Triggers an emoji captcha. |
/codegen [count] |
Admin-only: generate copy-paste-ready codes |
Drop flow. After every Telegram-dice game roll, TelegramMiniGameRedeemDrops.MaybePublishAsync rolls against Games:<game>:RedeemDropChance (default 0.02). On hit it publishes a RedeemDropRequested domain event consumed by RedeemDropSubscriber, which inserts a fresh redeem_codes row with issued_by = 0 and enqueues the announcement through ITelegramOutbox using a stable drop dedupe key.
Captcha. On /redeem, the bot:
- Validates UUID format and that the code is
active. - Rejects redemptions of codes the user issued (
issued_by != userIdrequired; bot-issued drops useIssuedBy = 0, so dropper restrictions don't apply to drops). - Sends a 6-emoji captcha: pick the matching one within
CaptchaTimeoutMs(default 15 s). - On correct answer: marks the code redeemed (
active = false,redeemed_by), callsIRollGate.GrantExtraRollAsyncto bump the daily roll quota by 1 for the matchingFreeSpinGameId, posts confirmation. - On wrong answer or timeout: keeps the code active, friendly message.
Captcha-timeout race fix. RedeemCaptchaTimeouts (singleton) holds a CancellationTokenSource per pending code. When the user picks an answer, RedeemHandler calls timeouts.TryCancel(codeGuid) to atomically yank the timeout task before it fires. The fire-and-forget Task.Delay listens to that token and atomically Forgets its slot before sending the timeout message — so a successful redeem never races into a stray "you took too long" reply.
Config (section Games:redeem):
| Key | Default | Notes |
|---|---|---|
FreeSpinGameId |
dice |
Default game id for codegen / drops without explicit override |
CaptchaItems |
6 |
Number of emoji buttons in the captcha |
CaptchaTimeoutMs |
15000 |
Captcha timeout in ms |
MaxCodegenCount |
20 |
Hard cap on /codegen per command |
Admins |
empty | Extra Telegram user ids allowed to run /codegen; unioned with Bot:Admins |
Five user-facing commands, plus one admin-only:
| Command | Restriction | Effect |
|---|---|---|
/balance |
none | Show this chat's wallet for the caller |
/daily |
none | Claim once-per-local-day bonus (see "Daily bonus" above) |
/top [full] |
none | Top players in this chat, with DaysOfInactivityToHide filter; full removes the trim |
/topall [full] / /topall split |
admin + private DM only | Cross-chat top. Default = aggregate (sum of coins across chats per user). split = per-chat breakdown. full removes inactivity filter. Output is chunked across multiple messages if it exceeds Telegram's 4096-char limit. |
/help |
none | Command reference (HTML), pulls dynamic config values for default bets, ticket price, draw hour, and timezone offset so the help text never goes stale |
Config (section Games:leaderboard):
| Key | Default | Notes |
|---|---|---|
DefaultLimit |
15 |
How many rows to show by default |
DaysOfInactivityToHide |
7 |
Filters out users with no recent activity unless full is passed |
Games.Meta adds persistent progression on top of completed games. Game modules publish
GameCompletedMetaEvent; three projections consume it:
MetaXpProjectionupdates the seasonal player profile, evaluates risk rules, and unlocks achievements.QuestProjectionadvances matching daily and weekly quests.ClanProjectionadds seasonal XP/rating contribution to the player's clan.
All data is scoped by both active season and Telegram chat. A player can therefore have different seasonal profiles and clan membership in different chats, matching the per-chat wallet model.
/menu opens one inline-keyboard message instead of sending a separate message for every screen.
It displays the current wallet balance, season level, XP, division, rating, unclaimed quest rewards,
achievement count, and active season end date.
Menu screens:
- profile and game statistics;
- active quests, individual claim buttons, and
claim all; - unlocked/locked achievements;
- seasonal top;
- compact game command reference;
- daily bonus claim;
- close/back navigation.
Callback data contains the menu owner's Telegram user id. In group chats another user cannot navigate the menu, claim its quest rewards, or trigger its daily bonus.
The first profile request or completed game creates the player row for the active season.
/profile and /rank are aliases for the same profile view.
The profile tracks:
- XP and level;
- rating and derived division;
- games, wins, and losses;
- total seasonal stake and payout;
- display name and last update time.
Useful commands:
| Command | Effect |
|---|---|
/season |
Active season name, status, start, and finish |
/profile, /rank |
Player level, XP progress, rating, division, and statistics |
/topseason |
Top 15 players in this chat by seasonal XP/rating |
/achievements shows unlocked and locked seasonal achievements. Unlock writes are idempotent, so
replayed domain events cannot issue the same achievement twice.
Current groups include first game/win, game-count milestones, ten wins, game-specific participation, seasonal stake volume, and a large single-game payout.
Two economy achievement thresholds are runtime-tunable:
| Config key | Default | Meaning |
|---|---|---|
Games:meta:HighRollerTotalStaked |
1000 |
Total seasonal stake required for High Roller |
Games:meta:BigPayoutMinimum |
1000 |
Single-game payout required for Большой занос |
The configured values are also used in achievement descriptions, so /achievements does not show
stale hard-coded targets. Changing a threshold affects future completed-game evaluation; achievements
already unlocked are not revoked.
/quests lists daily and weekly objectives with progress and coin/XP rewards.
/quest claim <id> claims one completed quest. The interactive menu additionally supports claiming
all currently completed quests.
Quest rewards use idempotent economics operation ids and add XP through the active season service. Current quest templates cover play count, wins, specific games, and weekly stake volume.
Clans are scoped to one Telegram chat. A player can belong to at most one clan in that chat. Completed games contribute clan XP; wins and stake volume increase the contribution.
| Command | Effect |
|---|---|
/clan create <TAG> <name> |
Create a clan and join as owner |
/clan join <TAG> |
Join an existing clan |
/clan info [TAG] |
Show own clan or a clan by tag |
/clan members [TAG] |
List members |
/clan top |
Seasonal clan leaderboard for this chat |
/tournament and /tour manage chat-scoped single-elimination brackets. Entry fees are debited
idempotently, accumulated into the prize pool, refunded on cancellation, and paid once to the winner.
Only the creator can start, report/finish, or cancel the tournament.
Supported tournament game keys: dice, dicecube (alias cube), darts, football,
basketball, and bowling. The bracket stores and advances reported winners; it does not
automatically launch or verify the underlying game.
| Command | Effect |
|---|---|
/tournament create <game> <entryFee> <maxPlayers> |
Create an open tournament |
/tournament join <id> |
Pay entry fee and join |
/tournament list |
List open tournaments in this chat |
/tournament status <id> |
Tournament summary |
/tournament players <id> |
Participant list |
/tournament start <id> |
Create and start the bracket |
/tournament bracket <id> |
Show rounds and match ids |
/tournament report <matchId> <winnerUserId> |
Report one match result |
/tournament finish <id> <winnerUserId> |
Manually finish and pay the prize |
/tournament cancel <id> |
Cancel and refund joined players |
Completed games can create deduplicated seasonal flags for large multipliers, large payouts, and
unusually high win rate. /risk [list], /risk resolve <id>, and /risk ignore <id> operate on
flags in the current chat. The handler is currently not role-gated, so deployments that expose this
command should account for that until an explicit admin guard is added.
Risk thresholds are currently internal rules in RiskService. In particular,
Games:meta:BigPayoutMinimum controls the achievement only and does not change the risk flag threshold.
Meta persistence is owned by MetaMigrations:
meta_seasons,meta_season_players;meta_player_achievements,meta_player_quests;meta_clans,meta_clan_members,meta_season_clans;meta_tournaments,meta_tournament_players,meta_tournament_matches;meta_risk_flags.
Restricted to Bot:Admins (super-admin) or Games:admin:Admins (per-module list).
| Command | Restriction | Effect |
|---|---|---|
/run pay <id> <amount> |
admin | Manual coin adjustment (positive or negative); ledger reason admin.adjust |
/run userinfo |
admin | Reply to a message → returns Telegram user id |
/run cancel_blackjack <id> |
admin | Refund and remove a stuck blackjack hand |
/run kick_poker <id> |
admin | Remove a player from a poker table and refund their stack |
/run debug |
admin | Misc debug helpers |
/rename <old> <new|*> |
admin | Display-name override; * clears |
/chats [filter] [full] |
admin + private DM only | List all chats the bot is in. Filter by chat type (group, super, private, channel); full removes truncation. Each row shows chat type, title/handle, id, user count, total coins, and last seen |
/analytics [window] |
admin + private DM only | Pull ClickHouse analytics: event counts, distinct users, top event types/modules/users for windows 24h / 7d / 30d, plus a daily timeline. Service: ClickHouseAnalyticsQueryService with parameterised, safe SQL |
/codegen [count] |
redeem-admin | Generate copy-paste /redeem <uuid> freespin codes. Capped by Games:redeem:MaxCodegenCount |
/horserun [global|all] |
admin | Run today's horse race. In groups: this chat's pool only; in private: global by default |
Per-module, Dapper-based, tracked in __module_migrations under a module_id key. Applied at startup by ModuleMigrationRunner (registered before BotHostedService so schema is ready before polling starts).
Adding a migration = one new Migration("name", "SQL") entry in the module's IModuleMigrations.GetMigrations() list. No EF tooling.
| ID | Contents |
|---|---|
001_event_store |
module_events + indexes |
002_snapshots |
module_snapshots |
003_users |
legacy single-scope users (superseded by 006) |
004_event_log |
event_log |
005_admin_audit |
admin_audit |
006_per_chat_wallets_and_ledger |
users with (telegram_user_id, balance_scope_id) PK; economics_ledger |
007_known_chats |
known_chats (first/last seen per chat) |
008_users_last_daily_bonus |
users.last_daily_bonus_on DATE for /daily |
009_users_telegram_dice_daily |
users.telegram_dice_rolls_on, telegram_dice_roll_count — legacy single-counter daily cap (superseded by 012) |
010_runtime_tuning |
runtime_tuning — JSON patch merged over file/env for whitelisted Bot + Games keys; edited from /admin/settings |
011_delivery_and_coordination |
processed_updates, game_command_idempotency, mini_game_sessions, mini_game_roll_gates |
012_telegram_dice_daily_per_game |
telegram_dice_daily_rolls with (telegram_user_id, balance_scope_id, game_id) PK for per-game daily caps |
013_event_dispatch_failures |
Retryable projection/subscriber failures with stream/version identity |
014_economics_operation_id |
Idempotent ledger operation identifiers |
015_telegram_outbox |
Durable Telegram delivery queue and retry state |
016_event_analytics_checkpoint |
Singleton checkpoint for historical ES → ClickHouse backfill |
017_responsible_gaming_and_ops_reports |
Global player stake limits/cooldowns/self-exclusion and deduplicated weekly/anomaly report checkpoints |
018_telegram_outbox_expired_lease_recovery |
Index for reclaiming expired Telegram outbox delivery leases |
019_quartz_ado_job_store |
Persistent Quartz ADO job-store schema and indexes |
020_game_availability–026_discord_outbox |
Availability, fairness, atomic command/event/schedule outboxes, and Discord delivery schema |
027_schedule_outbox_ownership |
Backfills and indexes the owning game_id, so distributed game workers claim only their schedules |
| Module | ID | Contents |
|---|---|---|
challenges |
001_initial |
challenge_duels plus indexes by (chat_id, status, created_at) and (target_id, status, expires_at) |
pixelbattle |
001_initial |
pixelbattle_version_seq + pixelbattle_tiles (each row stores index, color, version, updated_by, updated_at) |
pick |
001_lottery |
pick_lottery (one open pool per chat via partial unique index) + pick_lottery_entries (PK (lottery_id, user_id)) + sweeper index on deadline_at WHERE status = 'open' |
pick |
002_daily_lottery |
pick_daily_lottery (unique (chat_id, day_local)) + pick_daily_lottery_tickets (BIGSERIAL PK, one row per ticket) + sweeper / history indexes |
meta |
001–007 |
Seasons/players, achievements, quests, clans, tournaments, risk flags, and tournament matches |
The bot creates and forward-evolves its active ClickHouse tables at startup with CREATE TABLE IF NOT EXISTS and ALTER ... ADD COLUMN IF NOT EXISTS. Files under db/clickhouse/ are one-off operational migrations only; currently 001_rename_analytics_to_cazinoshiz.sql handles the historical analytics → cazinoshiz database rename.
All game/balance persistence is PostgreSQL + Dapper. EF Core packages and a generic EfRepository<T> exist for optional module-owned repositories, but the shipped game hot paths and framework tables documented below are all Dapper-backed.
| Column | Type | Notes |
|---|---|---|
telegram_user_id |
BIGINT | composite PK with balance_scope_id |
balance_scope_id |
BIGINT | usually Chat.Id — separate wallet per chat |
display_name |
TEXT NOT NULL | |
coins |
INTEGER NOT NULL DEFAULT 0 | written only through EconomicsService |
version |
BIGINT NOT NULL DEFAULT 0 | bumped on every balance mutation |
last_daily_bonus_on |
DATE NULL | last /daily claim in configured offset "day" |
telegram_dice_rolls_on |
DATE NULL | legacy single-counter (superseded by telegram_dice_daily_rolls) |
telegram_dice_roll_count |
INTEGER NULL | legacy single-counter |
created_at / updated_at |
TIMESTAMPTZ |
Append-only audit of balance changes: delta, balance_after, reason (e.g. dice.stake, daily.bonus, pick.lottery.payout, admin.adjust). See migration 006.
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL | PK |
actor_id |
BIGINT NOT NULL | Telegram user id of admin |
actor_name |
TEXT NOT NULL | |
action |
TEXT NOT NULL | e.g. users.set_coins, runtime_tuning.pick.save |
details |
JSONB NOT NULL DEFAULT '{}' | action-specific context |
occurred_at |
TIMESTAMPTZ |
Stores chats seen by the update pipeline. Admin pages use it to label chat ids with chat type, title, or username. The 1v1 admin page joins challenge_duels.chat_id here for readable chat labels. The /chats Telegram command uses it for the bot-side chat list.
Single-row (id = 1) JSONB overlay merged over appsettings / env on every read via IRuntimeTuningAccessor. RuntimeTuningPayloadSanitizer whitelists which keys may be stored — Bot.{DailyBonus, TelegramDiceDailyLimit} plus Games.{dice, dicecube, darts, football, basketball, bowling, horse, poker, blackjack, sh, pick, challenges, transfer, pixelbattle, redeem, leaderboard, admin}. Edited from /admin/settings.
Per-game daily roll counter for the Telegram-dice family.
| Column | Type | Notes |
|---|---|---|
telegram_user_id |
BIGINT | part of composite PK |
balance_scope_id |
BIGINT | wallet scope |
game_id |
TEXT | dice, dicecube, darts, football, basketball, bowling |
rolls_on |
DATE | last day on which the counter was bumped |
roll_count |
INTEGER | resets to 0 when rolls_on rolls over |
updated_at |
TIMESTAMPTZ |
module_events, module_snapshots (event-sourcing scaffolding), event_log, processed_updates, game_command_idempotency, mini_game_sessions, mini_game_roll_gates, event_dispatch_failures, telegram_outbox, event_analytics_checkpoint, player_protection, and operations_report_checkpoint. See FrameworkMigrations.cs for full DDL.
Pending bet state for two-step dice games. Identical shape:
| Column | Type |
|---|---|
user_id |
BIGINT (PK) |
chat_id |
BIGINT (PK) |
amount |
INTEGER |
created_at |
TIMESTAMPTZ |
dicecube_bets additionally snapshots mult4/mult5/mult6 at bet time so a runtime tuning change does not change the payout for a roll already in flight.
darts_bets was the original pending-bet table and was replaced by the durable darts_rounds queue. Each row contains id, user_id, chat_id, amount, status, optional bot_message_id, reply_to_message_id, and created_at. The (chat_id, status, id) index supports ordered dispatch and recovery.
| Column | Type | Notes |
|---|---|---|
user_id |
BIGINT | PK |
chat_id |
BIGINT | |
bet |
INTEGER | |
player_cards |
TEXT | space-separated card codes |
dealer_cards |
TEXT | |
deck_state |
TEXT | |
state_message_id |
INTEGER NULL | private DM edited in place |
created_at |
TIMESTAMPTZ |
See games/Games.Poker/ migrations for full DDL. Key columns: invite_code (PK), status, phase, deck_state, last_action_at. Seats keyed on (invite_code, position).
See games/Games.SecretHitler/ migrations. Deck serialized as L / F strings. Key columns: status, phase, deck_state, discard_state, election_tracker, last_action_at.
Day-scoped on race_date (MM-dd-yyyy in Games:horse:TimezoneOffsetHours). Bets include balance_scope_id (Telegram chat). Results are keyed on (race_date, balance_scope_id); scope 0 is the global merged race (all chats), other scopes are per-group races. file_id stores the Telegram animation reference.
Owned by Games.Challenges. One row per 1v1 PvP challenge.
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK, embedded in callback data as compact N format |
chat_id |
BIGINT | Chat/wallet scope where the challenge was created and settled |
challenger_id / target_id |
BIGINT | Telegram user ids |
challenger_name / target_name |
TEXT | Display names captured at challenge creation |
amount |
INTEGER | Per-player stake; pot is amount × 2 |
game |
TEXT | Serialized ChallengeGame enum name |
status |
TEXT | Pending, Accepted, Declined, Completed, Failed |
created_at |
TIMESTAMPTZ | |
expires_at |
TIMESTAMPTZ | Pending challenges cannot be accepted after this time |
responded_at |
TIMESTAMPTZ NULL | First accept/decline/failure transition |
completed_at |
TIMESTAMPTZ NULL | Set on Completed, Failed, or Declined |
Indexes: (chat_id, status, created_at DESC) for admin/chat tracking; (target_id, status, expires_at) for target-side pending lookup.
Owned by Games.Pick.
pick_lottery:
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
chat_id |
BIGINT | |
opener_id / opener_name |
BIGINT / TEXT | |
stake |
INTEGER | Fixed stake for every entrant |
status |
TEXT | open, settled, cancelled |
opened_at / deadline_at / settled_at |
TIMESTAMPTZ | |
winner_id / winner_name |
BIGINT / TEXT | NULL until settled |
pot_total / payout / fee |
INTEGER | Snapshot of settlement math |
Indexes:
ux_pick_lottery_open_per_chat—UNIQUE (chat_id) WHERE status = 'open'. One open pool per chat at most.ix_pick_lottery_deadline_open—(deadline_at) WHERE status = 'open'. Sweeper hot path.
pick_lottery_entries:
| Column | Type | Notes |
|---|---|---|
lottery_id |
UUID | FK → pick_lottery(id) ON DELETE CASCADE, part of PK |
user_id |
BIGINT | part of PK |
display_name |
TEXT | |
stake_paid |
INTEGER | What the user was actually debited (the pool stake at the time of entry) |
entered_at |
TIMESTAMPTZ |
Owned by Games.Pick.
pick_daily_lottery:
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
chat_id |
BIGINT | |
day_local |
DATE | Calendar date of the draw in the configured local offset |
ticket_price |
INTEGER | Snapshot of the ticket price at pool creation |
status |
TEXT | open, settled, cancelled |
opened_at / deadline_at / settled_at |
TIMESTAMPTZ | |
winner_id / winner_name |
BIGINT / TEXT | NULL until settled |
ticket_count / pot_total / payout / fee |
INTEGER | Snapshot of settlement math |
Indexes:
ux_pick_daily_lottery_chat_day—UNIQUE (chat_id, day_local). One pool per chat per day; servesGetOrCreaterace-safely viaON CONFLICT.ix_pick_daily_lottery_deadline_open— sweeper hot path.ix_pick_daily_lottery_chat_settled—(chat_id, day_local DESC) WHERE status = 'settled'for/dailylottery history.
pick_daily_lottery_tickets — one row per ticket (so ORDER BY random() LIMIT 1 is naturally weighted by ticket count):
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL | PK |
lottery_id |
UUID | FK → pick_daily_lottery(id) ON DELETE CASCADE |
user_id |
BIGINT | |
display_name |
TEXT | |
price_paid |
INTEGER | What the user paid for this specific ticket |
bought_at |
TIMESTAMPTZ |
Index: (lottery_id, user_id) for "how many tickets does X have in pool Y" lookups.
Owned by Games.PixelBattle. Sparse table for painted tiles; missing rows render as the default color.
| Column | Type | Notes |
|---|---|---|
index |
INTEGER | PK; flattened index in a 200 × 160 grid |
color |
TEXT | Hex color from the allowed palette |
version |
BIGINT | Monotonic version from pixelbattle_version_seq |
updated_by |
BIGINT | Telegram user id from validated WebApp init data |
updated_at |
TIMESTAMPTZ |
| Column | Type | Notes |
|---|---|---|
code |
UUID | PK |
active |
BOOLEAN | false once redeemed |
issued_by |
BIGINT | 0 for bot drops; user id for /codegen |
free_spin_game_id |
TEXT | Same-game extra roll target (dice, darts, bowling, …) |
redeemed_by |
BIGINT NULL | |
issued_at / redeemed_at |
TIMESTAMPTZ |
Razor pages under /admin/* served on the same port as the webhook (default 3000).
GET /admin/login — two sign-in methods:
- Telegram Login Widget — HMAC-SHA256 with
SHA256(botToken)as key, 24 h freshness check. RequiresBot:Usernameand the bot domain registered in BotFather. Real domains only — does not work on localhost. - Token form — constant-time comparison against
Bot:AdminWebToken. Works everywhere; used for local dev.
Successful auth writes an AdminSession (UserId, Name, Role) to ISession. Gate middleware redirects unauthenticated /admin/* to /admin/login.
| Role | Source | Permissions |
|---|---|---|
SuperAdmin |
Bot:Admins |
Full access — mutate balances, run races, edit runtime tuning |
ReadOnly |
Bot:ReadOnlyAdmins |
View only — mutation endpoints return 403 |
Every write action from the admin UI is recorded in admin_audit:
| Column | Description |
|---|---|
actor_id / actor_name |
Telegram user from session |
action |
e.g. users.set_coins, horse.run_race, runtime_tuning.pick.save |
details |
JSONB with action-specific context |
occurred_at |
TIMESTAMPTZ |
/admin— dashboard with the current economy snapshot, module/event totals, sticker-game activity, and background-job status/admin/users— user list, balance set/adjust (SuperAdmin only, viaIEconomicsService.AdjustUncheckedAsync)/admin/people— merged person view across known chats/scopes/admin/groups— known chat list/admin/ledger— economics ledger search/recovery/admin/horse— race control panel: today's bets, koefs, "Run race" button (SuperAdmin only)/admin/horse/image— horse race image/GIF preview/admin/bets— pending bets/admin/challenges— 1v1 PvP challenge tracking (read-only, both roles)/admin/history— race history/admin/events— event log/admin/recovery— unresolved event-dispatch failures, unsent Telegram outbox records, confirmed single-record retries, and read-only background-job state/admin/audit— filterable admin audit history with bounded CSV and JSON exports/admin/meta— meta administration index/admin/meta-seasons,/admin/meta-quests,/admin/meta-tournaments,/admin/meta-tournament/{id},/admin/meta-events,/admin/meta-economy,/admin/meta-alerts— seasonal progression and operational meta views/admin/settings— live runtime JSON patch editor + structured forms (SuperAdmin only)
The dashboard calculates this snapshot directly from PostgreSQL on every request and displays the database capture timestamp. Refresh now reloads the page and runs the query again.
- Wallet supply —
sum(users.coins)across all chat-scoped wallets. - Pending stake — unresolved stake currently held in
darts_rounds,dicecube_bets,basketball_bets,bowling_bets,football_bets,blackjack_hands, andhorse_bets. These coins have already been debited from wallets. - Tracked coins — wallet supply plus pending stake; useful for operational reconciliation, not a claim that every historical faucet or sink is conserved.
- Pending bets — row count over those same unresolved-bet stores.
- Funded wallets, median wallet, richest wallet — current distribution over
users. - 24-hour credits/debits/net/active users — rolling ledger activity from
economics_ledger.
All authenticated admins may inspect the page; only SuperAdmin may mutate records. Event retry redispatches the persisted event through IEventDispatchRetryService, so subscriber/projection idempotency remains authoritative. Outbox reschedule preserves payload, attempts, deduplication key, and previous error, makes an unsent record due now, clears only an expired lease, and refuses sent or actively leased rows. Every completed retry attempt is written to admin_audit. Operations are intentionally per-record and require browser confirmation; there is no bulk retry.
Filters support actor id/name, action, details/record id, and a time window. The page shows the newest 200 matching records. CSV and structured JSON exports apply the same filters and are capped at 1,000 records. CSV output is UTF-8 and neutralizes formula-leading cells before spreadsheet use; JSON emits details as an object rather than a quoted JSON string.
The page combines a live preview (read-only effective values), a JSON patch editor for arbitrary whitelisted keys, and structured forms for the most-tuned subsets:
- Sticker game testing —
RedeemDropChanceand per-game daily roll caps for all six Telegram-dice games - Poker —
BuyIn - Blackjack —
MinBet,MaxBet,HandTimeoutMs - Secret Hitler —
BuyIn - Challenge / PvP —
MinBet,MaxBet,HouseFeeBasisPoints,PendingTtlMinutes - Meta / achievements —
HighRollerTotalStaked,BigPayoutMinimum - Pick / lotteries — full set:
DefaultBet,MaxBet,HouseEdge, streak (StreakBonusPerWin,StreakCap), chain (ChainMaxDepth,ChainTtlSeconds),RevealAnimation; lottery (DurationSeconds,MinEntrantsToSettle,HouseFeePercent,MinStake,MaxStake); daily lottery (TicketPrice,MaxTicketsPerUserPerDay,MaxTicketsPerBuyCommand,HouseFeePercent,HistoryLimit,DrawHourLocal,TimezoneOffsetHoursOverride)
The save flow for every form is the same: RuntimeTuningPayloadSanitizer.Sanitize → RuntimeTuningMerge.MergeSection<T> for type-level validation → write to runtime_tuning.payload → IRuntimeTuningAccessor.ReloadFromDatabaseAsync → audit log entry → reload preview. The free-form JSON patch path runs through the same sanitize + validate steps.
Pending 🎲 cube bets snapshot their mult4/5/6 at bet time, so a tuning change does not change payouts for already-thrown rolls.
Host-owned Razor UI, backed by direct Dapper queries over challenge_duels and known_chats. Read-only, available to both roles through the normal admin gate.
Filters: game (all 8 challenge enums), status (Pending, Accepted, Completed, Declined, Failed), chat (distinct chats from challenge_duels, labeled from known_chats).
Summary cards: total duels for the current filter; pending / accepted / completed / cancelled counts; total pot tracked (sum(amount × 2)).
Tables: by game type (count, pending, completed, total pot, last created); by chat (label/id, count, pending/completed, total pot, last created); recent duels (newest 500 with full row details).
Intended for operational questions: "which chat uses PvP most?", "which game type is popular?", "are there stuck accepted challenges?", "how much pot volume is flowing through 1v1?".
appsettings.json is the source of truth; env vars override (Docker: environment block + .env file; K8s: Secret-backed env). The runtime_tuning DB row merges on top — see "Runtime tuning (admin overlay)" above.
| Key | Required | Description |
|---|---|---|
Token |
yes | Telegram bot API token |
Username / BotUsername |
yes | Bot @username (with or without @); BotUsername is accepted as a config alias and used in appsettings.json |
Admins |
yes | Telegram user IDs with SuperAdmin access |
ReadOnlyAdmins |
no | Telegram user IDs with ReadOnly access |
AdminWebToken |
no | Token for password-based admin login |
IsProduction |
no | true → webhook; false → polling (default) |
WebhookPort |
no | Kestrel port in webhook mode (default 3000) |
TrustedChannel |
no | @username for race GIF broadcast |
StartingCoins |
no | Coins for new users (default 100) |
DailyBonus (nested) |
no | Enabled, PercentOfBalance (% of balance, e.g. 0.35 = 0.35 % because the formula divides by 100 internally), MaxBonus, TimezoneOffsetHours |
TelegramDiceDailyLimit (nested) |
no | MaxRollsPerUserPerDayByGame maps dice, dicecube, darts, football, basketball, bowling to per-day caps (0 = unlimited for that game); MaxRollsPerUserPerDay is only a fallback for missing game ids; TimezoneOffsetHours follows DailyBonus. Cap is per wallet (chat scope). |
| Key | Description |
|---|---|
dice:Cost |
🎰 slot spin stake (gas tax adds 0 at Cost = 10) |
<game>:RedeemDropChance |
Chance per resolved sticker-game roll/throw to drop a /redeem <uuid> code for that same game (0.02 = 2%) |
dicecube:Mult4 / Mult5 / Mult6 |
Pay multipliers for faces 4–6 on /dice 🎲 |
dicecube:MaxBet, MinSecondsBetweenBets, CooldownCacheTtlSeconds |
Stake cap, per-chat cooldown and TTL of its Redis marker |
darts:*, bowling:*, football:*, basketball:* |
MaxBet, DefaultBet, RedeemDropChance |
horse:* |
See below |
poker:BuyIn |
Buy-in per seat |
blackjack:MinBet, MaxBet, HandTimeoutMs |
|
sh:BuyIn |
Secret Hitler buy-in |
pick:* |
See below |
fun:* |
BenPrimary:0..1 and BenRare:0..2; each value is a Telegram file_id, HTTPS URL, or application-relative local GIF path |
challenges:* |
See below |
transfer:* |
FeePercent, MinFeeCoins, MinNetCoins, MaxNetCoins |
pixelbattle:* |
See below |
redeem:* |
FreeSpinGameId, CaptchaItems, CaptchaTimeoutMs, MaxCodegenCount, Admins |
leaderboard:* |
DefaultLimit, DaysOfInactivityToHide |
meta:HighRollerTotalStaked |
Seasonal stake threshold for the High Roller achievement; default 1000 |
meta:BigPayoutMinimum |
Single-game payout threshold for the Большой занос achievement; default 1000 |
admin:Admins |
Per-module admin list (unioned with Bot:Admins) |
Games:horse — HorseCount, MinBetsToRun, AnnounceDelayMs, AnnounceDelay1v1Ms (used when HorseCount is 2, longer delay so the winner message does not appear before the GIF finishes), TimezoneOffsetHours, Admins (Telegram user IDs allowed to /horserun in addition to Bot:Admins), AutoRunEnabled, AutoRunEveryDays, AutoRunLocalHour, AutoRunLocalMinute, InfoCacheSeconds (read-only /horse info projection cache, default 30, valid range 1..300). When AutoRunEnabled is true, the Quartz command runs one global race every AutoRunEveryDays calendar days at the configured local time, if there are enough bets (MinBetsToRun).
Games:challenges:
| Key | Default | Notes |
|---|---|---|
MinBet |
1 |
Rejects smaller stakes |
MaxBet |
5000 |
Rejects larger stakes |
HouseFeeBasisPoints |
200 |
bps on the combined pot; 200 = 2%. Integer division truncates |
PendingTtlMinutes |
10 |
Pending expiry, clamped to 1..60 minutes |
Games:pick — see "Pick / Pick lottery / Daily lottery" sections above for the full key list (DefaultBet, MaxBet, HouseEdge, StreakBonusPerWin, StreakCap, ChainMaxDepth, ChainTtlSeconds, RevealAnimation, RevealStepDelayMs, RevealMaxTotalMs, plus nested Lottery and Daily blocks).
Games:fun provides the non-betting /roll, /choose, and /ben commands. /choose accepts 2–50 comma- or newline-separated options, each up to 50 characters. /ben requires all five sources: BenPrimary:0, BenPrimary:1, BenRare:0, BenRare:1, and BenRare:2; their fixed selection weights are 47%, 47%, 2%, 2%, and 2%. Sources can be Telegram file_id:<id> values, HTTPS URLs, or paths relative to the application directory. Empty sources intentionally make /ben report that the command is not configured.
Games:pixelbattle:
| Key | Default | Notes |
|---|---|---|
WebAppUrl |
empty | Public HTTPS Telegram WebApp URL. Must point to /pixelbattle/index.html |
FullUpdateIntervalMs |
10000 |
Periodic full-grid SSE snapshot interval |
MaxInitDataAgeSeconds |
86400 |
Maximum age of Telegram WebApp init data — expired returns 401 |
| Section | Key | Required when |
|---|---|---|
ConnectionStrings |
Postgres |
always |
Redis |
Enabled, ConnectionString |
Redis:Enabled = true |
Messaging |
Transport (Local, Redis, or Kafka) |
Redis keeps the legacy Redis:Enabled behavior; Kafka requires Kafka:Servers or Kafka:BootstrapServers |
Kafka |
Servers or BootstrapServers, optional MainConfig:* |
Messaging:Transport = Kafka |
TelegramOutbox |
Transport (Local or Cap) |
Cap for Backend → Telegram BFF delivery; requires PostgreSQL and the selected CAP transport in both processes |
ClickHouse |
Enabled, Host, Database, Table, Project |
ClickHouse:Enabled = true (validation throws on startup if Enabled but Host is empty) |
# Local dev (polling mode, needs local Postgres)
dotnet build
dotnet run --project host/CasinoShiz.Host
# Tests
dotnet test
# Full stack in monolith mode
# Create/fill .env with Bot__Token, Bot__BotUsername (or Bot__Username), Bot__Admins__0
docker compose --profile monolith up --build
# Distributed-process smoke/development mode. Also set OPERATIONS_API_KEY and
# ADMIN_SUPERADMIN_TOKEN; ADMIN_READONLY_TOKEN is optional.
docker compose --profile microservices up --build| Service | Port | URL | Purpose |
|---|---|---|---|
| Monolith | 4000 | http://localhost:4000 |
Combined compatibility deployment |
| Backend | 5081 | http://localhost:5081/health/live |
Games and Operations gRPC |
| Identity | 5082 | http://localhost:5082/health/live |
Identity gRPC |
| Wallet | 5083 | http://localhost:5083/health/live |
Wallet gRPC |
| Telegram BFF | 5084 | http://localhost:5084/health/live |
Telegram polling/webhook adapter |
| Admin BFF | 5085 | http://localhost:5085 |
Browser administration client |
| Postgres | 5432 | postgres://cazino:cazino@localhost:5432/cazino |
Primary datastore |
| Redis | 6379 | redis://localhost:6379 |
Update streams + default CAP transport |
| ClickHouse HTTP | 8123 | http://localhost:8123 |
Analytics queries |
| Prometheus | 9090 | http://localhost:9090 |
Exporter, cAdvisor, .NET runtime metrics |
| Grafana | 3001 | http://localhost:3001 |
Dashboards (admin/admin) |
dotnet-monitor runs as an internal compose service and exposes only its Prometheus metrics endpoint to the Docker network. The bot uses DOTNET_DiagnosticPorts=/diag/dotnet-monitor.sock,nosuspend, so metrics are collected when the monitor is available without blocking bot startup if the monitor is absent.
Compose also runs postgres-exporter, redis-exporter, cadvisor, and dotnet-monitor for Prometheus-backed Grafana dashboards. db-backup is a manual/one-shot helper that writes a pg_dump to the repository root when explicitly run.
Provisioned Grafana dashboards:
-
overview.json— ClickHouse event overview -
product-analytics-clickhouse.json— product snapshots for economy, engagement, delivery latency/backlog, ledger health, live game state, seasons, quests, and risk -
growth-reliability-clickhouse.json— D1/D7/D30 retention, new/returning users, game funnels, economy reconciliation, reliability, stale games, social activity, correlation/session depth, handler and delivery latency, snapshot freshness, ClickHouse storage/query health, writer health, ingestion throughput, and analytics data quality -
analytics-clickhouse.json— product analytics fromevents_v2: overview stats, TOD/weekday, DAU, per-chat, stacked hourly users/events by game, pie mix, user intensity, per-user × game matrix, drill-down user (defaults to busiest in range), extended signals (distinct types/modules, chat-tagged rows, errors byexception_type, repeat-day users, event breadth, params keys, tail), Economics & bot dynamics, Growth, quality & depth, Weekly leaderboards (top 15 modules, users, tagged chats per ISO week UTC), Retention, funnels & stake distribution (cohort w0–w8, funnel conversion %, stake quantiles, histogram), plus Chart views (bottom row: bar / time series companions for most heavy table panels — event types, modules, users, errors, params keys, funnel steps, payouts, repeat users, house edge, step conversion %, weekly module stack, weekly actives & tagged-chat volume, cohort w1–w4 curves, daily median stake, multi-game bursts, daily rollup, top wagerers). -
infra-pg-redis.json— PostgreSQL and Redis exporter metrics -
infra-services.json— cAdvisor container CPU/memory + connection panels -
dotnet-runtime.json—dotnet-monitorruntime metrics for the bot
The helm/ chart deploys the bot, a Postgres StatefulSet, and a Redis StatefulSet. ClickHouse and Grafana are not included — analytics disabled by default.
1. Build and push image:
docker build -t your-registry/casinoshiz:latest .
docker push your-registry/casinoshiz:latest2. Create helm/values.secret.yaml (git-ignored, never commit):
bot:
token: "YOUR_TELEGRAM_BOT_TOKEN"
username: "@YourBotUsername"
admins:
- 123456789 # your Telegram numeric user ID
postgres:
password: "strong-password-here"
redis:
enabled: true
password: "" # set if you want Redis auth3. Install:
helm install casinoshiz helm/ \
-f helm/values.yaml \
-f helm/values.secret.yaml \
--namespace casinoshiz --create-namespace4. Upgrade after changes:
helm upgrade casinoshiz helm/ \
-f helm/values.yaml \
-f helm/values.secret.yaml \
--namespace casinoshizWebhook (production). Set bot.isProduction: true and enable ingress:
bot:
isProduction: true
ingress:
enabled: true
className: nginx
host: bot.example.com
tls:
enabled: true
secretName: casinoshiz-tlsRegister the webhook with Telegram after deploy:
https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://bot.example.com/<TOKEN>
ClickHouseAnalyticsService (singleton) buffers events and flushes every FlushIntervalMs. It creates and forward-evolves its tables on startup. If ClickHouse is unreachable at startup the service logs and keeps gameplay independent from analytics I/O.
Validation: if ClickHouse:Enabled = true but ClickHouse:Host is empty, startup throws immediately.
The default database/table/project is cazinoshiz.events_v2 with project = 'cazinoshiz'. The legacy analytics.events_v2 / project = 'busino-bot' schema was renamed via db/clickhouse/001_rename_analytics_to_cazinoshiz.sql (copy-then-drop, preserves history).
Raw query: curl 'http://localhost:8123/?query=SELECT+*+FROM+cazinoshiz.events_v2+LIMIT+10'
The /analytics Telegram command (admin-only, private DM only) wraps ClickHouseAnalyticsQueryService with parameterised, safe SQL — event counts, distinct users, top event types/modules/users for 24h / 7d / 30d windows, plus a daily timeline.
The default product table is cazinoshiz.events_v2. The canonical event-sourcing table is cazinoshiz.events_v2_es (<ClickHouse:Table>_es when configured differently).
| Column | Type | Meaning |
|---|---|---|
event_id |
UUID | Random for ordinary analytics; deterministic SHA-256-derived UUID for persisted ES (stream_id, stream_version) |
event_type |
LowCardinality(String) | Stable dotted event name |
module |
LowCardinality(String) | Prefix before the first dot |
project |
LowCardinality(String) | Deployment/distribution identifier |
user_id |
Int64 | Telegram user, or 0 when unavailable/not applicable |
params |
Map(String,String) | Indexed/filterable string representation of event tags |
payload |
String | Full JSON analytics tags or serialized domain event |
created_at |
DateTime64(3) | Time the analytics row was produced |
stream_id |
String | ES aggregate stream; empty for non-ES events |
stream_version |
Int64 | Version within stream_id; 0 for non-ES events |
aggregate_type |
LowCardinality(String) | CLR aggregate/domain-event type identity where available |
schema_version |
UInt16 | Event payload contract version; currently 1 |
correlation_id |
String | Telegram update trace (telegram:<update_id>) propagated through commands/events |
causation_id |
String | Identifier of the operation/event that caused this event when available |
is_replay |
UInt8 | 1 for historical backfill, 0 for live delivery |
occurred_at |
DateTime64(3) | Original domain occurrence time; differs from created_at during replay/delay |
All events emitted during a Telegram update also receive these params keys: correlation_id, rolling 30-minute session_id, update_id, user_id, chat_id, ISO-8601 occurred_at, and source=telegram. Outcome events add outcome, optional error_code, and duration_ms. /start <parameter> adds a bounded acquisition_source containing only ASCII letters, digits, _, and -.
The ES table uses ReplacingMergeTree(created_at) ordered by (project, event_id). Queries that require canonical uniqueness use FINAL. Live persisted events are written to both tables; historical replay is written only to _es, so product metrics are not double-counted.
| Family | Event types | Important tags |
|---|---|---|
| Telegram ingress | telegram.command, telegram.message, telegram.dice, telegram.callback, telegram.update |
update/user/chat IDs, kind, chat type, command, callback prefix, acquisition source |
| Telegram completion | telegram.update_completed, telegram.route_completed, telegram.route_missed |
session/correlation IDs, handler/route, outcome, error code, duration |
| Framework | _framework.error, framework.daily_bonus |
exception type/message/stack or bonus/user/chat/day |
| Admin | admin.command, admin.user_map |
command/type/caller or mapped user details |
| Dice games | basketball.bet, basketball.throw, basketball.bet_aborted, bowling.bet, bowling.roll, bowling.bet_aborted, darts.bet, darts.throw, darts.quickplay, dice.forwarded, dice.not_enough_coins, dice.success, dicecube.bet, dicecube.roll, football.bet, football.throw, football.bet_aborted |
user/chat, stake/bet, face/value, multiplier, payout/prize, outcome |
| Blackjack | blackjack.start, blackjack.end, blackjack.not_enough_coins |
user/chat, stake, result, payout |
| Horse | horse.bet, horse.run |
user/chat/race, horse, stake, payout/outcome |
| Pick/lottery | pick.roll, pick.lottery.open, pick.lottery.join, pick.lottery.cancelled, pick.lottery.settled, pick.daily.buy, pick.daily.cancelled_empty, pick.daily.settled |
user/chat, lottery, ticket/stake/pot, winner, payout/fee |
| Poker | poker.create, poker.join, poker.hand_start, poker.action, poker.auto, poker.leave, poker.hand_end |
invite/table, user/chat, action, stack/bet/pot, outcome |
| Secret Hitler | sh.create, sh.join, sh.start, sh.nominate, sh.vote, sh.president_discard, sh.chancellor_enact, sh.leave |
invite/game, user/chat, phase/action/outcome |
| Challenges | challenges.created, challenges.accepted, challenges.declined, challenges.tie, challenges.completed, challenges.failed_refunded |
duel/chat/users, game, amount, rolls, winner, payout/fee |
| Transfers | transfer.completed |
sender/recipient/chat, net, fee, total |
| Redeem | redeem.issued, redeem.invalid_code, redeem.already_redeemed, redeem.self_redeem, redeem.success, redeem.captcha_succeed, redeem.captcha_failed |
issuer/redeemer, code/game, outcome |
| Leaderboard | leaderboard.viewed, leaderboard.balance_viewed |
mode, scope, limit, returned users/chats, visibility, outcome |
| PixelBattle | pixelbattle.opened, pixelbattle.tile_updated |
user/chat, tile index/color/version, validation outcome |
| Domain events | basketball.throw_completed, bowling.roll_completed, darts.throw_completed, dice.roll_completed, dicecube.roll_completed, football.throw_completed, blackjack.hand_started, blackjack.hand_updated, blackjack.hand_completed, blackjack.hand_closed, blackjack.state_message_set, horse.bet_placed, horse.race_finished, poker.table_created, poker.player_joined, poker.hand_started, poker.hand_ended, sh.game_created, sh.player_joined, sh.game_started, sh.game_ended, redeem.issued, redeem.redeemed, telegram_dice.redeem_code_drop_requested, meta.game_completed, meta.game_streak_updated, meta.achievement_unlocked, meta.transfer_completed, meta.daily_claimed, debug.es_smoke_incremented |
full reflected domain properties plus canonical ES metadata |
| Writer/backfill health | meta_analytics.clickhouse_writer_health, meta_analytics.event_backfill_health |
batch/buffer/flush counters and duration; rows read/tracked/checkpoint |
| Responsible gaming | responsible_gaming.settings_viewed, responsible_gaming.wager_blocked |
user/action, block reason, limit usage and expiry |
| Admin reporting | meta_analytics.weekly_admin_summary, meta_analytics.economy_anomaly_alert |
weekly activity/economy totals or wallet/ledger anomaly counts |
Domain-event rows use the contract's exact EventType; they are separate from service-level intent/outcome analytics. The canonical _es table is authoritative for persisted domain-event history.
MetaAnalyticsSnapshotJob runs every five minutes. It performs bounded aggregate queries against PostgreSQL, writes the following events to ClickHouse, and records its own duration/failure/freshness. Grafana queries ClickHouse only.
| Event type | Fields in params |
|---|---|
meta_analytics.economy_totals |
coinSupply, wallets, nearZeroWallets, p50Coins, p90Coins, p99Coins, top1Coins, top5Coins, top10Coins |
meta_analytics.economy_integrity_snapshot |
walletCoinSupply, latestLedgerSupply, mismatchedWallets, mismatchAbsoluteCoins, walletsWithoutLedger, balanceGini, topDecileCoinSharePercent |
meta_analytics.ledger_reason_window |
reason, rows, credits, debits, net |
meta_analytics.ledger_health_snapshot |
rowsWindow, creditsWindow, debitsWindow, netWindow, idempotentRows, negativeBalanceRows, zeroDeltaRows, lastLedgerAt, lastLedgerAgeSeconds |
meta_analytics.game_economy_window |
module, rows, stake, payout, net, users |
meta_analytics.engagement_snapshot |
wallets, users, balanceScopes, newWallets24H, newWallets7D, activeWallets24H, dailyClaimersToday, transactingUsers24H, activeScopes24H, activeChats24H |
meta_analytics.delivery_snapshot |
outbox pending/sent/failed/max attempts/oldest age; update counts and average/p95 latency; delivery average/p95 latency |
meta_analytics.reliability_snapshot |
unresolved/retrying/new/resolved failures, max retry/oldest age, idempotency failures/stuck operations, due outbox rows |
meta_analytics.game_state_snapshot |
live sessions/gates/challenges/lotteries, lottery pot, active poker/Secret Hitler games and players |
meta_analytics.game_health_snapshot |
stale games/challenges, completed/cancelled challenges/lotteries, expired sessions/gates |
meta_analytics.social_overview_snapshot |
new/active chats, transfers/coins, challenges created, social users (24h) |
meta_analytics.chat_type_snapshot |
chatType, chats, new24H, active24H |
meta_analytics.season_snapshot |
season identity/status, players/games/wins/losses/XP/stake/payout/level/clans |
meta_analytics.quest_period_snapshot |
season/period, rows, started, completed, claimed |
meta_analytics.quest_snapshot |
quest, rows, started/completed/claimed, average progress ratio |
meta_analytics.whale_balance |
user/scope, coins, rank |
meta_analytics.player_rtp |
season/chat/user/name, stake, payout, RTP |
meta_analytics.risk_flags |
severity, status, rows |
meta_analytics.ops_snapshot |
unresolved dispatch failures, known chats, processed updates, admin actions |
meta_analytics.snapshot_job_health |
outcome/error, duration, window, completion time |
| Metric | Definition |
|---|---|
| DAU | Exact distinct non-zero user_id values active on a UTC calendar day |
| D1/D7/D30 retention | Exact cohort users with activity 1/7/30 UTC days after their first observed day |
| New/returning | New when activity day equals first observed day; returning when later |
| Session | Same (user_id, chat_id) with less than 30 minutes inactivity; restart begins a new in-memory session |
| Engaged session | Session containing at least one non-framework, non-Telegram product-module event |
| Abandoned session | Session containing ingress events but no product-module event |
| Funnel intent | Bet/create event; completion is a terminal game event; rejection is an insufficient-balance event |
| RTP | payout / stake; dashboards display percentage and guard zero stake |
| House edge | (stake - payout) / stake, displayed as a percentage |
| Balance Gini | Standard ranked-wallet Gini coefficient over current wallet balances |
| Top-decile share | Percentage of total coins owned by the highest wallet decile |
| Economy mismatch | Current wallet balance differs from the latest ledger balance_after, or no ledger row exists |
| Ingestion lag | now() - max(created_at); ES source lag uses now() - max(occurred_at) |
| Handler/update/delivery latency | Server elapsed time in milliseconds; percentile panels use exact p50/p95/p99 or p95 as labeled |
| Stale multiplayer game | Active poker/Secret Hitler state with no action for 30 minutes |
/mystats is private-chat only. It shows the caller's current private-wallet balance plus global 7/30-day game stakes and classified payouts. It supports /mystats limit <coins|off>, /mystats cooldown <1h..30d>, and /mystats exclude <7d..3650d>. Cooldowns and self-exclusion cannot be shortened before expiry. Protection is global per Telegram user across every wallet/chat; the daily limit resets at 00:00 UTC.
All ordinary game debits pass the protection check inside the same PostgreSQL transaction as the wallet mutation. A per-user advisory transaction lock prevents concurrent bets from exceeding the daily limit. Admin corrections, transfers, and payout rollbacks bypass wagering controls.
meta.operations_reporting runs every 15 minutes. It queues one summary per completed ISO week to every configured bot admin through the durable Telegram outbox. It also detects negative wallets, latest-ledger/current-wallet mismatches, and balance mutations of at least 100,000 coins; identical alerts are deduplicated per UTC hour.
| Dashboard | Primary data |
|---|---|
overview.json, clickhouse-events-explorer.json |
Raw events_v2 event inventory/tail |
interactions-clickhouse.json |
telegram.* ingress and interaction tags |
analytics-clickhouse.json |
Raw product events, users/chats, economy tags, retention/funnels |
product-analytics-clickhouse.json |
meta_analytics.* five-minute snapshots |
growth-reliability-clickhouse.json |
Raw product/Telegram events, snapshots, writer/backfill health, system.*, and canonical events_v2_es FINAL |
infra-pg-redis.json, infra-services.json, dotnet-runtime.json |
Prometheus exporters/runtime metrics rather than ClickHouse |
- Live persisted events carry
(stream_id, stream_version)through in-process and CAP broker delivery. event_idis deterministic for persisted events, so retries have the same logical identity.events_v2_esusesReplacingMergeTree; canonical queries useFINALto collapse duplicate physical deliveries.EventAnalyticsBackfillServicereadsmodule_events.idin ascending batches of 500.- PostgreSQL
event_analytics_checkpoint.last_event_idadvances only after a successful ClickHouse flush. - Unreadable historical event types are logged and counted as skipped; the batch can advance instead of blocking forever.
- Replay rows have
is_replay=1and never enter the general product table. - Writer/backfill failures retain or retry buffered work; gameplay does not wait for analytics.
created_atis ingestion time;occurred_atis the source/domain time and must be used for historical ES timelines.
1,462 xUnit tests under tests/CasinoShiz.Tests/. The suite combines in-memory fakes (FakeEconomicsService, InMemoryBlackjackHandStore, etc.) with PostgreSQL integration tests provisioned by Testcontainers.PostgreSql; CI therefore requires Docker, but no manually managed external database. Boundary tests also enforce that contract/backend assemblies do not acquire Telegram or gRPC dependencies. DailyBonusMath unit-tests the bonus coin formula.
dotnet test
dotnet test --filter "FullyQualifiedName~HandEvaluatorTests"
dotnet test --filter "DisplayName~majorityJa"Coverage: domain logic (poker, secret hitler, blackjack, dice), services, taxes, PRNG, Russian plurals, router attribute scanning, InProcessEventBus, all framework contracts.
All UI in Russian. Command names are ASCII.
| Command | Effect |
|---|---|
🎰 |
Slot machine — Games:dice:Cost + gas, fixed prize table, optional same-game redeem-code drop |
/dice bet <amount> |
Bot sends 🎲 after bet (reply to you); you can still send your own 🎲. 4→×1, 5→×2, 6→×2 |
/darts bet <amount> |
Bot sends 🎯 or you throw. 4→×1, 5→×2, 6→×2 |
/football bet, /basket bet |
Bot sends ⚽/🏀 or you throw. 4→×2, 5→×2 |
/bowling bet |
Bot sends 🎳 or you throw. 4→×1, 5→×2, 6 (strike)→×2 |
/horse bet <1-N> <amount> |
Bet on today's race in this chat |
/horse info |
This chat's pool: stakes + koefs |
/horse result |
This chat's last race GIF, else today's global race |
/poker create / join / start / fold / call / raise <n> / check / leave |
Texas Hold'em |
/blackjack <bet> |
Start a hand; inline keyboard drives hit / stand / double |
/sh create / join / start / nominate <user> / vote yes|no / leave |
Secret Hitler (5–10 players) |
/pick <variants…> and variants (see /pick help) |
Pick game with explicit choice / parlay / streak / ×2 chain |
/roll [question] |
Random percentage; with a question, returns a forecast with a reduced fraction |
/choose <a, b or lines> |
Non-betting random choice: 2–50 options, max 50 characters each |
/ben |
Sends one of five configured Talking Ben GIFs (47% / 47% / 2% / 2% / 2%) |
/picklottery <stake>, /pickjoin, /picklottery info / cancel / help |
5-min lottery in this chat |
/dailylottery [buy] [N], /dailylottery history / help |
Daily lottery in this chat (draw at the configured local hour) |
/challenge @user <amount> <game> |
1v1 PvP stake challenge |
/challenge <amount> <game> |
Same, when replying to the target user's message |
/pixelbattle |
Open the PixelBattle Telegram WebApp |
/transfer <target> <amount> |
Send coins (in groups only). Amount = recipient net |
/redeem <uuid> |
Redeem one extra roll for the code's game (private chat only, emoji captcha) |
/balance |
Current coin balance for this chat's wallet |
/daily |
Once per local day after offset: small % of balance, capped — see Bot:DailyBonus |
/top [full] |
Per-chat leaderboard |
/mystats |
Private 7/30-day stake and payout statistics plus active protection settings |
/mystats limit <coins|off> |
Set or remove the global UTC daily stake limit |
/mystats cooldown <1h..30d> |
Start a non-cancellable cooldown across all chats |
/mystats exclude <7d..3650d> |
Start fixed-term self-exclusion across all chats |
/menu |
Interactive wallet/profile hub with quests, achievements, season top, daily bonus, and game navigation |
/season |
Active season information |
/profile, /rank |
Seasonal XP, level, rating, division, and player statistics |
/topseason |
Seasonal leaderboard for this chat |
/achievements |
Seasonal achievement progress |
/quests |
Daily and weekly quest progress |
/quest claim <id> |
Claim one completed quest reward |
/clan create, join, info, members, top |
Chat-scoped clans and seasonal clan leaderboard |
/tournament ..., /tour ... |
Create/join/manage chat-scoped tournament brackets |
/risk list, /risk resolve <id>, /risk ignore <id> |
Current-chat risk flags; currently not role-gated |
/help |
Command reference (HTML), dynamic — pulls actual config values |
| Command | Restriction | Effect |
|---|---|---|
/horserun [global|all] |
admin | Run today's race (group → this chat's pool; private → global by default) |
/codegen [count] |
redeem-admin | Generate copy-paste-ready /redeem <uuid> freespin codes |
/run pay <id> <amount> |
admin | Manual coin adjustment |
/run userinfo |
admin | Reply to a message → returns Telegram user id |
/run cancel_blackjack <id> |
admin | Refund and remove a stuck hand |
/run kick_poker <id> |
admin | Remove a player from a table and refund their stack |
/rename <old> <new|*> |
admin | Display-name override; * clears |
/topall [full|split] |
admin + private DM only | Cross-chat leaderboard (aggregated by default; split = per-chat breakdown) |
/chats [filter] [full] |
admin + private DM only | List bot chats, with type filter and optional full mode |
/analytics [window] |
admin + private DM only | ClickHouse summary (events / users / tops / timeline) |
- User strings: Russian, live in each module's
Locales.cs— never inline. - Plural forms:
RussianPlural.Plural(n, ["монета","монеты","монет"]). - Randomness: Telegram dice outcomes come from Telegram; blackjack/poker decks use
RandomNumberGenerator; horse winner/place generation uses the horse generator path and renders deterministic speed series for the chosen places;/pickand pick lotteries useRandom.Shared(sufficient for non-monetary fairness — the house edge / fee is the protective layer). - No
Task.Delayfor scheduling — useIBackgroundJob/IHostedServicesweepers. - Services return result records; handlers map to messages. Only throw for programmer errors.
- Balance changes go through
IEconomicsService— always. Never raw SQL onusers.coins. - Critical async Telegram notifications go through
ITelegramOutbox; directctx.Bot.SendMessage(...)is for live handler replies. - Logging: source-generated
[LoggerMessage]only. No string interpolation in log calls. Classes that use[LoggerMessage]must bepartial. - Primary constructors are the default style:
public sealed class Foo(Dep dep) : IBar. - Services:
Scoped; hosted services / analytics / process-memory state stores (e.g.PickStreakStore,PickChainStore):Singleton;ITelegramBotClient:Singleton. - Module migrations are append-only — never edit a shipped migration; add a new one.
Games:*config values are tunable at runtime through/admin/settings. Hard limits and security-sensitive options stay inappsettings.json/ env (e.g. tokens, admin lists).