@idealic/poker-engine
Version:
Poker game engine and hand evaluator
573 lines (466 loc) • 29.6 kB
Markdown
# Спецификация: Sit In & Sit Out
## Задача
Реализовать возможность:
- присоединиться к игре (со следующего раунда)
- покинуть игру (со следующего раунда)
- взять паузу (игрок неактивен)
- дождаться BB (с момента, когда игрок на позиции `BB`)
Этот функционал может быть реализован только через изменение игровой нотации, так как только игровая нотация может быть передана между клиентом и сервером.
Игровая нотация, которую мы используем как основу игрового состояния для обмена между клиентом и сервером, не предусматривает подобного функционала. ПХХ‑нотация предполагает, что игроки определяются до начала игры и остаются в игре на протяжении всей сессии. В то же время нотация PokerStars поддерживает такие сценарии, как возможность временно выйти из игры и затем вернуться.
В движке уже реализована поддержка user-defined полей `_inactive` и `_deadBlinds`, которые и используются для обратной совместимости игровой нотации между форматами phh<->pokerstars
```
/** Array with one entry per player; any non-zero value means the player is sitting out */
_inactive?: number[];
/** Array of dead blinds */
_deadBlinds?: number[];
```
Если добавить поле `_intents`, то можно добиться желаемого поведения
```
/** Array with one entry per player; can be zero(no pause) and any integer */
_intents: number[];
```
## Архитектурное видение полей состояния
### Разделение ответственности клиент-сервер
**Поле `_intents`** - это клиентское поле намерений:
- Отражает желание игрока изменить свое состояние (присоединиться, взять паузу, покинуть игру)
- Игрок может изменять ТОЛЬКО значение `_intents` для себя
- Все изменения других полей игроком будут проигнорированы сервером
**Поля `_inactive` и `_deadBlinds`** - это серверные поля состояния:
- Управляются исключительно серверной логикой
- Изменяются сервером на основе анализа `_intents` и игровой ситуации
- Клиент НЕ МОЖЕТ напрямую изменять эти поля
### Поток синхронизации состояния
1. **Клиент → Сервер**: Игрок изменяет `_intents` и отправляет игровое состояние
2. **Сервер**: Анализирует намерения из `_intents`, валидирует, обновляет `_inactive` и `_deadBlinds`
3. **Сервер → Клиент**: Отправляет синхронизированное состояние с актуальными значениями всех полей
4. **Клиент**: Рендерит UI на основе полученного состояния
### Значения поля `_intents`
- `0` - Игрок хочет играть (активное состояние)
- `1` - Игрок хочет взять паузу до позиции BB
- `2` - Игрок хочет взять простую паузу (без привязки к позиции)
- `3` - Игрок хочет покинуть игру окончательно
## Матрица состояний игрока
Комбинации полей отражают текущее состояние игрока, где:
- `_intents` - намерение игрока (управляется клиентом)
- `_inactive` и `_deadBlinds` - фактическое состояние (управляется сервером)
| \_inactive | \_intents | \_deadBlinds | Состояние | Описание |
| :--------: | :-----: | :----------: | ------------------------ | -------------------------------------- |
| **0** | 0 | 0 | **Активная игра** | Игрок участвует в текущей раздаче |
| **0** | 1 | 0 | **Запрос паузы до BB** | Игрок запросил паузу в текущей раздаче |
| **0** | 2 | 0 | **Запрос простой паузы** | Игрок запросил паузу без привязки к BB |
| **0** | 3 | 0 | **Запрос выхода** | Игрок запросил выход из игры |
| **1** | 0 | 0 | **Ожидание входа** | Новый игрок ждет следующей раздачи |
| **1** | 0 | >0 | **Готов к возврату** | Игрок хочет вернуться с оплатой долга |
| **1** | 1 | 0-1.5 | **Пауза до BB** | На паузе, ждет позиции BB |
| **1** | 2 | 0-1.5 | **Простая пауза** | На паузе без привязки к позиции |
| **1** | 3 | любое | **Выход из игры** | Покидает игру, долги не платит |
### Невозможные состояния
| \_inactive | \_intents | \_deadBlinds | Причина |
| :--------: | :-----: | :----------: | ---------------------------------------------- |
| **0** | 0 | >0 | Активный игрок не может иметь мертвых блайндов |
| **0** | 1 | >0 | Активный игрок не может иметь долгов |
| **0** | 2 | >0 | Активный игрок не может иметь долгов |
| **0** | 3 | >0 | Активный игрок не может иметь долгов |
### Ключевые правила перехода между состояниями:
1. **Присоединение к игре**: `_inactive: 1, _intents: 0` → игрок получает карты в следующей раздаче
2. **Взятие паузы**: `_intents: 1` или `_intents: 2` → `_inactive` становится 1 со следующего действия
3. **Накопление мертвых блайндов**: При `_inactive: 1` и `_intents: 1|2` за каждый пропущенный SB +0.5, за BB +1 (максимум `1.5 BB` в коэффициентах, хранится в абсолютных значениях фишек)
4. **Возврат с позиции BB**: `_intents: 1` → при достижении BB позиции `_deadBlinds` обнуляется
5. **Досрочный возврат**: `_intents: 2 → 0` → игрок платит накопленные `_deadBlinds`
6. **Окончательный выход**: `_intents: 3` → игрок удаляется из всех массивов в следующей раздаче
## Присоединение к игре
Чтобы `Player3` смог присоединиться к игре, сначала сервер должен отправить персонализированное игровое состояние для `Player3`. Он не сможет увидеть карты игроков.
**Запрос игры(любой или конкретной) с блайндами $1\2 от сервера**
**Клиент**
```
{ // Current hand state, Player1 and Player2 is already playing
author: 'Player3',
hand: 1,
players: ['Player1', 'Player2', 'Player3'], // `Player3` is added himself to `players` array ✅
startingStacks: [50, 100, 100], // desired stack size for `Player3` is 100 chips. ✅
blindsOrStraddles: [1, 2, 0], // `Player3` want to take UTG position ✅
antes: [0, 0]
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 ????',
'p1 cc',
'p2 cc',
'd db 2d7cJh',
],
_inactive: [0, 0],
_intents: [0, 0, 0], // `Player3` wants to join the game and added himself with state `0` - "ready to play" ✅
}
```
### Клиентская логика
#### Права доступа
- Игрок может изменять **ТОЛЬКО** поле `_intents` для себя
- Игрок может добавлять себя в массивы игроков при присоединении
- Все остальные изменения будут проигнорированы сервером
#### Процесс присоединения
1. Игрок добавляет себя в массив `players`
2. Указывает желаемый `buyIn` в `startingStacks`
3. Выбирает позицию в `seats`, если хочет сесть на конкретное место
4. Устанавливает `_intents: 0` (готов играть)
5. Отправляет состояние на сервер
#### После отправки
- Клиент рендерит себя за столом без карт
- Может смотреть текущую раздачу
- Ожидает начала следующей раздачи
**Сервер**
```
{ // Player3 wants to join the hand
hand: 1,
players: ['Player1', 'Player2', 'Player3'], // Player3 can add himself to the game ✅
startingStacks: [50, 100, 75.25], // `Player3` have total chips 75.25, not 100 as requested ✅
blindsOrStraddles: [1, 2, 0], // `Player3` can take UTG position ✅
antes: [0, 0, 0], // `Player3` was added into all player-related arrays ✅
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 ????',
'p1 cc',
'p2 cc',
'd db 2d7cJh',
],
_inactive: [0, 0, 1], // Player3 will play in the next hand ✅
_intents: [0, 0, 0], // Player3 can add himself to the game. Game is running, so new player MUST wait for next game ✅
}
```
### Серверная логика
Метод `Hand.merge()`:
- Видит что в `_intents` есть игроки, которые хотят играть
- Если игра уже началась (экшен лог не пустой), определяет кто в ней УЖЕ участвует, а остальных игроков помечает как неактивных, изменяя поле `_inactive`
- Убеждается, что все player-related arrays (`players`, `startingStacks`, `antes`, `_inactive`, `_intents`) изменены должным образом
`Player1` и `Player2` доигрывают текущую игру до конца.
При вызове `Hand.next()` на сервере:
- В игровом состоянии есть игроки, для которых `_inactive: 1`, и `_intents: 0`, если у игрока достаточно фишек для продолжения игры он участвует в следующей раздаче и получает карты
- Активный игрок, который участвует в раздаче, должен иметь итоговые значения `_inactive: 0`, `_intents: 0`
**Сервер начал новую игру**
```
{ // Player3 joined new hand
hand: 2, // new hand ✅
players: ['Player1', 'Player2', 'Player3'], // ✅ `Player3` is included in player-related array
startingStacks: [50, 100, 75.25], // ✅ `Player3` is included in player-related array
blindsOrStraddles: [0, 1, 2], // ✅ `Player3` is included in player-related array
antes: [0, 0, 0], // ✅ `Player3` is included in player-related array
seatCount: [6],
actions: [
'd dh p1 2h2d',
'd dh p2 3s3d',
'd dh p3 4c4d', // `Player3` got hole cards ✅
],
_inactive: [0, 0, 0], // `Player3` active ✅
_intents: [0, 0, 0], // `Player3` is ready to play ✅
}
```
**Player3 получил стейт**
```
{ // Player3 recieved new hand
author: 'Player3',
hand: 2,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [50, 100, 75.25],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 ????',
'd dh p3 4c4d, // `Player3` see his cards and can render UI
],
_inactive: [0, 0, 0], // `Player3` is active ✅
_intents: [0, 0, 0], // `Player3` is want to play ✅
}
```
UI отрисовывается по стейту.
## Пауза
Любой игрок может взять паузу в любой момент игры. За игроком сохраняется его место за столом, но при возврате в игру, возможны два сценария:
1. **Кейс А** Игрок пропускает все игры до момента, когда он сможет вернуться на позицию `BB`. В этом случае он _платит_ свой позиционный `BB`, _не платит_ `мертвые блайнды`.
- В этом случае игрок не получает позиционного преимущества, а просто пропускает оборот стола и продолжает игру с новой раздачи на позиции `BB`
- В игровом состоянии для поля `_intents` установлено значение `1`.
- `Мертвые блайнды` накапливаются, на случай если клиент изменит намерения и захочет вернуться в игру раньше
2. **Кейс B** Если игрок возвращается в игру до того, как `button` дойдет до его позиции большого блайнда, он обязан оплатить `мертвые блайнды`.
Логика расчета `dead blind` такая: нужно понять, сколько блайндов игрок пропустил _до момента возвращения в игру_, с максимальным размером `dead blind` = 1.5 х `BB`
С _каждой новой раздачей_, для каждого неактивного игрока, сумма `мертвых блайндов` которого еще не достигла максимума в `1.5 BB` нужно:
- За каждый пропущенный `SB` в раздаче добавлять к значению игрока в массиве `_deadBlinds` +=`0.5`(должен быть приведен к абсолютному значению фишек)
- За каждый пропущенный `BB` в раздаче добавлять к значению игрока в массиве `_deadBlinds` +=`1`(должен быть приведен к абсолютному значению фишек)
- Максимальное значение `_deadBlinds` для любого игрока === `1.5`(должен быть приведен к абсолютному значению фишек)
- `Мертвые блайнды` НЕ оплачиваются, если игрок покидает игру окончательно: `_inactive: 1` и `_intents: 3`
Рассмотрим все случаи.
### Кейс А
`Player2` решил взять паузу и продолжить игру, когда его очередь дойдет до позиции `BB`
**Клиент, начальное состояние**
```
{ // Player2 wants to pause playing during the hand going
hand: 3,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [50, 100, 75.25],
blindsOrStraddles: [0, 1, 2], // `Player2` have SB position ✅
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 3s3d, // `Player2` posted SB, and then got the hole cards ✅
'd dh p3 ????',
'p3 cc',
'p1 cc',
],
_inactive: [0, 0, 0],
_intents: [0, 0, 0], // initial state, all players are just playing ✅
}
```
`Player2` добавляет в массив `_intents` по своему индексу значение `1`. Это значит, что он будет пропускать все игры до его позиции в `BB`.
Однако `мертвые блайнды` для него все равно нужно рассчитывать, на случай, если игрок примет решение вернуться к игре досрочно.
**Клиент**
```
{ // Player2 paused the game
author: 'Player2',
hand: 3,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [50, 100, 75.25], // unchanged ✅
blindsOrStraddles: [0, 1, 2], // unchanged ✅
antes: [0, 0, 0], // unchanged ✅
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 3s3d
'd dh p3 ????',
'p3 cc',
'p1 cc',
],
_inactive: [0, 0, 0],
_intents: [0, 1, 0], // Player2 want to skip the game till next BB ✅
}
```
**Сервер**
_Nota bene: логика учета максимального времени паузы целиком серверная и нас особенно не интересует_
Метод `Hand.merge()`:
- Сервер видит что `Player2` хочет пропустить текущую и следующие игры (`_intents: 1`)
- В итоговом игровом состоянии игрок становится неактивным (`_inactive: 1`) и не участвует дальше в раздаче
- Игрок `Player2` находится на позиции `SB` и уже поставил блайнд, получив карты. `_deadBlinds` = `0`
```
{// Player2 paused before he's posted big blind
hand: 3,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is present, but not playing ✅
startingStacks: [50, 100, 75.25],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 3s3d
'd dh p3 ????',
'p3 cc',
'p1 cc',
'd db 5cAhQh` // `Player2` is paused, so he's not acting
],
_inactive: [0, 0, 0],
_intents: [0, 1, 0], // `Player2` paused and wait til next `BB`
_deadBlinds: [0, 0, 0] // `Player2` posted his `SB` in this hand
}
```
Игрок `Player2` встал после постановки блайндов, и получил карты в этой раздаче, следовательно, он может вернуться _в этой игре_. Доиграть эту игру он уже не сможет, но _сможет начать со следующей раздачи_ без начисления `_deadBlinds`
Логика метода `Hand.next()`:
**Расчет мертвых блайндов:**
- Проверяет игроков на паузе (`_inactive: 1`, `_intents: 1|2`)
- Игроки с `_deadBlinds === 1.5` достигли максимума и пропускаются
- За пропущенный `SB` добавляет +0.5 к `_deadBlinds` в абсолютных значениях фишек
- За пропущенный `BB` добавляет +1.0 к `_deadBlinds` в абсолютных значениях фишек
**Возврат в игру:**
- Игроки с `_intents: 1` на позиции `BB` возвращаются без оплаты долгов
- Устанавливается: `_deadBlinds: 0`, `_inactive: 0`, `_intents: 0`
- Игроки с недостатком фишек для оплаты долгов и блайндов:
- Получают флаги: `_inactive: 1`, `_intents: 3` (автоматический выход)
**Сервер** смерджил игровое состояние
```
{// Player2 paused before he's posted big blind
hand: 3,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [50, 100, 75.25],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 3s3d,
'd dh p3 ????',
'p3 cc',
'p1 cc',
'd db 5cAhQh` // `Player2` is paused, so he's not acting ✅
],
_inactive: [0, 1, 0], `Player2` is inactive and not acting in this hand ✅
_intents: [0, 1, 0], // `Player2` paused in this hand and waiting until next `BB` ✅
_deadBlinds: [0, 0, 0] // `Player2` already posted his `SB` in this hand
}
```
**Сервер**
Игрок `Player2` пропустил `Раздачу 3`, началась новая `Раздача 4`
```
{// Player2 paused and missing hand4 completely
hand: 4,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 100, 75.25], // `Player2` have chips to play ✅
blindsOrStraddles: [2, 0, 1], // `Player2` have UTG position now ✅
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
// no cards for `Player2`
'd dh p3 ????',
'p3 cc',
'p1 cc',
'd db 5cAhQh` // `Player2` is not playing in this hand at all ✅
],
_inactive: [0, 1, 0], `Player2` is inactive and not acting in this hand ✅
_intents: [0, 1, 0], // `Player2` paused in this hand and waiting until next `BB` ✅
_deadBlinds: [0, 0, 0] // `Player2` is skipping this hand completely
}
```
Несмотря на то, что игрок пропускает раздачи до своего следующего `BB` ему НЕ начисляются `мертвые блайнды`. Конкретно в этой ситуации игрок `Player2` находится на позиции `UTG` и не обязан ставить позиционный блайнд. Если он решит вернуться в игру досрочно - не оплачивает ничего и с новой раздачи принимает участие в игре. Если он дожидается своего `BB` - то `мертвые блайнды` обнуляются.
Игрок `Player2` пропустил `Раздачу 4`, началась новая `Раздача 5`, в которой он на позиции `BB`
```
{// Player2 paused till `BB`
hand: 5,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 100, 75.25], // `Player2` have chips to play ✅
blindsOrStraddles: [1, 2, 0], // `Player2` have BB position and can return without playing dead blinds ✅
antes: [0, 0, 0],
seatCount: [6],
actions: [],
_inactive: [0, 0, 0], `Player2` have `BB` position now ✅
_intents: [0, 0, 0], // `Player2` is playing ✅
_deadBlinds: [0, 0, 0] // `Player2` have NO dead blinds ✅
}
```
**Клиент**
```
{// Player2 paused till `BB`
author: `Player2`,
hand: 5,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 100, 75.25], // `Player2` have chips to play ✅
blindsOrStraddles: [1, 2, 0], // `Player2` have BB position now ✅
antes: [0, 0, 0],
seatCount: [6],
actions: [],
_inactive: [0, 0, 0], `Player2` have `BB` position now ✅
_intents: [0, 0, 0], // `Player2` is playing ✅
_deadBlinds: [0, 0, 0] // `Player2` have NO dead blinds ✅
}
```
Клиент рендерит UI в котором игрок вернулся в игру и ждет карт от дилера.
### Кейс B
Игрок хочет вернуться к игре досрочно. Например, когда игрок накопил максимальную сумму `мертвых блайндов` и хочет вернуться в игру до своего `BB`.
**Сервер**
```
{ // Player2 is paused
hand: 6,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 100, 75.25], // `Player2` have chips to play ✅
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p3 ????',
],
_inactive: [0, 1, 0], // `Player2` inactive ✅
_intents: [0, 2, 0], // `Player2` just paused the game, he doesn't want to wait until `BB`, just pause ✅
_deadBlinds: [0, 3, 0] // `Player2` have the max allowed `dead blind` value ($3 = 1.5 * $2 BB) ✅
}
```
**Клиент**
```
{ // Player2 wants to unpause the game from the next hand
hand: 6,
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 100, 75.25], // `Player2` have chips to play ✅
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p3 ????'],
_inactive: [0, 1, 0], // `Player2` CAN'T modify `_inactive` values ✅
_intents: [0, 0, 0], // `Player2` unpaused the game ✅
_deadBlinds: [0, 3, 0] // `Player2` can't modify `_deadBlinds` values ✅
}
```
**Сервер**
```
{ // Next hand started
hand: 7, // next hand ✅
players: ['Player1', 'Player2', 'Player3'], // `Player2` is sitting ✅
startingStacks: [50, 97, 75.25], // `Player2` had 100 chips, he payed $3 as dead blind ($100 - $3 = $97)
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 'JhJc'
'd dh p3 ????',
],
_inactive: [0, 0, 0], // `Player2` active ✅
_intents: [0, 0, 0], // `Player2` is playing ✅
_deadBlinds: [0, 0, 0] // `Player2` already paid his dead blinds ✅
}
```
# Выход из игры
Любой игрок может отказаться продолжать игру. Покинуть игру можно следующими способами:
1. Мгновенно(нажать `fold`)
2. Либо просто не совершая никаких действий в течении времени на ход, и тогда сервер через `Hand.auto()` сам сделает `fold/muck`
3. Либо отказаться от продолжения игры в следующей раздаче.
- При этом, в этой раздаче игрок уже не участвует
- Флаг состояния `_intents:3` обозначает намерение игрока уйти из игры окончательно
- Если у него есть `_deadBlinds`, то он их не оплачивает
**Клиент**
`Player1` решил покинуть игру, и нажал кнопку `ПОКИНУТЬ ИГРУ [x]` в интерфейсе. На сервер он отправит такое состояние:
```
{ // Player1 wants to exit the hand
author: 'Player1',
hand: 8,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [50, 100, 75.25],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
seatCount: [6],
actions: [
'd dh p1 ????',
'd dh p2 3s3d
'd dh p3 ????',
'p3 cc',
'p1 cc',
'p2 cc',
'd db 5hAdQc'
],
_inactive: [0, 0, 0], // `Player1` CAN'T modify `_inactive` values ✅
_intents: [3, 0, 0], // `Player1` wants to leave the game ✅
}
```
Интерфейс перерисовывается, клиент отписывается от обновлений этой игры и попадает в лобби.
**Сервер**
При вызове `Hand.merge()`:
- Видит что игрок `Player1` показал намерение покинуть игру
- Меняет флаг игрока на `_inactive: 1`
- Больше игровое состояние не бродкастится этому игроку
Логика `Hand.next()`
- Любые игроки, у которых `_inactive: 1` и `_intents:3` удаляются из всех массивов, связанных с игроками
- Значения `_deadBlinds`, `_inactive`, `_intents` игнорируются
```
{ // Player1 no more playing
hand: 9,
players: ['Player2', 'Player3'], // No `Player1` here ✅
startingStacks: [100, 75.25], // No `Player1` here ✅
blindsOrStraddles: [1, 2], // No `Player1` here ✅
antes: [0, 0], // No `Player1` here ✅
seatCount: [6],
actions: [
'd dh p1 '2s3d',
'd dh p2 '3s3d',
],
_inactive: [0, 0], // No `Player1` here ✅
_intents: [0, 0], // No `Player1` here ✅
}
```
Игрок `Player1` больше обновлений игры не получит.