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/src/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main loop: Ante -> Blind (small/big/boss) -> Round -> Shop. Log-style output throughout.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import log from './logStyle.js';
|
|
6
|
+
import * as deck from './deck.js';
|
|
7
|
+
import { formatCard, formatCardPadded, sortHand } from './deck.js';
|
|
8
|
+
import { getBlindConfig, getBlindLabel, BLIND_ORDER, ANTES_COUNT } from './blinds.js';
|
|
9
|
+
import { createRoundState, playHand, doDiscard } from './round.js';
|
|
10
|
+
import * as shop from './shop.js';
|
|
11
|
+
import * as cli from './cli.js';
|
|
12
|
+
|
|
13
|
+
const STARTING_MONEY = 4;
|
|
14
|
+
|
|
15
|
+
const ERROR_MSG = {
|
|
16
|
+
no_hands_left: '出牌次数已用完',
|
|
17
|
+
no_discards_left: '弃牌次数已用完',
|
|
18
|
+
must_play_5: '本盲注必须出满 5 张牌',
|
|
19
|
+
invalid_indices: '无效的牌序号',
|
|
20
|
+
need_5_cards: '请选择 5 张牌',
|
|
21
|
+
hand_type_already_played: '本轮已出过该牌型',
|
|
22
|
+
choose_cards: '请指定要弃的牌',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const HAND_ZH = {
|
|
26
|
+
HighCard: '高牌',
|
|
27
|
+
Pair: '对子',
|
|
28
|
+
TwoPair: '两对',
|
|
29
|
+
ThreeOfAKind: '三条',
|
|
30
|
+
Straight: '顺子',
|
|
31
|
+
Flush: '同花',
|
|
32
|
+
FullHouse: '葫芦',
|
|
33
|
+
FourOfAKind: '四条',
|
|
34
|
+
StraightFlush: '同花顺',
|
|
35
|
+
RoyalFlush: '皇家同花顺',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const BLIND_ZH = { small: '小盲', big: '大盲', boss: '首领' };
|
|
39
|
+
|
|
40
|
+
function formatHand(hand) {
|
|
41
|
+
return hand.map((c, i) => `${i + 1}:${formatCard(c)}`).join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runGame() {
|
|
45
|
+
const rl = cli.createInterface();
|
|
46
|
+
log.info('引导', '服务就绪');
|
|
47
|
+
log.debug('引导', '选项: 新对局 / 继续 / 退出');
|
|
48
|
+
const menu = await cli.askMainMenu(rl);
|
|
49
|
+
if (menu === 'exit') {
|
|
50
|
+
rl.close();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let runState = {
|
|
55
|
+
money: STARTING_MONEY,
|
|
56
|
+
jokers: [],
|
|
57
|
+
handLevels: {},
|
|
58
|
+
deckState: deck.createGameDeck(),
|
|
59
|
+
anteIndex: 0,
|
|
60
|
+
blindIndex: 0,
|
|
61
|
+
voucherBoughtThisAnte: false,
|
|
62
|
+
showHints: process.env.POKER_HIDE_HINTS !== '1',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
log.info('Gateway', '对局开始', { 底注: 1, 盲注: BLIND_ZH[BLIND_ORDER[0]] || BLIND_ORDER[0] });
|
|
66
|
+
|
|
67
|
+
while (runState.anteIndex < ANTES_COUNT) {
|
|
68
|
+
const blindType = BLIND_ORDER[runState.blindIndex];
|
|
69
|
+
const blindConfig = getBlindConfig(runState.anteIndex, blindType);
|
|
70
|
+
runState.bossDebuff = blindConfig.debuff === 'one_hand_type' ? 'one_hand_type' : null;
|
|
71
|
+
|
|
72
|
+
log.warn('调度', `盲注=${BLIND_ZH[blindType] || blindType} 底注=${runState.anteIndex + 1} 目标分=${blindConfig.target}`, {
|
|
73
|
+
出牌次数: blindConfig.hands,
|
|
74
|
+
弃牌次数: blindConfig.discards,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (blindType !== 'boss') {
|
|
78
|
+
const skipOrFight = await cli.askSkipOrFight(rl);
|
|
79
|
+
if (skipOrFight === 'skip') {
|
|
80
|
+
log.info('Gateway', '已跳过盲注,获得标签');
|
|
81
|
+
runState.blindIndex++;
|
|
82
|
+
if (runState.blindIndex >= BLIND_ORDER.length) {
|
|
83
|
+
runState.blindIndex = 0;
|
|
84
|
+
runState.anteIndex++;
|
|
85
|
+
runState.voucherBoughtThisAnte = false;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const roundState = createRoundState(runState.deckState, blindConfig);
|
|
92
|
+
let cleared = false;
|
|
93
|
+
await log.animateDots('发牌中', 900, 180);
|
|
94
|
+
|
|
95
|
+
while (roundState.handsLeft > 0 && !cleared) {
|
|
96
|
+
sortHand(roundState.hand);
|
|
97
|
+
const slotWidth = 5;
|
|
98
|
+
const posLine = [1, 2, 3, 4, 5, 6, 7, 8].map((n) => String(n).padEnd(slotWidth)).join('');
|
|
99
|
+
const cardLine = roundState.hand.map((c) => formatCardPadded(c, slotWidth)).join('');
|
|
100
|
+
log.info('牌务', '序号 ' + posLine);
|
|
101
|
+
log.info('牌务', '手牌 ' + cardLine, { 出牌次数: roundState.handsLeft, 弃牌次数: roundState.discardsLeft });
|
|
102
|
+
log.info('计分', '目标分', { 目标: roundState.target });
|
|
103
|
+
if (runState.showHints) log.info('提示', '出牌: 5个序号 如 13568 或 1 3 5 6 8 | 弃牌: q+序号 如 q25 或 q 2 5 | /hint 关提示');
|
|
104
|
+
|
|
105
|
+
const choice = await cli.askPlayOrDiscard(rl, log);
|
|
106
|
+
if (choice.action === 'toggleHint') {
|
|
107
|
+
runState.showHints = !runState.showHints;
|
|
108
|
+
log.info('提示', runState.showHints ? '已开启提示' : '已关闭提示');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (choice.action === 'discard') {
|
|
112
|
+
if (roundState.discardsLeft <= 0) {
|
|
113
|
+
log.warn('计分', '弃牌次数已用完');
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (choice.indices.length === 0) {
|
|
117
|
+
if (runState.showHints) log.warn('提示', '请输入要弃的牌序号,例如 q 2 5 或 弃牌 1 3');
|
|
118
|
+
else log.warn('计分', '弃牌需带序号,如 q 2 5');
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const result = doDiscard(roundState, choice.indices);
|
|
122
|
+
if (result.error) {
|
|
123
|
+
log.warn('计分', ERROR_MSG[result.error] || result.error);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
log.info('牌务', '已弃牌', { 剩余弃牌: roundState.discardsLeft });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (choice.action === 'play') {
|
|
131
|
+
if (choice.indices.length !== 5) {
|
|
132
|
+
log.warn('计分', '请输入 5 张牌序号(1-8)', { 示例: '1 3 5 6 8' });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const result = playHand(roundState, runState, choice.indices);
|
|
136
|
+
if (result.error) {
|
|
137
|
+
log.warn('计分', ERROR_MSG[result.error] || result.error);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const handName = HAND_ZH[result.handId] || result.handId;
|
|
141
|
+
const chips = result.result?.chips ?? 0;
|
|
142
|
+
const mult = result.result?.mult ?? 0;
|
|
143
|
+
await log.animateScoreResult(handName, chips, mult, result.score, result.target);
|
|
144
|
+
if (result.cleared) {
|
|
145
|
+
await log.sleep(180);
|
|
146
|
+
cleared = true;
|
|
147
|
+
const moneyEarned = blindType === 'boss' ? 8 : blindType === 'big' ? 5 : 3;
|
|
148
|
+
runState.money = (runState.money || 0) + moneyEarned;
|
|
149
|
+
log.info('结算', '结算完成', { 状态: '成功', 获得: moneyEarned, 余额: runState.money });
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!cleared) {
|
|
156
|
+
log.error('Gateway', '盲注未通过', { 底注: runState.anteIndex + 1, 盲注: BLIND_ZH[blindType] || blindType });
|
|
157
|
+
rl.close();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (runState.anteIndex === ANTES_COUNT - 1 && blindType === 'boss') {
|
|
162
|
+
log.info('引导', '对局完成,胜利。');
|
|
163
|
+
rl.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
runState.blindIndex++;
|
|
168
|
+
if (runState.blindIndex >= BLIND_ORDER.length) {
|
|
169
|
+
runState.blindIndex = 0;
|
|
170
|
+
runState.anteIndex++;
|
|
171
|
+
runState.voucherBoughtThisAnte = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
log.info('Gateway', '盲注通过,进入商店', { 底注: runState.anteIndex + 1 });
|
|
175
|
+
await log.sleep(200);
|
|
176
|
+
const shopState = shop.createShopState(runState);
|
|
177
|
+
let leave = false;
|
|
178
|
+
while (!leave) {
|
|
179
|
+
if (runState.showHints) log.info('提示', '可选: 1/2 买小丑 p 星球牌 v 优惠券 r 刷新 s1/s2… 出售小丑 q 离开');
|
|
180
|
+
log.info('商店', '当前', {
|
|
181
|
+
金钱: runState.money,
|
|
182
|
+
已装备小丑: runState.jokers.map((j) => j.nameZh || j.name),
|
|
183
|
+
可购: shopState.jokers.map((j) => `${j.nameZh || j.name} ¥${j.price}`),
|
|
184
|
+
});
|
|
185
|
+
const sel = await cli.askShopChoice(rl, shopState, runState);
|
|
186
|
+
if (sel.action === 'leave') leave = true;
|
|
187
|
+
else if (sel.action === 'joker') {
|
|
188
|
+
const j = shopState.jokers[sel.index];
|
|
189
|
+
if (shop.buyJoker(runState, j)) {
|
|
190
|
+
shopState.jokers = shopState.jokers.filter((_, i) => i !== sel.index);
|
|
191
|
+
log.info('库存', '已购买小丑牌', { 槽位: runState.jokers.length });
|
|
192
|
+
} else log.warn('商店', '无法购买', { 原因: '金钱不足或槽位已满' });
|
|
193
|
+
} else if (sel.action === 'planet' && shopState.planet && shopState.planet[0]) {
|
|
194
|
+
const p = shopState.planet[0];
|
|
195
|
+
if (shop.buyPlanet(runState, p)) {
|
|
196
|
+
shopState.planet = [];
|
|
197
|
+
log.info('库存', '已使用星球牌', { 牌型: HAND_ZH[p.handId] || p.handId });
|
|
198
|
+
} else log.warn('商店', '无法购买', { 原因: '金钱不足' });
|
|
199
|
+
} else if (sel.action === 'voucher' && shopState.voucher) {
|
|
200
|
+
const v = shopState.voucher;
|
|
201
|
+
if (shop.buyVoucher(runState, v)) {
|
|
202
|
+
shopState.voucher = null;
|
|
203
|
+
log.info('库存', '已使用优惠券', { 类型: v?.nameZh || v?.name || '空白' });
|
|
204
|
+
} else log.warn('商店', '无法购买', { 原因: '金钱不足或本底注已买过' });
|
|
205
|
+
} else if (sel.action === 'refresh') {
|
|
206
|
+
if (shop.refreshShop(shopState, runState)) log.info('商店', '已刷新');
|
|
207
|
+
else log.warn('商店', '金钱不足');
|
|
208
|
+
} else if (sel.action === 'sellJoker') {
|
|
209
|
+
const sold = shop.sellJoker(runState, sel.index);
|
|
210
|
+
if (sold.ok) log.info('库存', '已出售小丑牌', { 获得: sold.money, 余额: runState.money });
|
|
211
|
+
else log.warn('商店', '无法出售');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
rl.close();
|
|
217
|
+
}
|
package/src/jokers.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joker definitions: id, name, price, effect (addChips, addMult, multMult, or conditional).
|
|
3
|
+
* Effects applied left-to-right; order matters (chips left, mult middle, xMult right).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Base chips and mult per hand at level 1 (Balatro). */
|
|
7
|
+
export const HAND_BASE = {
|
|
8
|
+
HighCard: { chips: 5, mult: 1 },
|
|
9
|
+
Pair: { chips: 10, mult: 2 },
|
|
10
|
+
TwoPair: { chips: 20, mult: 2 },
|
|
11
|
+
ThreeOfAKind: { chips: 30, mult: 3 },
|
|
12
|
+
Straight: { chips: 30, mult: 4 },
|
|
13
|
+
Flush: { chips: 35, mult: 4 },
|
|
14
|
+
FullHouse: { chips: 40, mult: 4 },
|
|
15
|
+
FourOfAKind: { chips: 60, mult: 7 },
|
|
16
|
+
StraightFlush: { chips: 100, mult: 8 },
|
|
17
|
+
RoyalFlush: { chips: 100, mult: 8 },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Planet per-level bonus: +chips, +mult (e.g. Pluto: +10 chips, +1 mult per level). */
|
|
21
|
+
export const PLANET_PER_LEVEL = {
|
|
22
|
+
HighCard: { chips: 10, mult: 1 },
|
|
23
|
+
Pair: { chips: 15, mult: 1 },
|
|
24
|
+
TwoPair: { chips: 20, mult: 1 },
|
|
25
|
+
ThreeOfAKind: { chips: 20, mult: 2 },
|
|
26
|
+
Straight: { chips: 30, mult: 3 },
|
|
27
|
+
Flush: { chips: 15, mult: 2 },
|
|
28
|
+
FullHouse: { chips: 25, mult: 2 },
|
|
29
|
+
FourOfAKind: { chips: 30, mult: 3 },
|
|
30
|
+
StraightFlush: { chips: 40, mult: 4 },
|
|
31
|
+
RoyalFlush: { chips: 40, mult: 4 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const JOKERS = [
|
|
35
|
+
{ id: 'Joker_Base', name: 'Base Joker', nameZh: '基础小丑', price: 2, effect: 'addMult', value: 4 },
|
|
36
|
+
{ id: 'Joker_PlusChips', name: 'Plus Chips', nameZh: '加筹码', price: 3, effect: 'addChips', value: 20 },
|
|
37
|
+
{ id: 'Joker_PlusMult', name: 'Plus Mult', nameZh: '加倍率', price: 4, effect: 'addMult', value: 3 },
|
|
38
|
+
{ id: 'Joker_TimesMult', name: 'Times Mult', nameZh: '乘倍率', price: 5, effect: 'multMult', value: 2 },
|
|
39
|
+
{ id: 'Joker_Pair', name: 'Pair Bonus', nameZh: '对子加成', price: 4, effect: 'conditionalMult', condition: 'handId', handId: 'Pair', value: 5 },
|
|
40
|
+
{ id: 'Joker_Three', name: 'Three Bonus', nameZh: '三条加成', price: 5, effect: 'conditionalMult', condition: 'handId', handId: 'ThreeOfAKind', value: 8 },
|
|
41
|
+
{ id: 'Joker_Flush', name: 'Flush Bonus', nameZh: '同花加成', price: 5, effect: 'conditionalMult', condition: 'handId', handId: 'Flush', value: 10 },
|
|
42
|
+
{ id: 'Joker_Straight', name: 'Straight Chips', nameZh: '顺子筹码', price: 4, effect: 'conditionalChips', condition: 'handId', handId: 'Straight', value: 80 },
|
|
43
|
+
{ id: 'Joker_NoDiscard', name: 'No Discard', nameZh: '未弃牌', price: 6, effect: 'conditionalMult', condition: 'discardsLeft', value: 15 },
|
|
44
|
+
{ id: 'Joker_EveryJoker', name: 'Per Joker', nameZh: '每张小丑', price: 5, effect: 'perJokerMult', value: 2 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function getJokerPool() {
|
|
48
|
+
return [...JOKERS];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getJokerById(id) {
|
|
52
|
+
return JOKERS.find((j) => j.id === id) || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getRandomJokers(pool, count, excludeIds = []) {
|
|
56
|
+
const available = pool.filter((j) => !excludeIds.includes(j.id));
|
|
57
|
+
const shuffled = [...available].sort(() => Math.random() - 0.5);
|
|
58
|
+
return shuffled.slice(0, count);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply a single joker to current chips and mult. Modifies state in place; returns same object.
|
|
63
|
+
*/
|
|
64
|
+
export function applyJoker(joker, state) {
|
|
65
|
+
const { chips, mult, handId, discardsLeft, totalJokers } = state;
|
|
66
|
+
let addC = 0;
|
|
67
|
+
let addM = 0;
|
|
68
|
+
let mulM = 1;
|
|
69
|
+
|
|
70
|
+
switch (joker.effect) {
|
|
71
|
+
case 'addChips':
|
|
72
|
+
addC = joker.value ?? 0;
|
|
73
|
+
break;
|
|
74
|
+
case 'addMult':
|
|
75
|
+
addM = joker.value ?? 0;
|
|
76
|
+
break;
|
|
77
|
+
case 'multMult':
|
|
78
|
+
mulM = joker.value ?? 1;
|
|
79
|
+
break;
|
|
80
|
+
case 'conditionalMult':
|
|
81
|
+
if (joker.condition === 'handId' && handId === joker.handId) addM = joker.value ?? 0;
|
|
82
|
+
if (joker.condition === 'discardsLeft' && discardsLeft > 0) addM = joker.value ?? 0;
|
|
83
|
+
break;
|
|
84
|
+
case 'conditionalChips':
|
|
85
|
+
if (joker.condition === 'handId' && handId === joker.handId) addC = joker.value ?? 0;
|
|
86
|
+
break;
|
|
87
|
+
case 'perJokerMult':
|
|
88
|
+
addM = (totalJokers ?? 0) * (joker.value ?? 0);
|
|
89
|
+
break;
|
|
90
|
+
default:
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
state.chips = chips + addC;
|
|
95
|
+
state.mult = mult + addM;
|
|
96
|
+
state.mult = Math.floor(state.mult * mulM);
|
|
97
|
+
return state;
|
|
98
|
+
}
|
package/src/logStyle.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-log style formatter: [ISO time] LEVEL [Component] message
|
|
3
|
+
* All game output goes through this to look like monitoring logs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function log(level, component, message, data) {
|
|
7
|
+
const time = new Date().toISOString();
|
|
8
|
+
const line = `[${time}] ${level} [${component}] ${message}` + (data != null && typeof data === 'object' && Object.keys(data).length > 0 ? ' ' + JSON.stringify(data) : '');
|
|
9
|
+
console.log(line);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
debug(component, message, data) {
|
|
14
|
+
log('DEBUG', component, message, data);
|
|
15
|
+
},
|
|
16
|
+
info(component, message, data) {
|
|
17
|
+
log('INFO', component, message, data);
|
|
18
|
+
},
|
|
19
|
+
warn(component, message, data) {
|
|
20
|
+
log('WARN', component, message, data);
|
|
21
|
+
},
|
|
22
|
+
error(component, message, data) {
|
|
23
|
+
log('ERROR', component, message, data);
|
|
24
|
+
},
|
|
25
|
+
/** Optional: log with short delay for "streaming" feel (ms) */
|
|
26
|
+
async infoDelayed(component, message, data, delayMs = 50) {
|
|
27
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
28
|
+
this.info(component, message, data);
|
|
29
|
+
},
|
|
30
|
+
/** Sleep for transition / animation (ms) */
|
|
31
|
+
async sleep(ms) {
|
|
32
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* 终端点动画:同一行循环显示 prefix + . / .. / ...
|
|
36
|
+
* @param {string} prefix 如 "发牌中" "结算中"
|
|
37
|
+
* @param {number} durationMs 总时长
|
|
38
|
+
* @param {number} stepMs 每步间隔
|
|
39
|
+
*/
|
|
40
|
+
async animateDots(prefix, durationMs = 800, stepMs = 160) {
|
|
41
|
+
process.stdout.write('\n');
|
|
42
|
+
const steps = Math.max(1, Math.floor(durationMs / stepMs));
|
|
43
|
+
for (let i = 0; i < steps; i++) {
|
|
44
|
+
const dots = '.'.repeat((i % 3) + 1);
|
|
45
|
+
const time = new Date().toISOString();
|
|
46
|
+
const line = `[${time}] INFO [牌务] ${prefix}${dots}`;
|
|
47
|
+
process.stdout.write(line.padEnd(78));
|
|
48
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
49
|
+
if (i < steps - 1) process.stdout.write('\r');
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write('\r' + ' '.repeat(78) + '\r');
|
|
52
|
+
},
|
|
53
|
+
async animateSettling(durationMs = 500, stepMs = 140) {
|
|
54
|
+
process.stdout.write('\n');
|
|
55
|
+
const prefix = '结算中';
|
|
56
|
+
const steps = Math.max(1, Math.floor(durationMs / stepMs));
|
|
57
|
+
for (let i = 0; i < steps; i++) {
|
|
58
|
+
const dots = '.'.repeat((i % 3) + 1);
|
|
59
|
+
const time = new Date().toISOString();
|
|
60
|
+
const line = `[${time}] INFO [计分] ${prefix}${dots}`;
|
|
61
|
+
process.stdout.write(line.padEnd(78));
|
|
62
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
63
|
+
if (i < steps - 1) process.stdout.write('\r');
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write('\r' + ' '.repeat(78) + '\r');
|
|
66
|
+
},
|
|
67
|
+
/**
|
|
68
|
+
* 计分动画:先出牌型与筹码倍率,再小丑结算中,最后得分从 0 跳到目标值
|
|
69
|
+
*/
|
|
70
|
+
async animateScoreResult(handName, chips, mult, finalScore, target) {
|
|
71
|
+
const time = () => new Date().toISOString();
|
|
72
|
+
process.stdout.write('\n');
|
|
73
|
+
process.stdout.write(`[${time()}] INFO [计分] 牌型=${handName} 筹码=${chips} 倍率=${mult}\n`);
|
|
74
|
+
await new Promise((r) => setTimeout(r, 180));
|
|
75
|
+
process.stdout.write(`[${time()}] INFO [计分] 小丑牌结算中`);
|
|
76
|
+
for (let i = 0; i < 4; i++) {
|
|
77
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
78
|
+
process.stdout.write('.');
|
|
79
|
+
}
|
|
80
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
81
|
+
const steps = 14;
|
|
82
|
+
const stepMs = 45;
|
|
83
|
+
const prefix = `[${time()}] INFO [计分] 牌型=${handName} 得分=`;
|
|
84
|
+
for (let i = 0; i <= steps; i++) {
|
|
85
|
+
const current = i === steps ? finalScore : Math.floor((finalScore * (i + 1)) / (steps + 1));
|
|
86
|
+
process.stdout.write(prefix + String(current).padStart(6) + ` 目标=${target}`);
|
|
87
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
88
|
+
if (i < steps) process.stdout.write('\r');
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write('\n');
|
|
91
|
+
},
|
|
92
|
+
};
|
package/src/round.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single blind round: 8 cards in hand, limited hands/discards, play or discard until target met or out of attempts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as deck from './deck.js';
|
|
6
|
+
import { computeScore } from './scoring.js';
|
|
7
|
+
import { getBlindConfig } from './blinds.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run one round. Returns { cleared: boolean, score?: number, handId?, target }.
|
|
11
|
+
* state: { deckState, handLevels, jokers }
|
|
12
|
+
* onPlay(cards, result), onDiscard(handAfter) for logging; ask() for user input (handled by cli).
|
|
13
|
+
*/
|
|
14
|
+
export function createRoundState(deckState, blindConfig) {
|
|
15
|
+
const hand = deck.dealHand(deckState);
|
|
16
|
+
return {
|
|
17
|
+
deckState,
|
|
18
|
+
hand,
|
|
19
|
+
handsLeft: blindConfig.hands,
|
|
20
|
+
discardsLeft: blindConfig.discards,
|
|
21
|
+
target: blindConfig.target,
|
|
22
|
+
debuff: blindConfig.debuff,
|
|
23
|
+
playedHandTypes: new Set(),
|
|
24
|
+
mustPlay5: blindConfig.debuff === 'must_play_5',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Play 5 cards (or fewer for high card/pair if not mustPlay5). Returns { cleared, score, handId } or error.
|
|
30
|
+
*/
|
|
31
|
+
export function playHand(roundState, state, indices) {
|
|
32
|
+
const { deckState, hand, handsLeft, discardsLeft, target, playedHandTypes, mustPlay5 } = roundState;
|
|
33
|
+
if (handsLeft <= 0) return { error: 'no_hands_left' };
|
|
34
|
+
if (indices.length !== 5 && mustPlay5) return { error: 'must_play_5' };
|
|
35
|
+
if (indices.some((i) => i < 0 || i >= hand.length)) return { error: 'invalid_indices' };
|
|
36
|
+
const cards = indices.map((i) => hand[i]).filter(Boolean);
|
|
37
|
+
if (cards.length !== 5) return { error: 'need_5_cards' };
|
|
38
|
+
|
|
39
|
+
const result = computeScore(cards, state.handLevels || {}, state.jokers || [], { discardsLeft });
|
|
40
|
+
const alreadyPlayed = playedHandTypes.has(result.handId);
|
|
41
|
+
if (state.bossDebuff === 'one_hand_type' && alreadyPlayed) return { error: 'hand_type_already_played' };
|
|
42
|
+
|
|
43
|
+
roundState.handsLeft--;
|
|
44
|
+
roundState.playedHandTypes.add(result.handId);
|
|
45
|
+
const newHand = deck.playCards(deckState, hand, indices);
|
|
46
|
+
roundState.hand = newHand;
|
|
47
|
+
|
|
48
|
+
const cleared = result.score >= target;
|
|
49
|
+
return { cleared, score: result.score, handId: result.handId, target, result };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discard by indices, refill hand. Returns { success } or error.
|
|
54
|
+
*/
|
|
55
|
+
export function doDiscard(roundState, indices) {
|
|
56
|
+
const { deckState, hand, discardsLeft } = roundState;
|
|
57
|
+
if (discardsLeft <= 0) return { error: 'no_discards_left' };
|
|
58
|
+
if (indices.length === 0) return { error: 'choose_cards' };
|
|
59
|
+
if (indices.some((i) => i < 0 || i >= hand.length)) return { error: 'invalid_indices' };
|
|
60
|
+
|
|
61
|
+
roundState.discardsLeft--;
|
|
62
|
+
roundState.hand = deck.discardCards(deckState, hand, indices);
|
|
63
|
+
return { success: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isRoundOver(roundState, state) {
|
|
67
|
+
const { handsLeft, target } = roundState;
|
|
68
|
+
if (handsLeft <= 0) return { lost: true };
|
|
69
|
+
return { lost: false };
|
|
70
|
+
}
|
package/src/scoring.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score = chips * mult. Base from hand type + level, then card chip values, then jokers left-to-right.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { cardChipValue } from './deck.js';
|
|
6
|
+
import { evaluateHand } from './handRank.js';
|
|
7
|
+
import { HAND_BASE, PLANET_PER_LEVEL, applyJoker } from './jokers.js';
|
|
8
|
+
|
|
9
|
+
export function getHandBaseChipsMult(handId, level = 1) {
|
|
10
|
+
const base = HAND_BASE[handId] || { chips: 5, mult: 1 };
|
|
11
|
+
const perLevel = PLANET_PER_LEVEL[handId] || { chips: 0, mult: 0 };
|
|
12
|
+
const chips = base.chips + (level - 1) * (perLevel.chips || 0);
|
|
13
|
+
const mult = base.mult + (level - 1) * (perLevel.mult || 0);
|
|
14
|
+
return { chips, mult };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute chips from scoring cards only (hand-dependent: high card 1 card, pair 2+3 kickers, etc.).
|
|
19
|
+
*/
|
|
20
|
+
export function chipsFromCards(scoringCards) {
|
|
21
|
+
if (!scoringCards || scoringCards.length === 0) return 0;
|
|
22
|
+
return scoringCards.reduce((sum, c) => sum + cardChipValue(c.rank), 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Full score for a played hand.
|
|
27
|
+
* @param {Object[]} cards - 5 cards played
|
|
28
|
+
* @param {Object} handLevels - { HandId: level } e.g. { Pair: 2, HighCard: 1 }
|
|
29
|
+
* @param {Object[]} jokers - equipped jokers in order
|
|
30
|
+
* @param {Object} context - { discardsLeft }
|
|
31
|
+
*/
|
|
32
|
+
export function computeScore(cards, handLevels = {}, jokers = [], context = {}) {
|
|
33
|
+
if (!cards || cards.length !== 5) return { score: 0, handId: 'HighCard', chips: 0, mult: 0 };
|
|
34
|
+
const evaluated = evaluateHand(cards);
|
|
35
|
+
const handId = evaluated.handId;
|
|
36
|
+
const level = handLevels[handId] ?? 1;
|
|
37
|
+
const base = getHandBaseChipsMult(handId, level);
|
|
38
|
+
let chips = base.chips + chipsFromCards(evaluated.scoringCards);
|
|
39
|
+
let mult = base.mult;
|
|
40
|
+
|
|
41
|
+
const state = {
|
|
42
|
+
chips,
|
|
43
|
+
mult,
|
|
44
|
+
handId,
|
|
45
|
+
discardsLeft: context.discardsLeft ?? 0,
|
|
46
|
+
totalJokers: jokers.length,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const j of jokers) {
|
|
50
|
+
applyJoker(j, state);
|
|
51
|
+
chips = state.chips;
|
|
52
|
+
mult = state.mult;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const score = Math.floor(chips * mult);
|
|
56
|
+
return {
|
|
57
|
+
score,
|
|
58
|
+
handId,
|
|
59
|
+
level,
|
|
60
|
+
chips: state.chips,
|
|
61
|
+
mult: state.mult,
|
|
62
|
+
scoringCards: evaluated.scoringCards,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/shop.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop after clearing a blind: buy jokers, planet cards, tarot/spectral, vouchers; refresh.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getJokerPool, getRandomJokers } from './jokers.js';
|
|
6
|
+
import { PLANET_PER_LEVEL } from './jokers.js';
|
|
7
|
+
|
|
8
|
+
const JOKER_SLOT_MAX = 5;
|
|
9
|
+
const PLANET_IDS = [
|
|
10
|
+
'Pluto', 'Mercury', 'Uranus', 'Venus', 'Saturn', 'Jupiter', 'Earth', 'Mars', 'Neptune'
|
|
11
|
+
];
|
|
12
|
+
const HAND_IDS_FOR_PLANET = [
|
|
13
|
+
'HighCard', 'Pair', 'TwoPair', 'ThreeOfAKind', 'Straight', 'Flush', 'FullHouse', 'FourOfAKind', 'StraightFlush'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function createShopState(runState) {
|
|
17
|
+
const pool = getJokerPool();
|
|
18
|
+
const jokers = getRandomJokers(pool, 3, (runState.jokers || []).map((j) => j.id));
|
|
19
|
+
const planetList = PLANET_IDS.map((id, i) => ({
|
|
20
|
+
id,
|
|
21
|
+
handId: HAND_IDS_FOR_PLANET[i],
|
|
22
|
+
price: 4,
|
|
23
|
+
}));
|
|
24
|
+
const planet = planetList[Math.floor(Math.random() * planetList.length)];
|
|
25
|
+
return {
|
|
26
|
+
jokers: jokers.slice(0, 2),
|
|
27
|
+
planet: planet ? [planet] : [],
|
|
28
|
+
voucher: runState.voucherBoughtThisAnte ? null : { id: 'Blank', name: '+1 小丑槽位', nameZh: '+1 小丑槽位', price: 10 },
|
|
29
|
+
refreshCost: 5, // 与 Balatro 一致:刷新起价 5,每次 +1,进新商店重置
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function canBuyJoker(runState, joker) {
|
|
34
|
+
if (!joker) return false;
|
|
35
|
+
const current = runState.jokers || [];
|
|
36
|
+
const maxSlots = runState.jokerSlots ?? JOKER_SLOT_MAX;
|
|
37
|
+
if (current.length >= maxSlots) return false;
|
|
38
|
+
return (runState.money || 0) >= joker.price;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buyJoker(runState, joker) {
|
|
42
|
+
if (!canBuyJoker(runState, joker)) return { ok: false };
|
|
43
|
+
runState.money = (runState.money || 0) - joker.price;
|
|
44
|
+
runState.jokers = [...(runState.jokers || []), joker];
|
|
45
|
+
return { ok: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 出售小丑:售价 = floor(购买价/2),最低 1(与 Balatro 一致) */
|
|
49
|
+
export function sellJoker(runState, index) {
|
|
50
|
+
const list = runState.jokers || [];
|
|
51
|
+
if (index < 0 || index >= list.length) return { ok: false, money: 0 };
|
|
52
|
+
const sold = list[index];
|
|
53
|
+
const buyCost = sold.price || 2;
|
|
54
|
+
const money = Math.max(1, Math.floor(buyCost / 2));
|
|
55
|
+
runState.money = (runState.money || 0) + money;
|
|
56
|
+
runState.jokers = list.filter((_, i) => i !== index);
|
|
57
|
+
return { ok: true, money };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function canBuyPlanet(runState, planet) {
|
|
61
|
+
if (!planet) return false;
|
|
62
|
+
return (runState.money || 0) >= (planet.price || 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buyPlanet(runState, planet) {
|
|
66
|
+
if (!canBuyPlanet(runState, planet)) return { ok: false };
|
|
67
|
+
runState.money = (runState.money || 0) - (planet.price || 4);
|
|
68
|
+
const handId = planet.handId || planet.id;
|
|
69
|
+
runState.handLevels = runState.handLevels || {};
|
|
70
|
+
runState.handLevels[handId] = (runState.handLevels[handId] || 1) + 1;
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function canBuyVoucher(runState, voucher) {
|
|
75
|
+
if (!voucher) return false;
|
|
76
|
+
if (runState.voucherBoughtThisAnte) return false;
|
|
77
|
+
return (runState.money || 0) >= (voucher.price || 10);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buyVoucher(runState, voucher) {
|
|
81
|
+
if (!canBuyVoucher(runState, voucher)) return { ok: false };
|
|
82
|
+
runState.money = (runState.money || 0) - (voucher.price || 10);
|
|
83
|
+
runState.voucherBoughtThisAnte = true;
|
|
84
|
+
if (voucher.id === 'Blank') runState.jokerSlots = (runState.jokerSlots || JOKER_SLOT_MAX) + 1;
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function refreshShop(shopState, runState) {
|
|
89
|
+
const cost = shopState.refreshCost ?? 5;
|
|
90
|
+
if ((runState.money || 0) < cost) return { ok: false };
|
|
91
|
+
runState.money = runState.money - cost;
|
|
92
|
+
shopState.refreshCost = (shopState.refreshCost ?? 5) + 1;
|
|
93
|
+
const pool = getJokerPool();
|
|
94
|
+
shopState.jokers = getRandomJokers(pool, 2, (runState.jokers || []).map((j) => j.id));
|
|
95
|
+
const planets = PLANET_IDS.map((id, i) => ({
|
|
96
|
+
id: HAND_IDS_FOR_PLANET[i],
|
|
97
|
+
handId: HAND_IDS_FOR_PLANET[i],
|
|
98
|
+
price: 4,
|
|
99
|
+
}));
|
|
100
|
+
const planet = planets[Math.floor(Math.random() * planets.length)];
|
|
101
|
+
shopState.planet = planet ? [planet] : [];
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const JOKER_SLOT_MAX_EXPORT = JOKER_SLOT_MAX;
|