balatro-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/bin/start.js +8 -0
- package/package.json +29 -0
- package/src/blinds.js +38 -0
- package/src/cli.js +117 -0
- package/src/deck.js +133 -0
- package/src/handRank.js +135 -0
- package/src/index.js +217 -0
- package/src/jokers.js +98 -0
- package/src/logStyle.js +92 -0
- package/src/round.js +70 -0
- package/src/scoring.js +64 -0
- package/src/shop.js +105 -0
- package/src/tests/deck.test.js +82 -0
- package/src/tests/handRank.test.js +73 -0
- package/src/tests/scoring_jokers.test.js +85 -0
- package/src/tests/shop_cli_round.test.js +139 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Poker Roguelike CLI
|
|
2
|
+
|
|
3
|
+
Command-line poker roguelike inspired by Balatro (小丑牌). All output is styled as server logs so it looks like monitoring output.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
|
|
9
|
+
## Install & Run
|
|
10
|
+
|
|
11
|
+
**全局安装后一键启动:**
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g balatro-cli
|
|
15
|
+
balatro-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**本地开发运行:**
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node bin/start.js
|
|
22
|
+
# or
|
|
23
|
+
npm start
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How to play
|
|
27
|
+
|
|
28
|
+
- **Goal**: Each round (blind) has a target score. Play 5-card poker hands to reach it before you run out of hands (4) and discards (4).
|
|
29
|
+
- **Flow**: 8 Antes; each Ante has Small Blind, Big Blind, and Boss Blind. Beat the target in a blind to get money and enter the shop. Win Ante 8 Boss to win.
|
|
30
|
+
- **Shop**: Buy Jokers (passive effects), Planet cards (upgrade a hand type), Vouchers (+1 joker slot, etc.), or refresh. Jokers apply in order: +chips left, +mult middle, ×mult right.
|
|
31
|
+
- **Input**:
|
|
32
|
+
- During a round: type 5 card positions (1–8), e.g. `1 3 5 6 8` to play those cards, or `discard 2 5` to discard cards 2 and 5.
|
|
33
|
+
- Small/Big blind: `f` to fight, `s` to skip (get a tag, no money/shop).
|
|
34
|
+
- Shop: `1`/`2` buy joker, `p` planet, `v` voucher, `r` refresh, `q` leave.
|
|
35
|
+
- **关闭提示**: 启动时设置环境变量 `POKER_HIDE_HINTS=1` 可关闭操作提示;或在出牌阶段输入 `/hint`(或 `hint`、`/提示`)随时切换提示开关。
|
|
36
|
+
|
|
37
|
+
## Test
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm test
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
测试覆盖:牌堆与手牌、牌型判定、计分与小丑牌效果(加筹码/加倍率/乘倍率、对子加成、未弃牌、每张小丑)、星球牌等级、商店(购买/出售小丑、星球牌、优惠券、刷新)、盲注配置、回合出牌与弃牌、CLI 解析。塔罗牌/幻灵牌当前未实现,故无对应单测。
|
|
44
|
+
|
|
45
|
+
## Project layout
|
|
46
|
+
|
|
47
|
+
- `src/logStyle.js` – log-style formatter
|
|
48
|
+
- `src/deck.js` – deck, deal, play, discard
|
|
49
|
+
- `src/handRank.js` – 5-card hand detection
|
|
50
|
+
- `src/scoring.js` – score = chips × mult, joker order
|
|
51
|
+
- `src/jokers.js` – joker definitions and effects
|
|
52
|
+
- `src/blinds.js` – ante/blind targets and debuffs
|
|
53
|
+
- `src/round.js` – single blind round loop
|
|
54
|
+
- `src/shop.js` – shop and purchases
|
|
55
|
+
- `src/cli.js` – readline input
|
|
56
|
+
- `src/index.js` – main game loop
|
package/bin/start.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "balatro-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI poker roguelike (Balatro-style) with server-log aesthetic",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"balatro-cli": "bin/start.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/start.js",
|
|
15
|
+
"test": "node --test src/tests/deck.test.js src/tests/handRank.test.js src/tests/scoring_jokers.test.js src/tests/shop_cli_round.test.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"balatro",
|
|
19
|
+
"poker",
|
|
20
|
+
"roguelike",
|
|
21
|
+
"cli",
|
|
22
|
+
"game"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src"
|
|
28
|
+
]
|
|
29
|
+
}
|
package/src/blinds.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ante / Blind config: 8 Antes, each with small / big / boss. Target scores and optional boss debuffs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_HANDS = 4;
|
|
6
|
+
const DEFAULT_DISCARDS = 4;
|
|
7
|
+
|
|
8
|
+
/** Target score by ante index (0..7). Scale up for big/boss. */
|
|
9
|
+
function baseTarget(anteIndex) {
|
|
10
|
+
const base = [300, 600, 1200, 2400, 4800, 9600, 19200, 40000];
|
|
11
|
+
return base[Math.min(anteIndex, base.length - 1)] ?? 40000;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getBlindConfig(anteIndex, blindType) {
|
|
15
|
+
const base = baseTarget(anteIndex);
|
|
16
|
+
if (blindType === 'small') return { target: base, hands: DEFAULT_HANDS, discards: DEFAULT_DISCARDS, debuff: null };
|
|
17
|
+
if (blindType === 'big') return { target: Math.floor(base * 1.5), hands: DEFAULT_HANDS, discards: DEFAULT_DISCARDS, debuff: null };
|
|
18
|
+
if (blindType === 'boss') {
|
|
19
|
+
const target = Math.floor(base * 2);
|
|
20
|
+
const debuffs = ['none', 'no_discard', 'target_2x', 'must_play_5'];
|
|
21
|
+
const debuff = debuffs[anteIndex % debuffs.length];
|
|
22
|
+
return {
|
|
23
|
+
target: debuff === 'target_2x' ? target * 2 : target,
|
|
24
|
+
hands: DEFAULT_HANDS,
|
|
25
|
+
discards: debuff === 'no_discard' ? 0 : DEFAULT_DISCARDS,
|
|
26
|
+
debuff,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { target: base, hands: DEFAULT_HANDS, discards: DEFAULT_DISCARDS, debuff: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const BLIND_ORDER = ['small', 'big', 'boss'];
|
|
33
|
+
|
|
34
|
+
export function getBlindLabel(blindType) {
|
|
35
|
+
return { small: 'Small Blind', big: 'Big Blind', boss: 'Boss Blind' }[blindType] || blindType;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const ANTES_COUNT = 8;
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: readline for user input. Prompt disguised as "Gateway >". Parse play indices or discard command.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
|
|
7
|
+
const PROMPT = 'Gateway > ';
|
|
8
|
+
|
|
9
|
+
export function createInterface() {
|
|
10
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ask(rl, question) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
rl.question(question || PROMPT, (answer) => resolve((answer || '').trim()));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse "1 3 5 6 8" / "1,3,5,6,8" / "13568" (无空格) into 0-based indices (1-8 -> 0-7).
|
|
21
|
+
*/
|
|
22
|
+
export function parsePlayIndices(input) {
|
|
23
|
+
const s = (input || '').trim().replace(/,/g, ' ');
|
|
24
|
+
if (/^[1-8]{5}$/.test(s)) {
|
|
25
|
+
const indices = s.split('').map((d) => parseInt(d, 10) - 1);
|
|
26
|
+
return indices;
|
|
27
|
+
}
|
|
28
|
+
const parts = s.split(/\s+/).filter(Boolean);
|
|
29
|
+
const indices = parts.map((p) => parseInt(p, 10) - 1).filter((n) => n >= 0 && n < 8);
|
|
30
|
+
return indices.length === 5 ? indices : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse "discard 2 5" / "d 2 5" / "q 2 5" / "q25" (无空格) -> indices.
|
|
35
|
+
* 支持 q25 或 q 2 5
|
|
36
|
+
*/
|
|
37
|
+
export function parseDiscardIndices(input) {
|
|
38
|
+
const s = (input || '').trim();
|
|
39
|
+
const lower = s.toLowerCase();
|
|
40
|
+
const m = lower.match(/^(?:discard|d|弃牌|q)(?:\s*(.+))?$/);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
let rest = (m[1] || '').trim();
|
|
43
|
+
if (!rest) return null;
|
|
44
|
+
let parts;
|
|
45
|
+
if (/^[1-8]+$/.test(rest)) {
|
|
46
|
+
parts = rest.split('');
|
|
47
|
+
} else {
|
|
48
|
+
parts = rest.split(/\s+/).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
const indices = parts.map((p) => parseInt(p, 10) - 1).filter((n) => n >= 0 && n < 8);
|
|
51
|
+
return indices.length > 0 ? indices : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isDiscardCommand(input) {
|
|
55
|
+
const s = (input || '').trim().toLowerCase();
|
|
56
|
+
return /^(?:discard|d|弃牌|q)/.test(s);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ask for play: 5 indices (1-8) or discard command. Returns { action: 'play'|'discard'|'toggleHint', indices }.
|
|
61
|
+
*/
|
|
62
|
+
export async function askPlayOrDiscard(rl, log) {
|
|
63
|
+
const raw = await ask(rl, '');
|
|
64
|
+
const t = (raw || '').trim().toLowerCase();
|
|
65
|
+
if (t === '/hint' || t === 'hint' || t === '/提示') return { action: 'toggleHint' };
|
|
66
|
+
if (isDiscardCommand(raw)) {
|
|
67
|
+
const indices = parseDiscardIndices(raw);
|
|
68
|
+
return { action: 'discard', indices: indices || [] };
|
|
69
|
+
}
|
|
70
|
+
const indices = parsePlayIndices(raw);
|
|
71
|
+
return { action: 'play', indices: indices || [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ask shop choice: 1/2=买小丑 p=星球 v=优惠券 r=刷新 s1/s2=卖掉小丑1/2 q=离开
|
|
76
|
+
*/
|
|
77
|
+
export async function askShopChoice(rl, shopState, runState) {
|
|
78
|
+
const options = [];
|
|
79
|
+
shopState.jokers.forEach((j, i) => options.push(`${i + 1}: ${j.nameZh || j.name} (¥${j.price})`));
|
|
80
|
+
if (shopState.planet && shopState.planet[0]) options.push(`p: 星球牌 (¥${shopState.planet[0].price})`);
|
|
81
|
+
if (shopState.voucher) options.push(`v: 优惠券 (¥${shopState.voucher.price})`);
|
|
82
|
+
options.push(`r: 刷新 (¥${shopState.refreshCost})`);
|
|
83
|
+
const myJokers = runState.jokers || [];
|
|
84
|
+
myJokers.forEach((j, i) => options.push(`s${i + 1}: 出售小丑${i + 1}(${j.nameZh || j.name})`));
|
|
85
|
+
options.push('q: 离开');
|
|
86
|
+
const line = options.join(' | ');
|
|
87
|
+
const raw = await ask(rl, line + '\n' + PROMPT);
|
|
88
|
+
const c = (raw || '').trim().toLowerCase();
|
|
89
|
+
if (c === 'q') return { action: 'leave' };
|
|
90
|
+
if (c === 'p') return { action: 'planet' };
|
|
91
|
+
if (c === 'v') return { action: 'voucher' };
|
|
92
|
+
if (c === 'r') return { action: 'refresh' };
|
|
93
|
+
const sellMatch = c.match(/^s(\d+)$/);
|
|
94
|
+
if (sellMatch) {
|
|
95
|
+
const idx = parseInt(sellMatch[1], 10) - 1;
|
|
96
|
+
if (idx >= 0 && idx < (runState.jokers || []).length) return { action: 'sellJoker', index: idx };
|
|
97
|
+
}
|
|
98
|
+
const j = parseInt(c, 10);
|
|
99
|
+
if (j >= 1 && j <= (shopState.jokers || []).length) return { action: 'joker', index: j - 1 };
|
|
100
|
+
return { action: 'invalid' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function askSkipOrFight(rl) {
|
|
104
|
+
const raw = await ask(rl, '可选: f 迎战 s 跳过(跳过不得钱不进商店,获标签)\n' + PROMPT);
|
|
105
|
+
const c = (raw || '').trim().toLowerCase();
|
|
106
|
+
return c === 's' || c === '跳过' ? 'skip' : 'fight';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function askMainMenu(rl) {
|
|
110
|
+
const raw = await ask(rl, '可选: 1 新对局 2 继续 q 退出\n' + PROMPT);
|
|
111
|
+
const c = (raw || '').trim().toLowerCase();
|
|
112
|
+
if (c === 'q' || c === '退出') return 'exit';
|
|
113
|
+
if (c === '2' || c === '继续') return 'continue';
|
|
114
|
+
return 'new';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { PROMPT };
|
package/src/deck.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck: 52 cards, shuffle, deal 8, draw after play/discard. Reshuffle discard when empty.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SUITS = ['hearts', 'diamonds', 'clubs', 'spades'];
|
|
6
|
+
const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
|
|
7
|
+
|
|
8
|
+
/** 花色符号:红桃♥ 方片♦ 梅花♣ 黑桃♠ */
|
|
9
|
+
export const SUIT_SYMBOLS = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
|
|
10
|
+
|
|
11
|
+
/** ANSI 颜色:红桃红 方片粉 黑桃默认黑 梅花青 */
|
|
12
|
+
const SUIT_COLORS = {
|
|
13
|
+
hearts: '\x1b[31m', // 红
|
|
14
|
+
diamonds: '\x1b[35m', // 品红/粉
|
|
15
|
+
clubs: '\x1b[36m', // 青
|
|
16
|
+
spades: '\x1b[0m', // 默认(黑)
|
|
17
|
+
};
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
|
|
20
|
+
/** 单张牌显示:点数+花色符号(带颜色),如 A♠ 10♥ */
|
|
21
|
+
export function formatCard(c, useColor = true) {
|
|
22
|
+
if (!c) return '???';
|
|
23
|
+
const symbol = SUIT_SYMBOLS[c.suit] ?? c.suit[0];
|
|
24
|
+
if (!useColor || !SUIT_COLORS[c.suit]) return `${c.rank}${symbol}`;
|
|
25
|
+
return `${c.rank}${SUIT_COLORS[c.suit]}${symbol}${RESET}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 去掉 ANSI 后的可见长度 */
|
|
29
|
+
function visibleLength(s) {
|
|
30
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 固定可见宽度的牌面,序号与手牌对齐用 */
|
|
34
|
+
export function formatCardPadded(c, visibleWidth = 5) {
|
|
35
|
+
const s = formatCard(c);
|
|
36
|
+
const n = visibleLength(s);
|
|
37
|
+
const pad = n < visibleWidth ? ' '.repeat(visibleWidth - n) : '';
|
|
38
|
+
return s + pad;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 点数顺序:2~10, J, Q, K, A */
|
|
42
|
+
const RANK_ORDER = { '2': 0, '3': 1, '4': 2, '5': 3, '6': 4, '7': 5, '8': 6, '9': 7, '10': 8, 'J': 9, 'Q': 10, 'K': 11, 'A': 12 };
|
|
43
|
+
/** 花色顺序:梅花 方片 红桃 黑桃 */
|
|
44
|
+
const SUIT_ORDER = { clubs: 0, diamonds: 1, hearts: 2, spades: 3 };
|
|
45
|
+
|
|
46
|
+
/** 手牌排序:先按点数再按花色 */
|
|
47
|
+
export function sortHand(hand) {
|
|
48
|
+
hand.sort((a, b) => {
|
|
49
|
+
const r = (RANK_ORDER[a.rank] ?? 0) - (RANK_ORDER[b.rank] ?? 0);
|
|
50
|
+
if (r !== 0) return r;
|
|
51
|
+
return (SUIT_ORDER[a.suit] ?? 0) - (SUIT_ORDER[b.suit] ?? 0);
|
|
52
|
+
});
|
|
53
|
+
return hand;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createDeck() {
|
|
57
|
+
const cards = [];
|
|
58
|
+
for (const suit of SUITS) {
|
|
59
|
+
for (const rank of RANKS) {
|
|
60
|
+
cards.push({ suit, rank });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return cards;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function shuffle(cards) {
|
|
67
|
+
const out = [...cards];
|
|
68
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
69
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
70
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createShuffledDeck() {
|
|
76
|
+
return shuffle(createDeck());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Game deck state: draw pile and discard pile.
|
|
81
|
+
* drawPile[0] is top of deck.
|
|
82
|
+
*/
|
|
83
|
+
export function createGameDeck() {
|
|
84
|
+
return {
|
|
85
|
+
drawPile: createShuffledDeck(),
|
|
86
|
+
discardPile: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function draw(state, n) {
|
|
91
|
+
const drawn = [];
|
|
92
|
+
for (let i = 0; i < n; i++) {
|
|
93
|
+
if (state.drawPile.length === 0) {
|
|
94
|
+
state.drawPile = shuffle(state.discardPile);
|
|
95
|
+
state.discardPile = [];
|
|
96
|
+
}
|
|
97
|
+
if (state.drawPile.length === 0) break;
|
|
98
|
+
drawn.push(state.drawPile.shift());
|
|
99
|
+
}
|
|
100
|
+
return drawn;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Deal 8 cards into hand; remove from draw pile. */
|
|
104
|
+
export function dealHand(state) {
|
|
105
|
+
return draw(state, 8);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Play cards: move them from hand to discard, then draw same number to refill hand. */
|
|
109
|
+
export function playCards(state, hand, indicesToPlay) {
|
|
110
|
+
const played = indicesToPlay.map((i) => hand[i]).filter(Boolean);
|
|
111
|
+
const remaining = hand.filter((_, i) => !indicesToPlay.includes(i));
|
|
112
|
+
state.discardPile.push(...played);
|
|
113
|
+
const newCards = draw(state, played.length);
|
|
114
|
+
return [...remaining, ...newCards];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Discard by indices: move to discard pile, draw same number. */
|
|
118
|
+
export function discardCards(state, hand, indicesToDiscard) {
|
|
119
|
+
const discarded = indicesToDiscard.map((i) => hand[i]).filter(Boolean);
|
|
120
|
+
const remaining = hand.filter((_, i) => !indicesToDiscard.includes(i));
|
|
121
|
+
state.discardPile.push(...discarded);
|
|
122
|
+
const newCards = draw(state, discarded.length);
|
|
123
|
+
return [...remaining, ...newCards];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function cardChipValue(rank) {
|
|
127
|
+
if (rank === 'A') return 11;
|
|
128
|
+
if (['K', 'Q', 'J'].includes(rank)) return 10;
|
|
129
|
+
const n = parseInt(rank, 10);
|
|
130
|
+
return Number.isNaN(n) ? 0 : n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { SUITS, RANKS };
|
package/src/handRank.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect 5-card poker hand type (high card to royal flush). Returns hand id and level for scoring.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { RANKS } from './deck.js';
|
|
6
|
+
|
|
7
|
+
const RANK_ORDER = [...RANKS];
|
|
8
|
+
const RANK_INDEX = Object.fromEntries(RANK_ORDER.map((r, i) => [r, i]));
|
|
9
|
+
|
|
10
|
+
export const HAND_IDS = {
|
|
11
|
+
HIGH_CARD: 'HighCard',
|
|
12
|
+
PAIR: 'Pair',
|
|
13
|
+
TWO_PAIR: 'TwoPair',
|
|
14
|
+
THREE_OF_A_KIND: 'ThreeOfAKind',
|
|
15
|
+
STRAIGHT: 'Straight',
|
|
16
|
+
FLUSH: 'Flush',
|
|
17
|
+
FULL_HOUSE: 'FullHouse',
|
|
18
|
+
FOUR_OF_A_KIND: 'FourOfAKind',
|
|
19
|
+
STRAIGHT_FLUSH: 'StraightFlush',
|
|
20
|
+
ROYAL_FLUSH: 'RoyalFlush',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function rankIndex(rank) {
|
|
24
|
+
return RANK_INDEX[rank] ?? -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function countRanks(cards) {
|
|
28
|
+
const counts = {};
|
|
29
|
+
for (const c of cards) {
|
|
30
|
+
counts[c.rank] = (counts[c.rank] || 0) + 1;
|
|
31
|
+
}
|
|
32
|
+
return counts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countSuits(cards) {
|
|
36
|
+
const counts = {};
|
|
37
|
+
for (const c of cards) {
|
|
38
|
+
counts[c.suit] = (counts[c.suit] || 0) + 1;
|
|
39
|
+
}
|
|
40
|
+
return counts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isFlush(cards) {
|
|
44
|
+
const suits = countSuits(cards);
|
|
45
|
+
return Math.max(...Object.values(suits)) >= 5;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sortedRankIndices(cards) {
|
|
49
|
+
return cards.map((c) => rankIndex(c.rank)).sort((a, b) => b - a);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Check if 5 cards form a straight (A high or A low). */
|
|
53
|
+
function isStraight(cards) {
|
|
54
|
+
if (cards.length < 5) return false;
|
|
55
|
+
const indices = [...new Set(cards.map((c) => rankIndex(c.rank)))].sort((a, b) => b - a);
|
|
56
|
+
if (indices.length < 5) return false;
|
|
57
|
+
const hasAce = indices.includes(12);
|
|
58
|
+
for (let start = 0; start <= indices.length - 5; start++) {
|
|
59
|
+
const slice = indices.slice(start, start + 5);
|
|
60
|
+
let ok = true;
|
|
61
|
+
for (let i = 1; i < slice.length; i++) {
|
|
62
|
+
if (slice[i - 1] - slice[i] !== 1) {
|
|
63
|
+
ok = false;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (ok) return true;
|
|
68
|
+
}
|
|
69
|
+
if (hasAce) {
|
|
70
|
+
const wheel = [12, 3, 2, 1, 0];
|
|
71
|
+
const have = new Set(indices);
|
|
72
|
+
if (wheel.every((i) => have.has(i))) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Must receive exactly 5 cards. Returns { handId, level, scoringCards } for chips. */
|
|
78
|
+
export function evaluateHand(cards) {
|
|
79
|
+
if (!cards || cards.length !== 5) {
|
|
80
|
+
return { handId: HAND_IDS.HIGH_CARD, level: 1, scoringCards: cards?.slice(0, 1) || [] };
|
|
81
|
+
}
|
|
82
|
+
const rankCounts = countRanks(cards);
|
|
83
|
+
const values = Object.values(rankCounts);
|
|
84
|
+
const maxSame = Math.max(...values);
|
|
85
|
+
const suits = countSuits(cards);
|
|
86
|
+
const flush = Math.max(...Object.values(suits)) >= 5;
|
|
87
|
+
const straight = isStraight(cards);
|
|
88
|
+
|
|
89
|
+
const sorted = [...cards].sort((a, b) => rankIndex(b.rank) - rankIndex(a.rank));
|
|
90
|
+
const highRank = sorted[0]?.rank;
|
|
91
|
+
|
|
92
|
+
if (flush && straight) {
|
|
93
|
+
const isRoyal = sorted.every((c) => ['A', 'K', 'Q', 'J', '10'].includes(c.rank));
|
|
94
|
+
return {
|
|
95
|
+
handId: isRoyal ? HAND_IDS.ROYAL_FLUSH : HAND_IDS.STRAIGHT_FLUSH,
|
|
96
|
+
level: 1,
|
|
97
|
+
scoringCards: [...sorted],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (maxSame === 4) {
|
|
101
|
+
const quadRank = Object.entries(rankCounts).find(([, v]) => v === 4)[0];
|
|
102
|
+
const quad = cards.filter((c) => c.rank === quadRank);
|
|
103
|
+
const kicker = cards.filter((c) => c.rank !== quadRank).sort((a, b) => rankIndex(b.rank) - rankIndex(a.rank))[0];
|
|
104
|
+
return { handId: HAND_IDS.FOUR_OF_A_KIND, level: 1, scoringCards: [...quad, kicker].filter(Boolean) };
|
|
105
|
+
}
|
|
106
|
+
if (maxSame === 3 && values.filter((v) => v >= 2).length >= 2) {
|
|
107
|
+
return { handId: HAND_IDS.FULL_HOUSE, level: 1, scoringCards: [...sorted] };
|
|
108
|
+
}
|
|
109
|
+
if (flush) {
|
|
110
|
+
return { handId: HAND_IDS.FLUSH, level: 1, scoringCards: [...sorted] };
|
|
111
|
+
}
|
|
112
|
+
if (straight) {
|
|
113
|
+
return { handId: HAND_IDS.STRAIGHT, level: 1, scoringCards: [...sorted] };
|
|
114
|
+
}
|
|
115
|
+
if (maxSame === 3) {
|
|
116
|
+
const tripRank = Object.entries(rankCounts).find(([, v]) => v === 3)[0];
|
|
117
|
+
const trip = cards.filter((c) => c.rank === tripRank);
|
|
118
|
+
const rest = cards.filter((c) => c.rank !== tripRank).sort((a, b) => rankIndex(b.rank) - rankIndex(a.rank)).slice(0, 2);
|
|
119
|
+
return { handId: HAND_IDS.THREE_OF_A_KIND, level: 1, scoringCards: [...trip, ...rest] };
|
|
120
|
+
}
|
|
121
|
+
const pairs = Object.entries(rankCounts).filter(([, v]) => v >= 2);
|
|
122
|
+
if (pairs.length >= 2) {
|
|
123
|
+
pairs.sort((a, b) => rankIndex(b[0]) - rankIndex(a[0]));
|
|
124
|
+
const twoPairCards = cards.filter((c) => c.rank === pairs[0][0] || c.rank === pairs[1][0]);
|
|
125
|
+
const kicker = cards.filter((c) => !twoPairCards.includes(c)).sort((a, b) => rankIndex(b.rank) - rankIndex(a.rank))[0];
|
|
126
|
+
return { handId: HAND_IDS.TWO_PAIR, level: 1, scoringCards: [...twoPairCards, kicker].filter(Boolean) };
|
|
127
|
+
}
|
|
128
|
+
if (pairs.length === 1) {
|
|
129
|
+
const pairRank = pairs[0][0];
|
|
130
|
+
const pair = cards.filter((c) => c.rank === pairRank);
|
|
131
|
+
const kickers = cards.filter((c) => c.rank !== pairRank).sort((a, b) => rankIndex(b.rank) - rankIndex(a.rank)).slice(0, 3);
|
|
132
|
+
return { handId: HAND_IDS.PAIR, level: 1, scoringCards: [...pair, ...kickers] };
|
|
133
|
+
}
|
|
134
|
+
return { handId: HAND_IDS.HIGH_CARD, level: 1, scoringCards: [sorted[0]] };
|
|
135
|
+
}
|