fertig-classic-games/tools/verifyPaiGowPoker.js

487 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// verifyPaiGowPoker.js — engine tests for Pai Gow Poker
import {
HAND_RANK, makeJoker, buildDeck,
evaluate5Card, evaluate2Card,
compare5Card, compare2Card,
isFoul, houseWay,
createInitialState, prepareRound, applyBet, dealHands,
applyHouseWaySplit, applyDealerHouseWay, applyHumanSplit, resolveRound,
rankLabel,
} from '../src/games/paigow/PaiGowPokerLogic.js';
import { chooseBet } from '../src/games/paigow/PaiGowPokerAI.js';
import { SUITS, RANKS } from '../src/games/cards/Deck.js';
let pass = 0, fail = 0;
function ok(label, cond) {
if (cond) { console.log(`${label}`); pass++; }
else { console.error(`${label}`); fail++; }
}
function card(rank, suit) {
const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2]));
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}`, isJoker: false };
}
// ── Deck ──────────────────────────────────────────────────────────────────────
console.log('\nDeck');
const deck = buildDeck();
ok('deck has 53 cards', deck.length === 53);
ok('deck contains one Joker', deck.filter(c => c.isJoker).length === 1);
ok('Joker has correct key', deck.find(c => c.isJoker)?.key === 'JK');
const nonJoker = deck.filter(c => !c.isJoker);
ok('52 standard cards present', nonJoker.length === 52);
const keys = new Set(nonJoker.map(c => c.key));
ok('all standard keys unique', keys.size === 52);
// Deck should be shuffled (not in insertion order)
const firstRank = deck[0].rank;
const allSame = deck.slice(0, 13).every(c => c.rank === firstRank);
ok('deck is shuffled (not all same rank in first 13)', !allSame);
// ── 5-card eval — standard hands ─────────────────────────────────────────────
console.log('\n5-card evaluation — standard hands');
const royalFlush = [card('A','s'), card('K','s'), card('Q','s'), card('J','s'), card('T','s')];
const ev = evaluate5Card(royalFlush);
ok('Royal Flush detected', ev.rank === HAND_RANK.ROYAL_FLUSH);
ok('Royal Flush name', ev.name === 'Royal Flush');
const sf = [card('9','h'), card('8','h'), card('7','h'), card('6','h'), card('5','h')];
const sfEv = evaluate5Card(sf);
ok('Straight Flush detected', sfEv.rank === HAND_RANK.STRAIGHT_FLUSH);
ok('Straight Flush tiebreaker = 9', sfEv.tiebreakers[0] === 9);
const quads = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), card('K','s')];
const qEv = evaluate5Card(quads);
ok('Four of a Kind detected', qEv.rank === HAND_RANK.FOUR_OF_A_KIND);
ok('Quads tiebreakers [14,13]', qEv.tiebreakers[0] === 14 && qEv.tiebreakers[1] === 13);
const fh = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h')];
ok('Full House detected', evaluate5Card(fh).rank === HAND_RANK.FULL_HOUSE);
const flush = [card('A','h'), card('J','h'), card('9','h'), card('6','h'), card('2','h')];
ok('Flush detected', evaluate5Card(flush).rank === HAND_RANK.FLUSH);
const straight = [card('9','s'), card('8','h'), card('7','d'), card('6','c'), card('5','s')];
ok('Straight detected', evaluate5Card(straight).rank === HAND_RANK.STRAIGHT);
ok('Straight tiebreaker = 9', evaluate5Card(straight).tiebreakers[0] === 9);
// Wheel (A-2-3-4-5)
const wheel = [card('A','s'), card('2','h'), card('3','d'), card('4','c'), card('5','s')];
const wheelEv = evaluate5Card(wheel);
ok('Wheel straight detected', wheelEv.rank === HAND_RANK.STRAIGHT);
ok('Wheel tiebreaker = 5', wheelEv.tiebreakers[0] === 5);
const trips = [card('Q','s'), card('Q','h'), card('Q','d'), card('7','c'), card('3','s')];
ok('Three of a Kind detected', evaluate5Card(trips).rank === HAND_RANK.THREE_OF_A_KIND);
const twoPair = [card('A','s'), card('A','h'), card('K','d'), card('K','c'), card('J','s')];
ok('Two Pair detected', evaluate5Card(twoPair).rank === HAND_RANK.TWO_PAIR);
const onePair = [card('T','s'), card('T','h'), card('A','d'), card('K','c'), card('Q','s')];
ok('One Pair detected', evaluate5Card(onePair).rank === HAND_RANK.ONE_PAIR);
const hiCard = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')];
ok('High Card detected', evaluate5Card(hiCard).rank === HAND_RANK.HIGH_CARD);
// Flush SF distinction (non-SF flush)
const sfDistinct = [card('K','s'), card('Q','s'), card('J','s'), card('T','s'), card('8','s')];
ok('Non-consecutive flush is Flush not SF', evaluate5Card(sfDistinct).rank === HAND_RANK.FLUSH);
// ── 5-card eval — Joker ───────────────────────────────────────────────────────
console.log('\n5-card evaluation — Joker');
const joker = makeJoker();
// Five Aces
const fiveAces = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), joker];
const faEv = evaluate5Card(fiveAces);
ok('Five Aces detected', faEv.rank === HAND_RANK.FIVE_ACES);
ok('Five Aces name', faEv.name === 'Five Aces');
// Joker completes a flush
const flushWithJoker = [card('K','h'), card('J','h'), card('9','h'), card('6','h'), joker];
const fjEv = evaluate5Card(flushWithJoker);
ok('Joker completes flush', fjEv.rank === HAND_RANK.FLUSH);
// Joker completes a straight
const stWithJoker = [card('9','s'), card('8','h'), card('7','d'), card('6','c'), joker];
const sjEv = evaluate5Card(stWithJoker);
ok('Joker completes straight', sjEv.rank === HAND_RANK.STRAIGHT);
// Joker completes a straight flush
const sfWithJoker = [card('9','h'), card('8','h'), card('7','h'), card('6','h'), joker];
const sfJEv = evaluate5Card(sfWithJoker);
ok('Joker completes straight flush', sfJEv.rank === HAND_RANK.STRAIGHT_FLUSH);
// Joker as Ace (no better use in a scattered hand)
const jokerAlone = [card('K','s'), card('Q','h'), card('J','d'), card('9','c'), joker];
const jaEv = evaluate5Card(jokerAlone);
// K-Q-J-9 + Joker can complete K-Q-J-T-9 straight with Joker as T → STRAIGHT
ok('Joker completes a straight when possible', jaEv.rank >= HAND_RANK.STRAIGHT);
// Joker with one Ace in a hand that can make a straight (AKQJ+Joker=T → STRAIGHT)
const jokerAceStr = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), joker];
const jaStr = evaluate5Card(jokerAceStr);
ok('Joker+AKQJ makes a straight (Joker=T)', jaStr.rank === HAND_RANK.STRAIGHT);
// Joker with one Ace in a hand that cannot make straight or flush → pair of Aces
const jokerAcePair = [card('A','s'), card('9','h'), card('7','d'), card('5','c'), joker];
const jaPair = evaluate5Card(jokerAcePair);
ok('Joker + Ace (no straight/flush) = pair of Aces', jaPair.rank === HAND_RANK.ONE_PAIR && jaPair.tiebreakers[0] === 14);
// ── 2-card evaluation ─────────────────────────────────────────────────────────
console.log('\n2-card evaluation');
const pairKings = [card('K','s'), card('K','h')];
const hiCardAQ = [card('A','s'), card('Q','h')];
ok('pair beats high card', compare2Card(evaluate2Card(pairKings), evaluate2Card(hiCardAQ)) > 0);
const pairAces = [card('A','s'), card('A','h')];
ok('pair of Aces beats pair of Kings', compare2Card(evaluate2Card(pairAces), evaluate2Card(pairKings)) > 0);
const AK = [card('A','s'), card('K','h')];
const AQ = [card('A','h'), card('Q','d')];
ok('A-K beats A-Q', compare2Card(evaluate2Card(AK), evaluate2Card(AQ)) > 0);
const jokerK = [joker, card('K','s')];
const jokerKEv = evaluate2Card(jokerK);
ok('Joker in 2-card hand = Ace (Ace-King)', jokerKEv.rank === 0 && jokerKEv.tiebreakers[0] === 14);
// Pair of Joker+Ace (Joker treated as Ace)
const jokerAceLow2 = [joker, card('A','s')];
const jokerAceEv = evaluate2Card(jokerAceLow2);
ok('Joker + Ace in 2-card = pair of Aces', jokerAceEv.rank === 1 && jokerAceEv.tiebreakers[0] === 14);
// ── compare5Card / compare2Card ────────────────────────────────────────────────
console.log('\nComparison');
const sfEval = evaluate5Card(sf);
const flushEval = evaluate5Card(flush);
ok('compare5Card: SF beats Flush', compare5Card(sfEval, flushEval) > 0);
const pairAcesEv = evaluate5Card([card('A','s'), card('A','h'), card('K','d'), card('Q','c'), card('J','s')]);
const pairKingsEv = evaluate5Card([card('K','s'), card('K','h'), card('A','d'), card('Q','c'), card('J','s')]);
ok('compare5Card: pair Aces > pair Kings', compare5Card(pairAcesEv, pairKingsEv) > 0);
ok('compare5Card: tie returns 0', compare5Card(pairAcesEv, pairAcesEv) === 0);
const p2 = evaluate2Card([card('A','s'), card('K','h')]);
const p3 = evaluate2Card([card('A','h'), card('K','d')]);
ok('compare2Card: equal high cards returns 0', compare2Card(p2, p3) === 0);
// ── Foul detection ─────────────────────────────────────────────────────────────
console.log('\nFoul detection');
// Valid: 5-card pair of 2s, 2-card A-K
const valid5 = [card('2','s'), card('2','h'), card('A','d'), card('K','c'), card('Q','s')];
const valid2 = [card('A','s'), card('K','h')];
ok('pair of 2s vs A-K high card: NOT foul', !isFoul(valid5, valid2));
// Foul: 5-card A-K-Q-J-9 high card, 2-card pair of Kings
const foul5 = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')];
const foul2 = [card('K','s'), card('K','d')];
ok('high card 5-card vs pair of Kings 2-card: IS foul', isFoul(foul5, foul2));
// Foul: 5-card pair of Kings, 2-card pair of Aces
const foul5b = [card('K','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')];
const foul2b = [card('A','s'), card('A','h')];
ok('pair of Kings high vs pair of Aces low: IS foul', isFoul(foul5b, foul2b));
// Valid: 5-card pair of Aces, 2-card pair of Kings
const valid5b = [card('A','s'), card('A','h'), card('K','d'), card('Q','c'), card('J','s')];
const valid2b = [card('K','s'), card('K','h')];
ok('pair of Aces vs pair of Kings: NOT foul', !isFoul(valid5b, valid2b));
// Equal pairs: NOT a foul (5-card hand wins on kickers; this case only arises with quads)
const equalP5 = [card('K','d'), card('K','c'), card('Q','s'), card('J','h'), card('9','s')];
const equalP2 = [card('K','s'), card('K','h')];
ok('pair of Kings vs pair of Kings: NOT foul (equal rank, 5-card wins on kickers)', !isFoul(equalP5, equalP2));
// ── House Way ──────────────────────────────────────────────────────────────────
console.log('\nHouse Way — No Pair');
const noPair7 = [card('A','s'), card('Q','h'), card('J','d'), card('9','c'), card('8','s'), card('6','h'), card('3','d')];
const hwNP = houseWay(noPair7);
ok('no pair: high hand has 5 cards', hwNP.highHand.length === 5);
ok('no pair: low hand has 2 cards', hwNP.lowHand.length === 2);
ok('no pair: Ace in high hand', hwNP.highHand.some(c => c.rank === 'A'));
// 2nd/3rd best (Q, J) should be in low hand
ok('no pair: Q in low hand', hwNP.lowHand.some(c => c.rank === 'Q'));
ok('no pair: split not foul', !isFoul(hwNP.highHand, hwNP.lowHand));
console.log('\nHouse Way — One Pair');
// Hand with no straight/flush possible so pair rule is exercised cleanly
const onePair7 = [card('7','s'), card('7','h'), card('A','d'), card('K','c'), card('9','s'), card('5','h'), card('3','d')];
const hwOP = houseWay(onePair7);
ok('one pair: high hand has pair', evaluate5Card(hwOP.highHand).rank >= HAND_RANK.ONE_PAIR);
ok('one pair: A in low hand (best 2 singletons)', hwOP.lowHand.some(c => c.rank === 'A'));
ok('one pair: K in low hand', hwOP.lowHand.some(c => c.rank === 'K'));
ok('one pair: split not foul', !isFoul(hwOP.highHand, hwOP.lowHand));
console.log('\nHouse Way — Two Pair');
// Both pairs ≤ 6s: keep together
const tp1 = [card('6','s'), card('6','h'), card('4','d'), card('4','c'), card('A','s'), card('K','h'), card('Q','d')];
const hwTP1 = houseWay(tp1);
ok('two pair (≤6): both pairs in high', evaluate5Card(hwTP1.highHand).rank === HAND_RANK.TWO_PAIR);
ok('two pair (≤6): not foul', !isFoul(hwTP1.highHand, hwTP1.lowHand));
// High pair ≥ JJ: split — HIGH pair → HIGH hand, LOW pair → LOW hand
const tp2 = [card('J','s'), card('J','h'), card('5','d'), card('5','c'), card('A','s'), card('K','h'), card('Q','d')];
const hwTP2 = houseWay(tp2);
ok('two pair (JJ+): JJ in HIGH hand', hwTP2.highHand.some(c => c.rank === 'J'));
ok('two pair (JJ+): low pair (55) in LOW hand', hwTP2.lowHand.every(c => c.rank === '5'));
ok('two pair (JJ+): not foul', !isFoul(hwTP2.highHand, hwTP2.lowHand));
// AA + 22: split — AA in HIGH hand, 22 in LOW hand
const tp3 = [card('A','s'), card('A','h'), card('2','d'), card('2','c'), card('K','s'), card('Q','h'), card('J','d')];
const hwTP3 = houseWay(tp3);
ok('two pair (AA+22): AA in HIGH hand', hwTP3.highHand.some(c => c.rank === 'A'));
ok('two pair (AA+22): 22 in LOW hand', hwTP3.lowHand.every(c => c.rank === '2'));
ok('two pair (AA+22): not foul', !isFoul(hwTP3.highHand, hwTP3.lowHand));
console.log('\nHouse Way — Three Pair');
const threePair = [card('A','s'), card('A','h'), card('K','d'), card('K','c'), card('Q','s'), card('Q','h'), card('J','d')];
const hwThreePair = houseWay(threePair);
ok('three pair: low hand is a pair', evaluate2Card(hwThreePair.lowHand).rank === 1);
ok('three pair: low hand has highest pair (Aces)', hwThreePair.lowHand.some(c => c.rank === 'A'));
ok('three pair: not foul', !isFoul(hwThreePair.highHand, hwThreePair.lowHand));
console.log('\nHouse Way — Three of a Kind');
const trips7 = [card('8','s'), card('8','h'), card('8','d'), card('A','c'), card('K','s'), card('Q','h'), card('J','d')];
const hwTrips = houseWay(trips7);
ok('trips (888): trips in high hand', evaluate5Card(hwTrips.highHand).rank === HAND_RANK.THREE_OF_A_KIND);
ok('trips: A in low hand', hwTrips.lowHand.some(c => c.rank === 'A'));
ok('trips: not foul', !isFoul(hwTrips.highHand, hwTrips.lowHand));
// Three Aces
const tripsAces = [card('A','s'), card('A','h'), card('A','d'), card('K','c'), card('Q','s'), card('J','h'), card('T','d')];
const hwTA = houseWay(tripsAces);
ok('three Aces: one Ace in low hand', hwTA.lowHand.some(c => c.rank === 'A'));
ok('three Aces: not foul', !isFoul(hwTA.highHand, hwTA.lowHand));
console.log('\nHouse Way — Full House');
const fullHouse7 = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h'), card('A','c'), card('J','d')];
const hwFH = houseWay(fullHouse7);
ok('full house: pair in low hand', evaluate2Card(hwFH.lowHand).rank === 1);
ok('full house: pair of Queens in low', hwFH.lowHand.every(c => c.rank === 'Q'));
ok('full house: trips in high', evaluate5Card(hwFH.highHand).rank === HAND_RANK.THREE_OF_A_KIND);
ok('full house: not foul', !isFoul(hwFH.highHand, hwFH.lowHand));
console.log('\nHouse Way — Four of a Kind');
// Low quads (2s-6s): keep together
const quads6 = [card('6','s'), card('6','h'), card('6','d'), card('6','c'), card('A','s'), card('K','h'), card('Q','d')];
const hwQ6 = houseWay(quads6);
ok('quads of 6s: all 4 in high hand', hwQ6.highHand.filter(c => c.rank === '6').length === 4);
ok('quads of 6s: not foul', !isFoul(hwQ6.highHand, hwQ6.lowHand));
// High quads (Aces): always split
const quadsA = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), card('K','s'), card('Q','h'), card('J','d')];
const hwQA = houseWay(quadsA);
ok('quads of Aces: split (2 Aces in low)', hwQA.lowHand.filter(c => c.rank === 'A').length === 2);
ok('quads of Aces: 2 Aces in high', hwQA.highHand.filter(c => c.rank === 'A').length === 2);
ok('quads of Aces: not foul', !isFoul(hwQA.highHand, hwQA.lowHand));
// Jack quads: split
const quadsJ = [card('J','s'), card('J','h'), card('J','d'), card('J','c'), card('K','s'), card('Q','h'), card('T','d')];
const hwQJ = houseWay(quadsJ);
ok('quads of Jacks: split', hwQJ.lowHand.filter(c => c.rank === 'J').length === 2);
ok('quads of Jacks: not foul', !isFoul(hwQJ.highHand, hwQJ.lowHand));
console.log('\nHouse Way — Five Aces');
const fiveAces7 = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), makeJoker(), card('K','s'), card('Q','h')];
const hwFA = houseWay(fiveAces7);
ok('Five Aces: 5 ace-equiv cards in high', hwFA.highHand.length === 5);
ok('Five Aces: Five Aces rank', evaluate5Card(hwFA.highHand).rank === HAND_RANK.FIVE_ACES);
ok('Five Aces: not foul', !isFoul(hwFA.highHand, hwFA.lowHand));
// ── State management ───────────────────────────────────────────────────────────
console.log('\nState management');
const opponents = Array.from({ length: 3 }, (_, i) => ({ name: `AI${i+1}`, id: i+1 }));
let gs = createInitialState(opponents, 2000);
ok('createInitialState: 6 seats total', gs.players.length === 6);
ok('seat 0 is human', gs.players[0].isHuman);
ok('3 opponents active', gs.players.filter(p => p.active && !p.isHuman).length === 3);
ok('2 empty seats inactive', gs.players.filter(p => !p.active).length === 2);
gs = prepareRound(gs);
ok('prepareRound: phase = betting', gs.phase === 'betting');
ok('prepareRound: deck has 53 cards', gs.deck.length === 53);
ok('prepareRound: bets reset', gs.players.every(p => p.bet === 0));
gs = applyBet(gs, 0, 25);
ok('applyBet: human bet = 25', gs.players[0].bet === 25);
gs = dealHands(gs);
ok('dealHands: phase = setting', gs.phase === 'setting');
ok('human has 7 cards', gs.players[0].hand.length === 7);
ok('AI1 has 7 cards', gs.players[1].hand.length === 7);
ok('dealer has 7 cards', gs.dealer.hand.length === 7);
// 4 active players × 7 + dealer × 7 = 35 cards dealt; 53 - 35 = 18 remaining
ok('4 active players + dealer = 35 cards dealt, 18 remaining', gs.deck.length === 53 - 35);
// Apply house way to AI
gs = applyHouseWaySplit(gs, 1);
ok('AI1 has highHand of 5', gs.players[1].highHand.length === 5);
ok('AI1 has lowHand of 2', gs.players[1].lowHand.length === 2);
ok('AI1 not foul', !gs.players[1].isFoul);
gs = applyDealerHouseWay(gs);
ok('dealer has highHand of 5', gs.dealer.highHand.length === 5);
ok('dealer has lowHand of 2', gs.dealer.lowHand.length === 2);
// Human sets hands (use house way for simplicity in test)
const hwHuman = houseWay(gs.players[0].hand);
gs = applyHumanSplit(gs, hwHuman.highHand, hwHuman.lowHand);
ok('human has highHand of 5', gs.players[0].highHand.length === 5);
ok('human not foul', !gs.players[0].isFoul);
// Apply house way to remaining AI seats too
for (let s = 2; s <= 5; s++) {
if (gs.players[s].active) gs = applyHouseWaySplit(gs, s);
}
gs = resolveRound(gs);
ok('resolveRound: phase = resolved', gs.phase === 'resolved');
ok('human has a result', ['win','push','lose','foul'].includes(gs.players[0].result));
// ── Resolution — chips math ───────────────────────────────────────────────────
console.log('\nResolution — chips math');
// Simulate a known win (human wins both hands)
{
let testGs = createInitialState([{ name: 'Bot' }], 1000);
testGs = prepareRound(testGs);
testGs = applyBet(testGs, 0, 100);
testGs = dealHands(testGs);
// Force-set human and dealer hands for a guaranteed win
const winHigh = [card('A','s'), card('A','h'), card('A','d'), card('K','s'), card('K','h')]; // FH
const winLow = [card('Q','s'), card('Q','h')]; // pair of Qs
const looseHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')]; // garbage
const looseLow = [card('4','s'), card('6','h')];
testGs = { ...testGs, players: testGs.players.map(p => p.seat === 0
? { ...p, highHand: winHigh, lowHand: winLow,
highEval: { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [14, 13] },
lowEval: { rank: 1, tiebreakers: [12] }, isFoul: false }
: p), };
testGs = { ...testGs, dealer: { ...testGs.dealer, highHand: looseHigh, lowHand: looseLow,
highEval: evaluate5Card(looseHigh), lowEval: evaluate2Card(looseLow) } };
testGs = applyHouseWaySplit(testGs, 1);
testGs = resolveRound(testGs);
ok('win result: chipsWon = 95 (100 - 5% commission)', testGs.players[0].chipsWon === 95);
ok('win result: chips increased', testGs.players[0].chips === 1095);
}
// Simulate a known loss
{
let testGs = createInitialState([{ name: 'Bot' }], 1000);
testGs = prepareRound(testGs);
testGs = applyBet(testGs, 0, 50);
testGs = dealHands(testGs);
const loseHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')];
const loseLow = [card('4','s'), card('6','h')];
const winHigh = [card('A','s'), card('A','h'), card('A','d'), card('K','s'), card('K','h')];
const winLow = [card('Q','s'), card('Q','h')];
testGs = { ...testGs, players: testGs.players.map(p => p.seat === 0
? { ...p, highHand: loseHigh, lowHand: loseLow,
highEval: evaluate5Card(loseHigh), lowEval: evaluate2Card(loseLow), isFoul: false }
: p) };
testGs = { ...testGs, dealer: { ...testGs.dealer, highHand: winHigh, lowHand: winLow,
highEval: { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [14, 13] },
lowEval: { rank: 1, tiebreakers: [12] } } };
testGs = applyHouseWaySplit(testGs, 1);
testGs = resolveRound(testGs);
ok('lose result: chipsWon = -50', testGs.players[0].chipsWon === -50);
ok('lose result: chips = 950', testGs.players[0].chips === 950);
}
// Push: player wins high, loses low
{
let testGs = createInitialState([{ name: 'Bot' }], 1000);
testGs = prepareRound(testGs);
testGs = applyBet(testGs, 0, 25);
testGs = dealHands(testGs);
const pHigh = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h')]; // FH KKK-QQ
const pLow = [card('3','s'), card('4','h')];
const dHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')]; // high card
const dLow = [card('A','s'), card('A','h')]; // pair of Aces
testGs = { ...testGs,
players: testGs.players.map(p => p.seat === 0
? { ...p, highHand: pHigh, lowHand: pLow, highEval: evaluate5Card(pHigh), lowEval: evaluate2Card(pLow), isFoul: false }
: p),
dealer: { ...testGs.dealer, highHand: dHigh, lowHand: dLow, highEval: evaluate5Card(dHigh), lowEval: evaluate2Card(dLow) }
};
testGs = applyHouseWaySplit(testGs, 1);
testGs = resolveRound(testGs);
ok('push result: chipsWon = 0', testGs.players[0].chipsWon === 0);
ok('push result: result = push', testGs.players[0].result === 'push');
}
// Foul auto-lose
{
let testGs = createInitialState([{ name: 'Bot' }], 1000);
testGs = prepareRound(testGs);
testGs = applyBet(testGs, 0, 40);
testGs = dealHands(testGs);
const foulHighHand = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')];
const foulLowHand = [card('A','h'), card('A','d')]; // pair Aces in low, but high hand is high card = foul
testGs = applyHumanSplit(testGs, foulHighHand, foulLowHand);
testGs = applyDealerHouseWay(testGs);
testGs = applyHouseWaySplit(testGs, 1);
testGs = resolveRound(testGs);
ok('foul: auto-lose', testGs.players[0].result === 'foul');
ok('foul: chipsWon = -40', testGs.players[0].chipsWon === -40);
}
// Copy rule: tied high hand → dealer wins (push if player wins low)
{
// Force both high hands to the same rank/tiebreakers
let testGs = createInitialState([{ name: 'Bot' }], 1000);
testGs = prepareRound(testGs);
testGs = applyBet(testGs, 0, 20);
testGs = dealHands(testGs);
const sameHighEval = { rank: HAND_RANK.FLUSH, name: 'Flush', tiebreakers: [14, 12, 10, 8, 6] };
const winLowEval = { rank: 1, tiebreakers: [13] }; // pair Kings
const loseLowEval = { rank: 0, tiebreakers: [12, 10] }; // Q-T high
testGs = { ...testGs,
players: testGs.players.map(p => p.seat === 0
? { ...p, highHand: [], lowHand: [], highEval: sameHighEval, lowEval: winLowEval, isFoul: false }
: p),
dealer: { ...testGs.dealer, highHand: [], lowHand: [], highEval: sameHighEval, lowEval: loseLowEval }
};
testGs = applyHouseWaySplit(testGs, 1);
testGs = resolveRound(testGs);
// Player wins low (pair K > Q-T), dealer wins high (tie → dealer). Push overall.
ok('copy rule: tied high + player wins low = push', testGs.players[0].result === 'push');
}
// ── AI bet ────────────────────────────────────────────────────────────────────
console.log('\nAI bet');
const aiPlayer = { chips: 500 };
const bets = Array.from({ length: 100 }, () => chooseBet(aiPlayer));
ok('chooseBet: all in range [5, 100]', bets.every(b => b >= 5 && b <= 100));
ok('chooseBet: never exceeds chips (500)', bets.every(b => b <= 500));
const poorPlayer = { chips: 7 };
const poorBets = Array.from({ length: 20 }, () => chooseBet(poorPlayer));
ok('chooseBet: never exceeds player chips (7)', poorBets.every(b => b <= 7));
// ── Deck coverage ─────────────────────────────────────────────────────────────
console.log('\nDeck coverage');
// 6 players + dealer × 7 = 49 cards ≤ 53
ok('max 5 AI opponents: 6 players + dealer = 49 cards dealt ≤ 53', 6 * 7 + 7 <= 53);
// ── Summary ────────────────────────────────────────────────────────────────────
console.log(`\n ${pass} passed, ${fail} failed\n`);
if (fail > 0) process.exit(1);