Загрузка данных
ТЗ: мультиплеерный «Дурак» на Go + Nakama
1. Цель
Разработать production-ready server-side мультиплеерной игры «Дурак»:
Go + Nakama + PostgreSQL;
WebSocket / Realtime;
HTTPS/RPC;
S3-compatible storage для аватаров.
Клиент не входит в scope.
Поддержать:
подкидной;
переводной;
2–4 игрока;
public/private rooms;
matchmaking;
внутриигровую валюту;
ставки и holds;
server-authoritative gameplay;
30s timer;
reconnect до deadline;
timeout → forfeit;
audit;
ledger;
crash recovery/reconciliation.
Серверный protocol должен быть достаточен для независимой реализации клиента.
2. Server authoritative
Сервер — единственный источник истины для:
game state;
cards/deck/trump;
turns/phases;
legal actions;
timer;
seats;
result;
economy/settlement.
Клиент отправляет только intent:
ATTACK
BEAT
TAKE
TRANSLATE
THROW_IN
Сервер сам определяет player_id, seat, role, phase, result, balance, deadline.
Клиент не может передать или изменить эти значения.
3. Stack / versions
Обязательные:
Go
Nakama
PostgreSQL
Nakama WebSocket/Realtime
HTTPS/RPC
S3-compatible storage
Зафиксировать:
NAKAMA_VERSION
NAKAMA_COMMON_VERSION
GO_VERSION
POSTGRES_VERSION
PROTOCOL_VERSION
RULES_VERSION
PAYOUT_CONFIG_VERSION
Обязательны:
go.mod
go.sum
Dockerfile
docker-compose.yml
Makefile
migrations/
Versions Nakama/nakama-common должны быть совместимы.
4. Архитектура
Client
↓ HTTPS / WebSocket
Nakama
├─ Auth
├─ RPC
├─ Matchmaker
└─ Authoritative Match
├─ Game Engine
└─ Economy
↓
PostgreSQL
Nakama
auth;
sessions;
WebSocket;
RPC;
matchmaking;
match lifecycle;
transport.
Game Engine
cards/deck;
shuffle/RNG;
rules;
hands;
turns;
phases;
timer;
game result.
Не зависит от Nakama/transport/client и тестируется отдельно.
Economy
balance;
holds;
capture/release;
payout;
rake;
ledger;
settlement;
reconciliation.
Game Engine не изменяет balance напрямую.
5. Database
players
CREATE TABLE players (
id BIGSERIAL PRIMARY KEY,
nakama_user_id UUID NOT NULL UNIQUE,
name TEXT NOT NULL,
picture TEXT,
money BIGINT NOT NULL DEFAULT 0 CHECK (money >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Nakama user_id — внешний ID.
players.id — внутренний ID.
rooms
CREATE TABLE rooms (
id UUID PRIMARY KEY,
players_count INT NOT NULL CHECK (players_count BETWEEN 2 AND 4),
bet BIGINT NOT NULL CHECK (bet > 0),
game_type TEXT NOT NULL CHECK (
game_type IN ('throw_in', 'transfer')
),
status TEXT NOT NULL CHECK (
status IN ('waiting','starting','playing','finished','cancelled')
),
match_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
Room = lobby/financial entity.
Nakama match = runtime.
Одна room → максимум 1 game + 1 match.
balance_holds
CREATE TABLE balance_holds (
id UUID PRIMARY KEY,
player_id BIGINT NOT NULL REFERENCES players(id),
room_id UUID NOT NULL REFERENCES rooms(id),
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT NOT NULL CHECK (
status IN ('held','captured','released')
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(player_id, room_id)
);
JOIN:
available >= bet
→ money -= bet
→ hold = bet
В одной PostgreSQL transaction.
Отмена:
held → released
money += amount
Start:
held → captured
games
CREATE TABLE games (
id UUID PRIMARY KEY,
room_id UUID NOT NULL UNIQUE REFERENCES rooms(id),
pool BIGINT NOT NULL CHECK (pool >= 0),
finish_kind TEXT NOT NULL CHECK (
finish_kind IN ('normal','forfeit','cancelled')
),
rules_version TEXT NOT NULL,
payout_config_version TEXT NOT NULL,
rng_seed BYTEA NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
finalized_at TIMESTAMPTZ,
result_json JSONB
);
finalized_at != NULL = settlement завершён.
game_players
CREATE TABLE game_players (
game_id UUID NOT NULL REFERENCES games(id),
player_id BIGINT NOT NULL REFERENCES players(id),
seat INT NOT NULL CHECK (seat BETWEEN 0 AND 3),
place INT,
payout BIGINT NOT NULL DEFAULT 0 CHECK (payout >= 0),
forfeited BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (game_id, player_id),
UNIQUE (game_id, seat)
);
Seat назначает сервер.
payout_config
CREATE TABLE payout_config (
players_count INT NOT NULL CHECK (players_count BETWEEN 2 AND 4),
place INT NOT NULL,
numerator INT NOT NULL CHECK (numerator >= 0),
denominator INT NOT NULL CHECK (denominator > 0),
PRIMARY KEY (players_count, place)
);
Version фиксируется в games.
transactions
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
player_id BIGINT REFERENCES players(id),
game_id UUID REFERENCES games(id),
amount BIGINT NOT NULL,
reason TEXT NOT NULL CHECK (
reason IN ('bet','payout','refund','rake','admin_adjustment')
),
idempotency_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
player_id = NULL = house.
amount > 0 = credit, < 0 = debit.
Любое изменение денег должно иметь ledger entry.
game_events
CREATE TABLE game_events (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL REFERENCES games(id),
sequence BIGINT NOT NULL,
player_id BIGINT REFERENCES players(id),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(game_id, sequence)
);
Audit immutable.
6. Economy invariants
Для начатой игры:
pool = sum(captured holds)
Для завершённой:
sum(payouts) + rake = pool
Всегда:
money >= 0
payout >= 0
Все money operations — PostgreSQL transactions + row locks/atomic operations.
7. Settlement
Settlement:
BEGIN
lock game
if finalized_at != NULL → return already finalized
lock players
calculate result
write ledger
update balances
update game_players
set games.finalized_at
COMMIT
Settlement должен быть idempotent при:
duplicate RPC;
retry;
worker restart;
process crash;
Nakama restart;
recovery.
Примеры deterministic idempotency keys:
game:{game_id}:player:{player_id}:payout
game:{game_id}:player:{player_id}:refund
game:{game_id}:rake
8. Recovery
Reconciliation job ищет:
expired waiting rooms;
held holds у cancelled/inactive rooms;
finished games без finalized_at;
playing rooms без runtime match.
Для каждого случая — deterministic recovery policy.
Recovery не должен повторно начислять деньги.
9. Game Engine
Card
type Card struct {
Rank uint8
Suit uint8
}
Ranks:
6..14
14 = Ace
36 cards.
RNG
При создании game:
cryptographically secure random seed;
seed сохраняется в games.rng_seed;
фиксируются PRNG и shuffle algorithm;
одинаковые seed + rules version дают одинаковую колоду.
Seed используется для audit/debug/replay, но не является proof of fairness.
Deck
36 cards
→ shuffle
→ deal
Trump определяется стандартным правилом.
Открытая trump card остаётся последней картой deck.
Client получает:
trump_suit
deck_count
Порядок оставшихся карт не раскрывается.
GameState
type GameState struct {
GameID string
Seats []Seat
Hands map[string][]Card
Deck []Card
TrumpSuit uint8
Table []TablePair
AttackerSeat int
DefenderSeat int
ActiveSeat int
Phase Phase
TurnDeadline int64
Out []string
Forfeited []string
RngSeed []byte
Sequence uint64
}
Runtime state находится в authoritative match.
Чужие cards клиенту не отправляются.
Seat
type Seat struct {
UserID string
PlayerID int64
Index int
}
Reconnect:
Nakama user_id
→ player
→ game
→ game_player
→ seat
TablePair
type TablePair struct {
ID uint64
Attack Card
Defense *Card
}
ID уникален внутри game.
Client использует attack_id, не array index.
Phase
type Phase int
const (
PhaseWaiting Phase = iota
PhaseDeal
PhaseTrickStart
PhaseAttack
PhaseDefend
PhaseThrowIn
PhaseResolveBeat
PhaseResolveTake
PhaseDraw
PhaseCheckEnd
PhaseGameOver
)
State transitions только сервер.
State machine
WAITING
→ DEAL
→ TRICK_START
→ ATTACK
→ DEFEND
├─ BEAT
├─ THROW_IN
├─ TRANSLATE
└─ TAKE
→ RESOLVE
→ DRAW
→ CHECK_END
→ ATTACK / GAME_OVER
Timeout в active-player phase:
TURN_TIMEOUT → FORFEIT
10. Rules
First attacker
Определяется детерминированно:
младший trump;
если trump нет — минимальная card по зафиксированному ordering.
Обязательно:
CompareOpeningCard(a, b Card) int
с tests.
ATTACK
Разрешён только attacker.
Проверить:
player/game membership;
not forfeited;
correct phase;
attacker role;
card ownership;
legal card;
table limit.
Все cards валидируются до mutation.
Одна invalid card → вся команда отклоняется.
BEAT
Обычная карта:
same suit && defense.rank > attack.rank
Trump против trump:
defense is trump
&& attack is trump
&& defense.rank > attack.rank
Обычная card против trump — invalid.
THROW_IN
Разрешён живым игрокам, кроме defender.
Card:
есть в hand;
соответствует rank на table;
legal по table limit.
Максимум:
min(6, defender_hand_size_at_trick_start)
Если rules version использует limit 5:
min(5, defender_hand_size_at_trick_start)
Несколько concurrent THROW_IN обрабатываются последовательно authoritative match.
Каждый следующий action проверяется против нового state.
TRANSLATE
Только до начала отбивания.
Проверить:
card принадлежит defender;
rank соответствует translation rules;
table limit;
translation chain.
После успешного перевода:
old defender → attacker
next player → defender
Server пересчитывает attacker/defender/active/deadline.
TAKE
При TAKE:
table → defender hand
Следующий attacker:
player to the left of defender
RESOLVE BEAT
Все cards побиты:
table → discard
→ DRAW
Defender становится следующим attacker по rules.
DRAW
До 6 cards.
Порядок:
attacker
→ next players clockwise
→ defender last
Deck empty → stop.
Последняя trump card выдаётся согласно deterministic draw algorithm.
GAME END
Первый player без cards:
place = 1
Остальные places — порядок выхода.
Последний player с cards = durak.
Tie не поддерживается.
Одновременное завершение определяется server action/event order.
11. Timer
Каждый active turn:
30 seconds
TurnDeadline = server_now + 30s
Server time — единственный источник истины.
Tick:
10/sec
Tick только проверяет deadline.
Для:
ATTACK — attacker;
DEFEND — defender;
THROW_IN — одно общее defensive window.
После успешного THROW_IN:
TurnDeadline = now + 30s
12. Disconnect / reconnect
Disconnect не меняет state.
MatchLeave != Forfeit
Timer продолжает идти.
Reconnect разрешён до deadline.
Grace period отсутствует.
После reconnect:
GAME_STATE
YOUR_HAND
TURN_TIMER
Server сам определяет seat/role/cards/phase/deadline.
13. Forfeit
Timeout:
forfeit(activeSeat)
Server:
forfeited = true;
audit event;
finish_kind = forfeit;
GAME_OVER;
settlement.
MVP: forfeit сразу заканчивает game.
Forfeit settlement
Для N игроков:
forfeiter → stake to house;
остальные → stake refund.
Пример 4×100:
forfeiter: -100
others: +100 each
house: +100
Все операции в ledger.
14. Normal settlement
payout_config_version определяет payout.
Пример:
pool = 4B
place 1 = 2B
place 2 = 1B
place 3 = 0.5B
place 4 = 0
rake = 0.5B
Пример не является обязательной production configuration.
Payout рассчитывается deterministic.
Rounding/remainder policy должна быть зафиксирована.
Обязательно:
sum(payouts) + rake = pool
15. Protocol
Каждый client message:
{
"version": 1,
"request_id": "uuid",
"type": "ATTACK",
"game_id": "uuid",
"sequence": 123,
"payload": {}
}
request_id — deduplication.
sequence — последнее состояние клиента.
Устаревший sequence:
ERR_STALE_STATE
Исключение — resync.
Client actions
ATTACK
{
"type": "ATTACK",
"payload": {
"cards": [
{"rank": 6, "suit": 1}
]
}
}
BEAT
{
"type": "BEAT",
"payload": {
"attack_id": 17,
"card": {"rank": 10, "suit": 2}
}
}
TAKE
{"type":"TAKE"}
TRANSLATE
{
"type": "TRANSLATE",
"card": {"rank": 9, "suit": 3}
}
THROW_IN
{
"type": "THROW_IN",
"payload": {
"cards": [
{"rank": 9, "suit": 1}
]
}
}
16. Server messages
Минимум:
GAME_STATE
YOUR_HAND
TURN_TIMER
ACTION_ACCEPTED
ACTION_REJECTED
GAME_STARTED
GAME_FINISHED
PLAYER_JOINED
PLAYER_LEFT
PLAYER_RECONNECTED
PLAYER_FORFEITED
BALANCE_UPDATED
ERROR
GAME_STATE:
{
"type": "GAME_STATE",
"version": 1,
"game_id": "uuid",
"sequence": 123,
"phase": "DEFEND",
"attacker_seat": 0,
"defender_seat": 1,
"active_seat": 1,
"trump_suit": 2,
"deck_count": 18,
"table": [],
"players": [],
"deadline": 1730000000
}
YOUR_HAND содержит только cards текущего player.
17. Errors
Стабильные machine-readable codes:
ERR_UNAUTHORIZED
ERR_FORBIDDEN
ERR_INVALID_REQUEST
ERR_PROTOCOL_VERSION
ERR_GAME_NOT_FOUND
ERR_GAME_FINISHED
ERR_PLAYER_NOT_IN_GAME
ERR_PLAYER_FORFEITED
ERR_INVALID_PHASE
ERR_NOT_YOUR_TURN
ERR_INVALID_ACTION
ERR_INVALID_CARD
ERR_CARD_NOT_IN_HAND
ERR_INVALID_BEAT
ERR_INVALID_TRANSLATE
ERR_INVALID_THROW_IN
ERR_TABLE_LIMIT
ERR_STALE_STATE
ERR_TIMEOUT
ERR_ROOM_NOT_FOUND
ERR_ROOM_FULL
ERR_ROOM_STARTED
ERR_INVALID_BET
ERR_INSUFFICIENT_FUNDS
ERR_HOLD_FAILED
ERR_ALREADY_JOINED
ERR_ALREADY_FINALIZED
ERR_INTERNAL
SQL/internal errors наружу не выдаются.
18. Sequence / resync
Каждая state-changing mutation:
Sequence += 1
Sequence строго монотонный.
При gap клиент вызывает:
GET_GAME_STATE
RPC возвращает актуальный state только в пределах прав пользователя.
Resync не изменяет state.
19. Rooms / matchmaking
Room lifecycle:
WAITING
→ STARTING
→ PLAYING
→ FINISHED
или:
WAITING → CANCELLED
Cancellation → release holds.
Нельзя менять:
bet;
players_count;
game_type
после start.
Matchmaking учитывает:
game type;
players count;
bet.
Public room может участвовать в matchmaking.
Private room использует server-generated join code.
20. Audit / recovery
Каждое state-changing действие записывается в game_events.
Минимум:
GAME_CREATED
GAME_STARTED
PLAYER_JOINED
DEAL
ATTACK
BEAT
THROW_IN
TRANSLATE
TAKE
DRAW
TURN_CHANGED
TURN_TIMEOUT
FORFEIT
GAME_FINISHED
SETTLEMENT_STARTED
PAYOUT
REFUND
RAKE
SETTLEMENT_FINISHED
RECOVERY
Каждый event имеет sequence.
21. Security
Server всегда получает identity из Nakama session.
Нельзя доверять client:
user_id;
player_id;
seat;
cards ownership;
balance;
result;
payout;
timer;
winner/loser.
Все game actions проходят server validation.
Private room codes должны быть непредсказуемыми.
Secrets не хранятся в repository.
22. Tests
Обязательны unit + integration tests.
Покрыть:
cards/deck/trump;
RNG reproducibility;
attack;
beat;
take;
throw-in;
translate;
draw;
game end;
timer;
timeout;
forfeit;
payout/rounding;
holds;
settlement;
duplicate settlement;
recovery;
reconciliation;
concurrent holds;
concurrent throw-in;
reconnect;
stale sequence;
duplicate request.
Минимальные сценарии:
2/3/4 players;
normal game;
forfeit;
disconnect/reconnect;
reconnect after deadline;
deck exhaustion;
crash before settlement;
crash/retry during settlement.
23. Definition of Done
☑ go test ./... проходит.
☑ PostgreSQL migrations работают с чистой БД.
☑ Nakama запускается на pinned version.
☑ Public/private rooms работают.
☑ Matchmaking работает.
☑ Holds атомарны.
☑ Game Engine тестируется независимо от Nakama.
☑ Все actions server-authoritative.
☑ Клиент не может подменить seat/cards/result/balance/timer.
☑ Timer = 30s по server time.
☑ Disconnect не вызывает немедленный forfeit.
☑ Reconnect до deadline восстанавливает game.
☑ Timeout вызывает forfeit.
☑ Forfeit вызывает settlement.
☑ Normal settlement idempotent.
☑ Duplicate RPC безопасен.
☑ Crash recovery безопасен.
☑ Reconciliation безопасен.
☑ Ledger балансируется.
☑ sum(payouts) + rake = pool.
☑ money >= 0.
☑ Audit sequence монотонен.
☑ rules_version, payout_config_version, rng_seed фиксируются на game.
☑ Чужие cards не раскрываются.
☑ GET_GAME_STATE позволяет восстановить client state.
☑ Docker build воспроизводим.
☑ Нет secrets в repository.