feat: add Pai Gow Poker game with AI opponents and hand-setting UI
- Implement complete Pai Gow Poker game using Phaser with drag-and-drop hand-setting panel for splitting 7 cards into 5-card high and 2-card low - Add pure logic module (PaiGowPokerLogic.js) with full hand evaluation supporting Joker substitution, 9 hand ranks including Five Aces, foul detection, house way splitting, and commission-based resolution - Add AI bet sizing module (PaiGowPokerAI.js) for opponent betting - Register game in server registry (casino category, 1-6 players) - Register scene in main.js and GameRoomScene slug dispatch - Add comprehensive test suite (verifyPaiGowPoker.js) covering deck, evaluation, comparison, foul detection, house way, state management, chip math (wins/losses/pushes/fouls/copy rule), and AI betting - Update StrategoGame to trigger portrait emotions on piece captures - Update game-icons sprite sheet
This commit is contained in:
parent
280169d40d
commit
0435ad7c38
Binary file not shown.
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 273 KiB |
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Pai Gow Poker AI — bet sizing + re-exports house way
|
||||||
|
|
||||||
|
export { houseWay } from './PaiGowPokerLogic.js';
|
||||||
|
|
||||||
|
export function chooseBet(player) {
|
||||||
|
const options = [5, 10, 15, 25];
|
||||||
|
const raw = options[Math.floor(Math.random() * options.length)];
|
||||||
|
return Math.min(raw, player.chips, 100);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,504 @@
|
||||||
|
// Pai Gow Poker pure game logic — no Phaser dependencies
|
||||||
|
|
||||||
|
import { SUITS, RANKS } from '../cards/Deck.js';
|
||||||
|
|
||||||
|
// ─── Card values ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2])); // 2=2…A=14
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
isJoker: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeJoker() {
|
||||||
|
return { rank: 'JK', suit: null, value: 15, label: '★', isRed: false, suitSymbol: '★', key: 'JK', isJoker: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeck() {
|
||||||
|
const cards = [];
|
||||||
|
for (const suit of SUITS) {
|
||||||
|
for (const rank of RANKS) {
|
||||||
|
cards.push(makeCard(rank, suit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards.push(makeJoker());
|
||||||
|
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 rank constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const HAND_RANK = {
|
||||||
|
HIGH_CARD: -1,
|
||||||
|
ONE_PAIR: 0,
|
||||||
|
TWO_PAIR: 1,
|
||||||
|
THREE_OF_A_KIND: 2,
|
||||||
|
STRAIGHT: 3,
|
||||||
|
FLUSH: 4,
|
||||||
|
FULL_HOUSE: 5,
|
||||||
|
FOUR_OF_A_KIND: 6,
|
||||||
|
STRAIGHT_FLUSH: 7,
|
||||||
|
ROYAL_FLUSH: 8,
|
||||||
|
FIVE_ACES: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function cardVal(c) { return c.isJoker ? 14 : c.value; }
|
||||||
|
|
||||||
|
export function rankLabel(val) {
|
||||||
|
const labels = { 14:'Ace', 13:'King', 12:'Queen', 11:'Jack', 10:'10',
|
||||||
|
9:'9', 8:'8', 7:'7', 6:'6', 5:'5', 4:'4', 3:'3', 2:'2' };
|
||||||
|
return labels[val] ?? String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCombinations(arr, k) {
|
||||||
|
if (k === arr.length) return [[...arr]];
|
||||||
|
if (k === 0) return [[]];
|
||||||
|
const [first, ...rest] = arr;
|
||||||
|
const withFirst = getCombinations(rest, k - 1).map(c => [first, ...c]);
|
||||||
|
const withoutFirst = getCombinations(rest, k);
|
||||||
|
return [...withFirst, ...withoutFirst];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups cards by effective rank value; sorted by group size desc, then value desc
|
||||||
|
function getGroups(cards) {
|
||||||
|
const map = {};
|
||||||
|
for (const c of cards) {
|
||||||
|
const v = cardVal(c);
|
||||||
|
if (!map[v]) map[v] = [];
|
||||||
|
map[v].push(c);
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([v, cs]) => ({ value: +v, cards: cs }))
|
||||||
|
.sort((a, b) => b.cards.length - a.cards.length || b.value - a.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStraight(sortedVals) {
|
||||||
|
const uniq = [...new Set(sortedVals)];
|
||||||
|
if (uniq.length < 5) return false;
|
||||||
|
if (uniq[0] - uniq[4] === 4) return uniq[0]; // returns high card value
|
||||||
|
// Wheel: A-2-3-4-5
|
||||||
|
if (uniq[0] === 14 && uniq[1] === 5 && uniq[2] === 4 && uniq[3] === 3 && uniq[4] === 2) return 5;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5-card evaluation (no Joker) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function evaluate5Pure(cards) {
|
||||||
|
const vals = cards.map(c => c.value).sort((a, b) => b - a);
|
||||||
|
const suits = cards.map(c => c.suit);
|
||||||
|
const isFlush = suits.every(s => s === suits[0]);
|
||||||
|
const straightHigh = checkStraight(vals);
|
||||||
|
|
||||||
|
if (isFlush && straightHigh) {
|
||||||
|
if (straightHigh === 14) return { rank: HAND_RANK.ROYAL_FLUSH, name: 'Royal Flush', tiebreakers: [14] };
|
||||||
|
return { rank: HAND_RANK.STRAIGHT_FLUSH, name: 'Straight Flush', tiebreakers: [straightHigh] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = getGroups(cards);
|
||||||
|
const counts = groups.map(g => g.cards.length);
|
||||||
|
|
||||||
|
if (counts[0] === 4) {
|
||||||
|
return { rank: HAND_RANK.FOUR_OF_A_KIND, name: 'Four of a Kind', tiebreakers: [groups[0].value, groups[1].value] };
|
||||||
|
}
|
||||||
|
if (counts[0] === 3 && counts[1] === 2) {
|
||||||
|
return { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [groups[0].value, groups[1].value] };
|
||||||
|
}
|
||||||
|
if (isFlush) {
|
||||||
|
return { rank: HAND_RANK.FLUSH, name: 'Flush', tiebreakers: vals };
|
||||||
|
}
|
||||||
|
if (straightHigh) {
|
||||||
|
return { rank: HAND_RANK.STRAIGHT, name: 'Straight', tiebreakers: [straightHigh] };
|
||||||
|
}
|
||||||
|
if (counts[0] === 3) {
|
||||||
|
return { rank: HAND_RANK.THREE_OF_A_KIND, name: 'Three of a Kind', tiebreakers: [groups[0].value, groups[1].value, groups[2].value] };
|
||||||
|
}
|
||||||
|
if (counts[0] === 2 && counts[1] === 2) {
|
||||||
|
return { rank: HAND_RANK.TWO_PAIR, name: 'Two Pair', tiebreakers: [groups[0].value, groups[1].value, groups[2].value] };
|
||||||
|
}
|
||||||
|
if (counts[0] === 2) {
|
||||||
|
return { rank: HAND_RANK.ONE_PAIR, name: 'One Pair', tiebreakers: [groups[0].value, groups[1].value, groups[2].value, groups[3].value] };
|
||||||
|
}
|
||||||
|
return { rank: HAND_RANK.HIGH_CARD, name: 'High Card', tiebreakers: vals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5-card evaluation (Joker aware) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function evaluate5Card(cards) {
|
||||||
|
const joker = cards.find(c => c.isJoker);
|
||||||
|
if (!joker) return evaluate5Pure(cards);
|
||||||
|
|
||||||
|
const others = cards.filter(c => !c.isJoker);
|
||||||
|
|
||||||
|
// Five Aces: 4 Aces + Joker
|
||||||
|
if (others.filter(c => c.rank === 'A').length === 4) {
|
||||||
|
return { rank: HAND_RANK.FIVE_ACES, name: 'Five Aces', tiebreakers: [15, 14, 14, 14, 14] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brute-force all possible Joker substitutions
|
||||||
|
const existingKeys = new Set(others.map(c => c.key));
|
||||||
|
let best = null;
|
||||||
|
|
||||||
|
for (const suit of SUITS) {
|
||||||
|
for (const rank of RANKS) {
|
||||||
|
const sub = makeCard(rank, suit);
|
||||||
|
if (existingKeys.has(sub.key)) continue;
|
||||||
|
const hand = [...others, sub];
|
||||||
|
const ev = evaluate5Pure(hand);
|
||||||
|
if (!best || compare5Card(ev, best) > 0) best = ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Joker as Ace of spades if no non-duplicate substitution works (rare)
|
||||||
|
return best ?? evaluate5Pure([...others, makeCard('A', 's')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handName5(ev) { return ev.name; }
|
||||||
|
|
||||||
|
// ─── 2-card evaluation (Joker always = Ace) ───────────────────────────────────
|
||||||
|
|
||||||
|
export function evaluate2Card(cards) {
|
||||||
|
const vals = cards.map(c => cardVal(c)).sort((a, b) => b - a);
|
||||||
|
if (vals[0] === vals[1]) {
|
||||||
|
return { rank: 1, name: `Pair of ${rankLabel(vals[0])}s`, tiebreakers: [vals[0]] };
|
||||||
|
}
|
||||||
|
return { rank: 0, name: `${rankLabel(vals[0])}-${rankLabel(vals[1])} High`, tiebreakers: vals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Comparison ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function compare5Card(a, b) {
|
||||||
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||||
|
for (let i = 0; i < Math.max(a.tiebreakers.length, b.tiebreakers.length); i++) {
|
||||||
|
const av = a.tiebreakers[i] ?? 0;
|
||||||
|
const bv = b.tiebreakers[i] ?? 0;
|
||||||
|
if (av !== bv) return av - bv;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compare2Card(a, b) {
|
||||||
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||||
|
for (let i = 0; i < Math.max(a.tiebreakers.length, b.tiebreakers.length); i++) {
|
||||||
|
const av = a.tiebreakers[i] ?? 0;
|
||||||
|
const bv = b.tiebreakers[i] ?? 0;
|
||||||
|
if (av !== bv) return av - bv;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Foul detection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function isFoul(highHand5, lowHand2) {
|
||||||
|
const high = evaluate5Card(highHand5);
|
||||||
|
const low = evaluate2Card(lowHand2);
|
||||||
|
|
||||||
|
// Any 5-card rank above ONE_PAIR always beats any 2-card hand
|
||||||
|
if (high.rank > HAND_RANK.ONE_PAIR) return false;
|
||||||
|
|
||||||
|
if (high.rank === HAND_RANK.ONE_PAIR) {
|
||||||
|
// 5-card pair vs 2-card high card: always valid
|
||||||
|
if (low.rank === 0) return false;
|
||||||
|
// Both pairs: foul only if 5-card pair rank is strictly less (equal is OK — kickers decide)
|
||||||
|
return high.tiebreakers[0] < low.tiebreakers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-card HIGH_CARD: foul if low hand has a pair, or if 5-card top card < 2-card top card
|
||||||
|
if (low.rank === 1) return true; // pair beats high card
|
||||||
|
const highTop2 = high.tiebreakers.slice(0, 2);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
if (highTop2[i] > (low.tiebreakers[i] ?? 0)) return false;
|
||||||
|
if (highTop2[i] < (low.tiebreakers[i] ?? 0)) return true;
|
||||||
|
}
|
||||||
|
return false; // equal top 2: not foul (5-card wins on kickers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── House Way ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sortByValDesc(cards) {
|
||||||
|
return [...cards].sort((a, b) => cardVal(b) - cardVal(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestFiveCombo(cards) {
|
||||||
|
const combos = getCombinations(cards, 5);
|
||||||
|
let bestEval = null, bestCards = null;
|
||||||
|
for (const c of combos) {
|
||||||
|
const ev = evaluate5Card(c);
|
||||||
|
if (!bestEval || compare5Card(ev, bestEval) > 0) { bestEval = ev; bestCards = c; }
|
||||||
|
}
|
||||||
|
return { eval: bestEval, cards: bestCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function houseWay(sevenCards) {
|
||||||
|
// Five Aces: keep in high
|
||||||
|
const joker = sevenCards.find(c => c.isJoker);
|
||||||
|
const aces = sevenCards.filter(c => c.rank === 'A');
|
||||||
|
if (joker && aces.length >= 4) {
|
||||||
|
const high5 = [...aces.slice(0, 4), joker];
|
||||||
|
const low2 = sevenCards.filter(c => !high5.includes(c));
|
||||||
|
return { highHand: high5, lowHand: low2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = getGroups(sevenCards);
|
||||||
|
const numPairs = groups.filter(g => g.cards.length === 2).length;
|
||||||
|
const numTrips = groups.filter(g => g.cards.length === 3).length;
|
||||||
|
const numQuads = groups.filter(g => g.cards.length === 4).length;
|
||||||
|
|
||||||
|
const { eval: bestEval, cards: bestCombo } = bestFiveCombo(sevenCards);
|
||||||
|
const lowCards = sevenCards.filter(c => !bestCombo.includes(c));
|
||||||
|
|
||||||
|
// Straight flush / royal flush: keep in high
|
||||||
|
if (bestEval.rank >= HAND_RANK.STRAIGHT_FLUSH) {
|
||||||
|
return { highHand: bestCombo, lowHand: lowCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four of a kind
|
||||||
|
if (numQuads > 0) {
|
||||||
|
const quadGroup = groups.find(g => g.cards.length >= 4);
|
||||||
|
const quads = quadGroup.cards;
|
||||||
|
const qVal = quadGroup.value;
|
||||||
|
const rest = sortByValDesc(sevenCards.filter(c => !quads.includes(c)));
|
||||||
|
|
||||||
|
// 2s–6s: keep quads together
|
||||||
|
if (qVal <= 6) return { highHand: [...quads, rest[0]], lowHand: [rest[1], rest[2]] };
|
||||||
|
|
||||||
|
// 7s–10s: split only if Ace-equivalent available for low
|
||||||
|
if (qVal <= 10) {
|
||||||
|
const hasAce = rest.some(c => cardVal(c) >= 14);
|
||||||
|
if (!hasAce) return { highHand: [...quads, rest[0]], lowHand: [rest[1], rest[2]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// JJ–AA (and 7-10 with Ace): split into pair+pair
|
||||||
|
return { highHand: [quads[0], quads[1], ...rest], lowHand: [quads[2], quads[3]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full house
|
||||||
|
if (bestEval.rank === HAND_RANK.FULL_HOUSE) {
|
||||||
|
const tripsGroup = groups.find(g => g.cards.length >= 3);
|
||||||
|
const pairGroup = groups.find(g => g !== tripsGroup && g.cards.length >= 2);
|
||||||
|
const trips3 = tripsGroup.cards.slice(0, 3);
|
||||||
|
const pair2 = pairGroup.cards.slice(0, 2);
|
||||||
|
const extras = sevenCards.filter(c => !trips3.includes(c) && !pair2.includes(c));
|
||||||
|
return { highHand: [...trips3, ...extras], lowHand: pair2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush (no sf) — keep flush in high
|
||||||
|
if (bestEval.rank === HAND_RANK.FLUSH) {
|
||||||
|
return { highHand: bestCombo, lowHand: lowCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Straight (no flush) — keep straight in high
|
||||||
|
if (bestEval.rank === HAND_RANK.STRAIGHT) {
|
||||||
|
return { highHand: bestCombo, lowHand: lowCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three of a kind
|
||||||
|
if (numTrips > 0 && numPairs === 0) {
|
||||||
|
const tripsGroup = groups.find(g => g.cards.length >= 3);
|
||||||
|
const trips3 = tripsGroup.cards.slice(0, 3);
|
||||||
|
|
||||||
|
// Three Aces: pair in high, one Ace in low
|
||||||
|
if (tripsGroup.value === 14) {
|
||||||
|
const rest = sortByValDesc(sevenCards.filter(c => !trips3.includes(c)));
|
||||||
|
return { highHand: [trips3[0], trips3[1], ...rest.slice(0, 3)], lowHand: [trips3[2], rest[3]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other trips: trips in high, 2 best singletons in low
|
||||||
|
const rest = sortByValDesc(sevenCards.filter(c => !trips3.includes(c)));
|
||||||
|
return { highHand: [...trips3, ...rest.slice(2)], lowHand: [rest[0], rest[1]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three pair
|
||||||
|
const pairGroups = groups.filter(g => g.cards.length >= 2).slice(0, 3);
|
||||||
|
if (pairGroups.length >= 3) {
|
||||||
|
// Highest pair → low hand; other two pairs + singleton in high
|
||||||
|
const highestPair = pairGroups[0]; // already sorted by value desc
|
||||||
|
const lowPairCards = highestPair.cards.slice(0, 2);
|
||||||
|
const remainPairs = sevenCards.filter(c => !lowPairCards.includes(c));
|
||||||
|
const { eval: bestRemEval, cards: bestRemCombo } = bestFiveCombo(remainPairs);
|
||||||
|
return { highHand: bestRemCombo, lowHand: lowPairCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two pair
|
||||||
|
if (numPairs >= 2) {
|
||||||
|
const pg1 = pairGroups[0]; // higher pair
|
||||||
|
const pg2 = pairGroups[1]; // lower pair
|
||||||
|
const p1Cards = pg1.cards.slice(0, 2);
|
||||||
|
const p2Cards = pg2.cards.slice(0, 2);
|
||||||
|
const singletons = sortByValDesc(sevenCards.filter(c => !p1Cards.includes(c) && !p2Cards.includes(c)));
|
||||||
|
|
||||||
|
if (pg1.value >= 11) {
|
||||||
|
// JJ+ high pair: split — HIGH pair → HIGH 5-card hand, LOW pair → LOW 2-card hand
|
||||||
|
return { highHand: [...p1Cards, ...singletons], lowHand: p2Cards };
|
||||||
|
}
|
||||||
|
// Both pairs ≤ 10: keep together in high, 2 best singletons in low
|
||||||
|
return { highHand: [...p1Cards, ...p2Cards, singletons[2]], lowHand: [singletons[0], singletons[1]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One pair
|
||||||
|
if (numPairs === 1) {
|
||||||
|
const pg = pairGroups[0];
|
||||||
|
const pCards = pg.cards.slice(0, 2);
|
||||||
|
const singletons = sortByValDesc(sevenCards.filter(c => !pCards.includes(c)));
|
||||||
|
// 2 best singletons in low
|
||||||
|
return { highHand: [...pCards, ...singletons.slice(2)], lowHand: [singletons[0], singletons[1]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No pair (HIGH_CARD): 2nd and 3rd best in low
|
||||||
|
const sorted = sortByValDesc(sevenCards);
|
||||||
|
return { highHand: [sorted[0], sorted[3], sorted[4], sorted[5], sorted[6]], lowHand: [sorted[1], sorted[2]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State creation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createInitialState(opponents, chips) {
|
||||||
|
const players = [
|
||||||
|
{
|
||||||
|
seat: 0, name: 'You', isHuman: true, active: true, opponent: null,
|
||||||
|
chips, bet: 0,
|
||||||
|
hand: [], highHand: [], lowHand: [],
|
||||||
|
highEval: null, lowEval: null,
|
||||||
|
isFoul: false, result: null, chipsWon: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const opp = opponents[i] ?? null;
|
||||||
|
players.push({
|
||||||
|
seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp,
|
||||||
|
chips: 1000, bet: 0,
|
||||||
|
hand: [], highHand: [], lowHand: [],
|
||||||
|
highEval: null, lowEval: null,
|
||||||
|
isFoul: false, result: null, chipsWon: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: 'betting',
|
||||||
|
deck: [],
|
||||||
|
players,
|
||||||
|
dealer: { hand: [], highHand: [], lowHand: [], highEval: null, lowEval: null, revealed: false },
|
||||||
|
roundNumber: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Round management ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function prepareRound(gs) {
|
||||||
|
const deck = buildDeck();
|
||||||
|
const players = gs.players.map(p => ({
|
||||||
|
...p,
|
||||||
|
bet: 0, hand: [], highHand: [], lowHand: [],
|
||||||
|
highEval: null, lowEval: null,
|
||||||
|
isFoul: false, result: null, chipsWon: 0,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...gs,
|
||||||
|
phase: 'betting',
|
||||||
|
deck,
|
||||||
|
players,
|
||||||
|
dealer: { hand: [], highHand: [], lowHand: [], highEval: null, lowEval: null, revealed: 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dealHands(gs) {
|
||||||
|
const deck = [...gs.deck];
|
||||||
|
const players = gs.players.map(p => {
|
||||||
|
if (!p.active) return p;
|
||||||
|
const hand = deck.splice(0, 7);
|
||||||
|
return { ...p, hand };
|
||||||
|
});
|
||||||
|
const dealerHand = deck.splice(0, 7);
|
||||||
|
return {
|
||||||
|
...gs,
|
||||||
|
phase: 'setting',
|
||||||
|
deck,
|
||||||
|
players,
|
||||||
|
dealer: { ...gs.dealer, hand: dealerHand },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHumanSplit(gs, highHand, lowHand) {
|
||||||
|
const foul = isFoul(highHand, lowHand);
|
||||||
|
const highEval = foul ? null : evaluate5Card(highHand);
|
||||||
|
const lowEval = foul ? null : evaluate2Card(lowHand);
|
||||||
|
const players = gs.players.map(p => {
|
||||||
|
if (p.seat !== 0) return p;
|
||||||
|
return { ...p, highHand, lowHand, highEval, lowEval, isFoul: foul };
|
||||||
|
});
|
||||||
|
return { ...gs, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHouseWaySplit(gs, seat) {
|
||||||
|
const player = gs.players.find(p => p.seat === seat);
|
||||||
|
if (!player || !player.active) return gs;
|
||||||
|
const { highHand, lowHand } = houseWay(player.hand);
|
||||||
|
const highEval = evaluate5Card(highHand);
|
||||||
|
const lowEval = evaluate2Card(lowHand);
|
||||||
|
const players = gs.players.map(p => p.seat === seat ? { ...p, highHand, lowHand, highEval, lowEval, isFoul: false } : p);
|
||||||
|
return { ...gs, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDealerHouseWay(gs) {
|
||||||
|
const { highHand, lowHand } = houseWay(gs.dealer.hand);
|
||||||
|
const highEval = evaluate5Card(highHand);
|
||||||
|
const lowEval = evaluate2Card(lowHand);
|
||||||
|
return { ...gs, dealer: { ...gs.dealer, highHand, lowHand, highEval, lowEval } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolution ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns 'win' | 'push' | 'lose'. Ties go to dealer (copy rule).
|
||||||
|
function resolvePlayer(player, dealer) {
|
||||||
|
if (player.isFoul) return 'foul';
|
||||||
|
|
||||||
|
const highCmp = compare5Card(player.highEval, dealer.highEval);
|
||||||
|
const lowCmp = compare2Card(player.lowEval, dealer.lowEval);
|
||||||
|
|
||||||
|
// copy rule: ties go to dealer (cmp <= 0 means dealer wins or ties)
|
||||||
|
const winHigh = highCmp > 0;
|
||||||
|
const winLow = lowCmp > 0;
|
||||||
|
|
||||||
|
if (winHigh && winLow) return 'win';
|
||||||
|
if (!winHigh && !winLow) return 'lose';
|
||||||
|
return 'push';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRound(gs) {
|
||||||
|
const dealer = gs.dealer;
|
||||||
|
const COMMISSION = 0.05;
|
||||||
|
|
||||||
|
const players = gs.players.map(p => {
|
||||||
|
if (!p.active || p.bet === 0) return p;
|
||||||
|
|
||||||
|
const result = resolvePlayer(p, dealer);
|
||||||
|
let chipsWon = 0;
|
||||||
|
if (result === 'win') {
|
||||||
|
chipsWon = p.bet - Math.floor(p.bet * COMMISSION);
|
||||||
|
} else if (result === 'lose' || result === 'foul') {
|
||||||
|
chipsWon = -p.bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...p, result, chipsWon, chips: p.chips + chipsWon };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...gs, phase: 'resolved', players };
|
||||||
|
}
|
||||||
|
|
@ -747,6 +747,19 @@ export default class StrategoGame extends Phaser.Scene {
|
||||||
// Map result to containers.
|
// Map result to containers.
|
||||||
const attackerIsHuman = mover.owner === this.humanSeat;
|
const attackerIsHuman = mover.owner === this.humanSeat;
|
||||||
let winnerCont, loserCont, winnerTxt, loserTxt;
|
let winnerCont, loserCont, winnerTxt, loserTxt;
|
||||||
|
|
||||||
|
// Portrait emotion: AI upset when it loses a high-value piece; happy when it captures one.
|
||||||
|
const REACT_RANK = 6;
|
||||||
|
const aiLosesPiece = res === 'both' ||
|
||||||
|
(res === 'attacker' && attackerIsHuman) ||
|
||||||
|
(res === 'defender' && !attackerIsHuman);
|
||||||
|
const aiCapturesPiece = (res === 'attacker' && !attackerIsHuman) ||
|
||||||
|
(res === 'defender' && attackerIsHuman);
|
||||||
|
const triggerEmotion = () => {
|
||||||
|
const ctrl = this.portraits[this.aiSeat];
|
||||||
|
if (aiLosesPiece && aiPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('upset');
|
||||||
|
else if (aiCapturesPiece && humanPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('happy');
|
||||||
|
};
|
||||||
if (res === 'attacker') {
|
if (res === 'attacker') {
|
||||||
[winnerCont, loserCont] = attackerIsHuman ? [humanCont, aiCont] : [aiCont, humanCont];
|
[winnerCont, loserCont] = attackerIsHuman ? [humanCont, aiCont] : [aiCont, humanCont];
|
||||||
[winnerTxt, loserTxt] = attackerIsHuman ? [humanRankTxt, aiRankTxt] : [aiRankTxt, humanRankTxt];
|
[winnerTxt, loserTxt] = attackerIsHuman ? [humanRankTxt, aiRankTxt] : [aiRankTxt, humanRankTxt];
|
||||||
|
|
@ -805,13 +818,16 @@ export default class StrategoGame extends Phaser.Scene {
|
||||||
|
|
||||||
if (res === 'both') {
|
if (res === 'both') {
|
||||||
// Tie: both shoot simultaneously, both explode/fade.
|
// Tie: both shoot simultaneously, both explode/fade.
|
||||||
|
let emotionFired = false;
|
||||||
fireShot(humanStageX, STAGE_CY, aiStageX, STAGE_CY, () => {
|
fireShot(humanStageX, STAGE_CY, aiStageX, STAGE_CY, () => {
|
||||||
this._spawnExplosions(aiStageX, STAGE_CY);
|
this._spawnExplosions(aiStageX, STAGE_CY);
|
||||||
fadeOut(aiCont, aiRankTxt);
|
fadeOut(aiCont, aiRankTxt);
|
||||||
|
if (!emotionFired) { emotionFired = true; triggerEmotion(); }
|
||||||
});
|
});
|
||||||
fireShot(aiStageX, STAGE_CY, humanStageX, STAGE_CY, () => {
|
fireShot(aiStageX, STAGE_CY, humanStageX, STAGE_CY, () => {
|
||||||
this._spawnExplosions(humanStageX, STAGE_CY);
|
this._spawnExplosions(humanStageX, STAGE_CY);
|
||||||
fadeOut(humanCont, humanRankTxt);
|
fadeOut(humanCont, humanRankTxt);
|
||||||
|
if (!emotionFired) { emotionFired = true; triggerEmotion(); }
|
||||||
});
|
});
|
||||||
// After both explosions settle, fade outcome label and undim.
|
// After both explosions settle, fade outcome label and undim.
|
||||||
this.time.delayedCall(2700, () => {
|
this.time.delayedCall(2700, () => {
|
||||||
|
|
@ -828,6 +844,7 @@ export default class StrategoGame extends Phaser.Scene {
|
||||||
fireShot(winnerX, STAGE_CY, loserX, STAGE_CY, () => {
|
fireShot(winnerX, STAGE_CY, loserX, STAGE_CY, () => {
|
||||||
this._spawnExplosions(loserX, STAGE_CY);
|
this._spawnExplosions(loserX, STAGE_CY);
|
||||||
fadeOut(loserCont, loserTxt);
|
fadeOut(loserCont, loserTxt);
|
||||||
|
triggerEmotion();
|
||||||
finishWinner();
|
finishWinner();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import RiskGame from './games/risk/RiskGame.js';
|
||||||
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
|
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
|
||||||
import KataminoGame from './games/katamino/KataminoGame.js';
|
import KataminoGame from './games/katamino/KataminoGame.js';
|
||||||
import BookworkGame from './games/bookwork/BookworkGame.js';
|
import BookworkGame from './games/bookwork/BookworkGame.js';
|
||||||
|
import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -179,6 +180,7 @@ const config = {
|
||||||
GeniusSquareGame,
|
GeniusSquareGame,
|
||||||
KataminoGame,
|
KataminoGame,
|
||||||
BookworkGame,
|
BookworkGame,
|
||||||
|
PaiGowPokerGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,4 @@ registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2,
|
||||||
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
||||||
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
||||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||||
|
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,486 @@
|
||||||
|
#!/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 './public/src/games/paigow/PaiGowPokerLogic.js';
|
||||||
|
import { chooseBet } from './public/src/games/paigow/PaiGowPokerAI.js';
|
||||||
|
import { SUITS, RANKS } from './public/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);
|
||||||
Loading…
Reference in New Issue