352 lines
12 KiB
JavaScript
352 lines
12 KiB
JavaScript
// Blackjack pure game logic — no Phaser dependencies
|
|
|
|
import { SUITS, RANKS } from '../cards/Deck.js';
|
|
|
|
// ─── Shoe ─────────────────────────────────────────────────────────────────────
|
|
|
|
const RANK_VALUE = {
|
|
'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'T':10,'J':10,'Q':10,'K':10,'A':11,
|
|
};
|
|
|
|
function makeCard(rank, suit) {
|
|
return {
|
|
rank, suit,
|
|
value: RANK_VALUE[rank],
|
|
label: rank === 'T' ? '10' : rank,
|
|
isRed: suit === 'h' || suit === 'd',
|
|
suitSymbol: { s:'♠', h:'♥', d:'♦', c:'♣' }[suit],
|
|
key: `${rank}${suit}`,
|
|
};
|
|
}
|
|
|
|
export function buildShoe(numDecks = 6) {
|
|
const cards = [];
|
|
for (let d = 0; d < numDecks; d++) {
|
|
for (const suit of SUITS) {
|
|
for (const rank of RANKS) {
|
|
cards.push(makeCard(rank, suit));
|
|
}
|
|
}
|
|
}
|
|
for (let i = cards.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[cards[i], cards[j]] = [cards[j], cards[i]];
|
|
}
|
|
return cards;
|
|
}
|
|
|
|
// ─── Hand evaluation ──────────────────────────────────────────────────────────
|
|
|
|
export function handValue(cards) {
|
|
let total = 0;
|
|
let aces = 0;
|
|
for (const c of cards) {
|
|
if (c.rank === 'A') { aces++; total += 11; }
|
|
else total += Math.min(c.value, 10);
|
|
}
|
|
while (total > 21 && aces > 0) { total -= 10; aces--; }
|
|
return { score: total, soft: aces > 0 && total <= 21 };
|
|
}
|
|
|
|
export function isBlackjack(hand) {
|
|
return hand.length === 2 && handValue(hand).score === 21;
|
|
}
|
|
|
|
export function isBust(hand) {
|
|
return handValue(hand).score > 21;
|
|
}
|
|
|
|
export function canDouble(player) {
|
|
const hand = player.activeHand === 1 ? player.hand2 : player.hand;
|
|
return (hand ?? []).length === 2;
|
|
}
|
|
|
|
export function canSplit(player) {
|
|
return (
|
|
player.hand.length === 2 &&
|
|
player.hand[0].rank === player.hand[1].rank &&
|
|
!player.hand2
|
|
);
|
|
}
|
|
|
|
// ─── State creation ───────────────────────────────────────────────────────────
|
|
|
|
export function createInitialState(opponents, chips) {
|
|
const players = [
|
|
{
|
|
seat: 0, name: 'You', isHuman: true, active: true, opponent: null,
|
|
chips, bet: 0, bet2: null,
|
|
hand: [], hand2: null,
|
|
doubled: false, doubled2: false,
|
|
insuranceBet: 0,
|
|
activeHand: 0,
|
|
status: 'waiting', status2: null,
|
|
result: null, result2: null, chipsWon: 0,
|
|
},
|
|
];
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
const opp = opponents[i] ?? null;
|
|
players.push({
|
|
seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp,
|
|
chips: 1000, bet: 0, bet2: null,
|
|
hand: [], hand2: null,
|
|
doubled: false, doubled2: false,
|
|
insuranceBet: 0,
|
|
activeHand: 0,
|
|
status: 'waiting', status2: null,
|
|
result: null, result2: null, chipsWon: 0,
|
|
});
|
|
}
|
|
|
|
return {
|
|
phase: 'betting',
|
|
dealer: { hand: [], revealed: false },
|
|
players,
|
|
currentSeat: 0,
|
|
insurancePending: false,
|
|
roundNumber: 0,
|
|
};
|
|
}
|
|
|
|
// ─── Round start ──────────────────────────────────────────────────────────────
|
|
|
|
export function prepareRound(gs) {
|
|
const players = gs.players.map(p => ({
|
|
...p,
|
|
bet: 0, bet2: null,
|
|
hand: [], hand2: null,
|
|
doubled: false, doubled2: false,
|
|
insuranceBet: 0,
|
|
activeHand: 0,
|
|
status: p.active ? 'waiting' : 'waiting',
|
|
status2: null,
|
|
result: null, result2: null, chipsWon: 0,
|
|
}));
|
|
return {
|
|
...gs,
|
|
phase: 'betting',
|
|
dealer: { hand: [], revealed: false },
|
|
players,
|
|
currentSeat: 0,
|
|
insurancePending: false,
|
|
roundNumber: gs.roundNumber + 1,
|
|
};
|
|
}
|
|
|
|
export function applyBet(gs, seat, amount) {
|
|
const players = gs.players.map(p => p.seat === seat ? { ...p, bet: amount } : p);
|
|
return { ...gs, players };
|
|
}
|
|
|
|
// Deal all cards into state from the shoe (shoe is mutated).
|
|
// Returns new gs with hands populated, phase set appropriately.
|
|
export function dealAllCards(gs, shoe) {
|
|
const players = gs.players.map(p => ({ ...p }));
|
|
const dealer = { hand: [], revealed: false };
|
|
|
|
// Standard deal order: each player card 1, dealer upcard, each player card 2, dealer hole
|
|
const activePlayers = players.filter(p => p.active);
|
|
|
|
// Round 1
|
|
for (const p of activePlayers) p.hand.push(shoe.pop());
|
|
dealer.hand.push(shoe.pop()); // upcard (index 0)
|
|
|
|
// Round 2
|
|
for (const p of activePlayers) p.hand.push(shoe.pop());
|
|
dealer.hand.push(shoe.pop()); // hole card (index 1)
|
|
|
|
// Detect player blackjacks immediately
|
|
for (const p of activePlayers) {
|
|
if (isBlackjack(p.hand)) p.status = 'blackjack';
|
|
}
|
|
|
|
const firstActiveSeat = activePlayers[0]?.seat ?? 0;
|
|
const dealerUpcard = dealer.hand[0];
|
|
const insurancePending = dealerUpcard.rank === 'A';
|
|
|
|
return {
|
|
...gs,
|
|
phase: insurancePending ? 'insurance' : 'player_turn',
|
|
dealer,
|
|
players,
|
|
currentSeat: firstActiveSeat,
|
|
insurancePending,
|
|
};
|
|
}
|
|
|
|
// ─── Insurance ────────────────────────────────────────────────────────────────
|
|
|
|
export function applyInsurance(gs, seat, accept) {
|
|
const players = gs.players.map(p => {
|
|
if (p.seat !== seat) return p;
|
|
const insuranceBet = accept ? Math.floor(p.bet / 2) : 0;
|
|
return { ...p, insuranceBet };
|
|
});
|
|
return { ...gs, players };
|
|
}
|
|
|
|
// After all insurance decisions: check dealer blackjack
|
|
export function resolveInsurance(gs) {
|
|
const dealerBJ = isBlackjack(gs.dealer.hand);
|
|
const players = gs.players.map(p => {
|
|
if (!p.active || p.insuranceBet === 0) return p;
|
|
// Insurance bet: pays 2:1 if dealer has BJ, else lost
|
|
const insuranceResult = dealerBJ ? p.insuranceBet * 2 : -p.insuranceBet;
|
|
return { ...p, chipsWon: (p.chipsWon ?? 0) + insuranceResult };
|
|
});
|
|
return { ...gs, players, insurancePending: false };
|
|
}
|
|
|
|
// ─── Player actions ───────────────────────────────────────────────────────────
|
|
|
|
export function applyHit(gs, seat, shoe) {
|
|
const players = gs.players.map(p => {
|
|
if (p.seat !== seat) return p;
|
|
const isH2 = p.activeHand === 1;
|
|
const newCard = shoe.pop();
|
|
if (isH2) {
|
|
const hand2 = [...p.hand2, newCard];
|
|
const bust = isBust(hand2);
|
|
return { ...p, hand2, status2: bust ? 'bust' : 'playing' };
|
|
} else {
|
|
const hand = [...p.hand, newCard];
|
|
const bust = isBust(hand);
|
|
return { ...p, hand, status: bust ? 'bust' : 'playing' };
|
|
}
|
|
});
|
|
return { ...gs, players };
|
|
}
|
|
|
|
export function applyStand(gs, seat) {
|
|
const players = gs.players.map(p => {
|
|
if (p.seat !== seat) return p;
|
|
if (p.activeHand === 1) return { ...p, status2: 'standing' };
|
|
// If split exists and hand 1 not yet played, move to hand 2
|
|
if (p.hand2 !== null && p.status2 === null) return { ...p, status: 'standing', activeHand: 1 };
|
|
return { ...p, status: 'standing' };
|
|
});
|
|
return { ...gs, players };
|
|
}
|
|
|
|
export function applyDouble(gs, seat, shoe) {
|
|
const players = gs.players.map(p => {
|
|
if (p.seat !== seat) return p;
|
|
const isH2 = p.activeHand === 1;
|
|
const newCard = shoe.pop();
|
|
if (isH2) {
|
|
const hand2 = [...p.hand2, newCard];
|
|
const bust = isBust(hand2);
|
|
return { ...p, hand2, bet2: p.bet2 * 2, doubled2: true, status2: bust ? 'bust' : 'standing' };
|
|
} else {
|
|
const hand = [...p.hand, newCard];
|
|
const bust = isBust(hand);
|
|
const doubled = true;
|
|
// If split exists and not yet played hand 2
|
|
if (p.hand2 !== null && p.status2 === null) {
|
|
return { ...p, hand, bet: p.bet * 2, doubled, status: bust ? 'bust' : 'standing', activeHand: 1 };
|
|
}
|
|
return { ...p, hand, bet: p.bet * 2, doubled, status: bust ? 'bust' : 'standing' };
|
|
}
|
|
});
|
|
return { ...gs, players };
|
|
}
|
|
|
|
export function applySplit(gs, seat, shoe) {
|
|
const players = gs.players.map(p => {
|
|
if (p.seat !== seat) return p;
|
|
const [c1, c2] = p.hand;
|
|
const hand = [c1, shoe.pop()];
|
|
const hand2 = [c2, shoe.pop()];
|
|
const isAceSplit = c1.rank === 'A';
|
|
// Ace splits: each hand gets one card only, immediately stands
|
|
return {
|
|
...p,
|
|
hand, hand2,
|
|
bet2: p.bet,
|
|
doubled: false, doubled2: false,
|
|
status: isAceSplit ? 'standing' : 'playing',
|
|
status2: isAceSplit ? 'standing' : null,
|
|
activeHand: 0,
|
|
};
|
|
});
|
|
return { ...gs, players };
|
|
}
|
|
|
|
// ─── Dealer turn ──────────────────────────────────────────────────────────────
|
|
|
|
// Returns new gs with dealer hand fully played out. shoe is mutated.
|
|
export function runDealerTurn(gs, shoe) {
|
|
const dealer = { ...gs.dealer, revealed: true };
|
|
while (true) {
|
|
const { score } = handValue(dealer.hand);
|
|
if (score >= 17) break;
|
|
dealer.hand = [...dealer.hand, shoe.pop()];
|
|
}
|
|
return { ...gs, phase: 'resolved', dealer };
|
|
}
|
|
|
|
// ─── Resolve round ────────────────────────────────────────────────────────────
|
|
|
|
export function resolveRound(gs) {
|
|
const { score: dealerScore } = handValue(gs.dealer.hand);
|
|
const dealerBJ = isBlackjack(gs.dealer.hand);
|
|
const dealerBust = dealerScore > 21;
|
|
|
|
const players = gs.players.map(p => {
|
|
if (!p.active) return p;
|
|
|
|
function resolveHand(hand, bet, status, doubled) {
|
|
if (!hand || hand.length === 0) return { result: null, net: 0 };
|
|
const { score: playerScore } = handValue(hand);
|
|
const playerBJ = isBlackjack(hand);
|
|
|
|
if (status === 'bust') return { result: 'lose', net: -bet };
|
|
if (playerBJ && dealerBJ) return { result: 'push', net: 0 };
|
|
if (playerBJ) return { result: 'blackjack', net: Math.floor(bet * 1.5) }; // 3:2
|
|
if (dealerBJ) return { result: 'lose', net: -bet };
|
|
if (dealerBust) return { result: 'win', net: bet };
|
|
if (playerScore > dealerScore) return { result: 'win', net: bet };
|
|
if (playerScore === dealerScore) return { result: 'push', net: 0 };
|
|
return { result: 'lose', net: -bet };
|
|
}
|
|
|
|
const h1 = resolveHand(p.hand, p.bet, p.status, p.doubled);
|
|
const h2 = p.hand2 ? resolveHand(p.hand2, p.bet2, p.status2, p.doubled2) : { result: null, net: 0 };
|
|
|
|
const totalNet = h1.net + h2.net + (p.chipsWon ?? 0); // chipsWon may have insurance delta
|
|
const newChips = p.chips + totalNet;
|
|
|
|
return {
|
|
...p,
|
|
chips: newChips,
|
|
result: h1.result,
|
|
result2: h2.result,
|
|
chipsWon: totalNet,
|
|
};
|
|
});
|
|
|
|
return { ...gs, phase: 'resolved', players };
|
|
}
|
|
|
|
// ─── Turn advancement ─────────────────────────────────────────────────────────
|
|
|
|
// Returns the seat number of the next player who still needs to act, or null if done.
|
|
export function nextActiveSeat(gs) {
|
|
const activePlayers = gs.players.filter(p => p.active);
|
|
const currentIdx = activePlayers.findIndex(p => p.seat === gs.currentSeat);
|
|
for (let i = currentIdx + 1; i < activePlayers.length; i++) {
|
|
const p = activePlayers[i];
|
|
if (p.status === 'playing' || p.status === 'waiting') return p.seat;
|
|
}
|
|
return null; // dealer's turn
|
|
}
|
|
|
|
export function isPlayerDone(player) {
|
|
// Hand 1 is done
|
|
const h1done = ['standing','bust','blackjack','done'].includes(player.status);
|
|
// Hand 2 (if split): must also be done
|
|
const h2done = player.hand2 === null || ['standing','bust','blackjack','done'].includes(player.status2);
|
|
return h1done && h2done;
|
|
}
|