Загрузка данных


ТЗ: мультиплеерный «Дурак» на 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.