feat(SCOPONE-0008): complete iteration 0 improve ai rules
This commit is contained in:
339
src/game/ai.ts
339
src/game/ai.ts
@@ -7,6 +7,27 @@ export interface AIMove {
|
||||
capture: Card[];
|
||||
}
|
||||
|
||||
export interface AIDecisionProgress {
|
||||
difficulty: Difficulty;
|
||||
progress: number;
|
||||
elapsedMs: number;
|
||||
budgetMs: number;
|
||||
batchesCompleted: number;
|
||||
}
|
||||
|
||||
interface SearchProfile {
|
||||
timeBudgetMs: number;
|
||||
sampleCount: number;
|
||||
maxDepth: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
const SEARCH_PROFILES: Record<Difficulty, SearchProfile> = {
|
||||
beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 },
|
||||
advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 },
|
||||
master: { timeBudgetMs: 9800, sampleCount: 12, maxDepth: 6, batchSize: 2 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers shared across all difficulty levels
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -51,28 +72,78 @@ function countValueInHand(hand: Card[], value: number): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
function getSearchProfile(state: GameState, difficulty: Difficulty): SearchProfile {
|
||||
if (difficulty !== 'master') return SEARCH_PROFILES[difficulty];
|
||||
|
||||
const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
|
||||
if (cardsRemaining <= 6) {
|
||||
return { timeBudgetMs: 9800, sampleCount: 18, maxDepth: Math.min(cardsRemaining, 8), batchSize: 1 };
|
||||
}
|
||||
if (cardsRemaining <= 12) {
|
||||
return { timeBudgetMs: 9000, sampleCount: 16, maxDepth: 8, batchSize: 2 };
|
||||
}
|
||||
if (cardsRemaining <= 20) {
|
||||
return { timeBudgetMs: 8200, sampleCount: 14, maxDepth: 7, batchSize: 2 };
|
||||
}
|
||||
return SEARCH_PROFILES.master;
|
||||
}
|
||||
|
||||
function reportDecisionProgress(
|
||||
onProgress: ((progress: AIDecisionProgress) => void) | undefined,
|
||||
difficulty: Difficulty,
|
||||
startedAt: number,
|
||||
budgetMs: number,
|
||||
progress: number,
|
||||
batchesCompleted: number,
|
||||
): void {
|
||||
if (!onProgress) return;
|
||||
|
||||
onProgress({
|
||||
difficulty,
|
||||
progress: Math.max(0, Math.min(1, progress)),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
budgetMs,
|
||||
batchesCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
function yieldToBrowser(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function handLikelyHasValue(
|
||||
value: number,
|
||||
handSize: number,
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
tracker: CardTracker | undefined,
|
||||
myHand: Card[],
|
||||
table: Card[],
|
||||
): number {
|
||||
if (handSize <= 0) return 0;
|
||||
|
||||
if (tracker) {
|
||||
return tracker.probabilityHandHasValue(value, handSize, myHand, table);
|
||||
}
|
||||
|
||||
const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker);
|
||||
let unseenWithValue = 0;
|
||||
for (const card of unseen) {
|
||||
if (card.value === value) unseenWithValue++;
|
||||
}
|
||||
|
||||
if (unseenWithValue === 0 || unseen.length === 0) return 0;
|
||||
const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize);
|
||||
return 1 - probNone;
|
||||
}
|
||||
|
||||
/** Check if partner likely holds a card of given value (via tracker inference) */
|
||||
function partnerLikelyHolds(
|
||||
value: number, playerIdx: PlayerIndex, state: GameState,
|
||||
tracker: CardTracker | undefined, myHand: Card[], table: Card[],
|
||||
): number {
|
||||
const partner = partnerOf(playerIdx);
|
||||
const partnerHandSize = state.players[partner].hand.length;
|
||||
if (partnerHandSize === 0) return 0;
|
||||
|
||||
const unseen = tracker
|
||||
? tracker.getUnseenCards(myHand, table)
|
||||
: getUnseenWithoutTracker(state, playerIdx);
|
||||
|
||||
let unseenWithValue = 0;
|
||||
for (const c of unseen) if (c.value === value) unseenWithValue++;
|
||||
if (unseenWithValue === 0) return 0;
|
||||
|
||||
// P(partner has ≥1 card of this value) ≈ 1 - hypergeometric(0 drawn)
|
||||
const totalUnseen = unseen.length;
|
||||
if (totalUnseen === 0) return 0;
|
||||
const probNone = hypergeometricNone(totalUnseen, unseenWithValue, partnerHandSize);
|
||||
return 1 - probNone;
|
||||
return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table);
|
||||
}
|
||||
|
||||
/** Race state: who's winning each scoring category */
|
||||
@@ -196,16 +267,30 @@ function hypergeometricNone(total: number, threats: number, drawn: number): numb
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function chooseMove(
|
||||
export async function chooseMove(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
difficulty: Difficulty = 'advanced',
|
||||
tracker?: CardTracker,
|
||||
): AIMove {
|
||||
onProgress?: (progress: AIDecisionProgress) => void,
|
||||
): Promise<AIMove> {
|
||||
const startedAt = Date.now();
|
||||
const profile = getSearchProfile(state, difficulty);
|
||||
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 0, 0);
|
||||
|
||||
switch (difficulty) {
|
||||
case 'beginner': return beginnerMove(state, playerIdx, tracker);
|
||||
case 'advanced': return advancedMove(state, playerIdx, tracker);
|
||||
case 'master': return masterMove(state, playerIdx, tracker);
|
||||
case 'beginner': {
|
||||
const move = beginnerMove(state, playerIdx, tracker);
|
||||
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1);
|
||||
return move;
|
||||
}
|
||||
case 'advanced': {
|
||||
const move = advancedMove(state, playerIdx, tracker);
|
||||
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1);
|
||||
return move;
|
||||
}
|
||||
case 'master':
|
||||
return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,27 +737,86 @@ function scoreDumpAdv(
|
||||
// improved evaluation, team-aware search, last-play awareness
|
||||
// ===========================================================================
|
||||
|
||||
function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
|
||||
function tableControlPressure(
|
||||
afterTable: Card[],
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
tracker: CardTracker | undefined,
|
||||
myHand: Card[],
|
||||
race: RaceState,
|
||||
): number {
|
||||
if (afterTable.length === 0) return 0;
|
||||
|
||||
let score = 0;
|
||||
const next = nextPlayer(playerIdx);
|
||||
const partner = partnerOf(playerIdx);
|
||||
const nextHandSize = state.players[next].hand.length;
|
||||
const partnerHandSize = state.players[partner].hand.length;
|
||||
const nextIsOpp = isOpponent(playerIdx, next);
|
||||
const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0);
|
||||
|
||||
if (tableSum >= 11) score += 70;
|
||||
if (tableSum <= 10 && nextIsOpp) score -= 110;
|
||||
if (race.behindInDenari && afterTable.some(card => card.suit === 'denara')) score += 35;
|
||||
if (race.need7s && afterTable.some(card => card.value === 7)) score += 45;
|
||||
|
||||
for (const tableCard of afterTable) {
|
||||
const myAnchors = countValueInHand(myHand, tableCard.value);
|
||||
if (myAnchors > 0) score += myAnchors * 18;
|
||||
|
||||
const partnerProb = handLikelyHasValue(
|
||||
tableCard.value,
|
||||
partnerHandSize,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
myHand,
|
||||
afterTable,
|
||||
);
|
||||
score += partnerProb * (nextIsOpp ? 20 : 55);
|
||||
|
||||
if (nextHandSize > 0 && nextIsOpp) {
|
||||
const nextProb = handLikelyHasValue(
|
||||
tableCard.value,
|
||||
nextHandSize,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
myHand,
|
||||
afterTable,
|
||||
);
|
||||
score -= nextProb * 80;
|
||||
}
|
||||
}
|
||||
|
||||
if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60;
|
||||
return score;
|
||||
}
|
||||
|
||||
async function masterMove(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
tracker: CardTracker | undefined,
|
||||
onProgress: ((progress: AIDecisionProgress) => void) | undefined,
|
||||
profile: SearchProfile,
|
||||
startedAt: number,
|
||||
): Promise<AIMove> {
|
||||
const myTeam = teamOf(playerIdx);
|
||||
const phase = gamePhase(state);
|
||||
const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0);
|
||||
|
||||
const isDeepEndgame = cardsRemaining <= 6;
|
||||
const isEndgame = cardsRemaining <= 12;
|
||||
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 14 : 10;
|
||||
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 8 : 6;
|
||||
|
||||
const legalMoves = getLegalMoves(state, playerIdx);
|
||||
if (legalMoves.length === 1) return legalMoves[0];
|
||||
if (legalMoves.length === 1) {
|
||||
reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, 1);
|
||||
return legalMoves[0];
|
||||
}
|
||||
|
||||
// Time budget: 1.5 seconds max
|
||||
const deadline = Date.now() + 1500;
|
||||
const deadline = startedAt + profile.timeBudgetMs;
|
||||
|
||||
// Quick-eval move ordering for better pruning
|
||||
const lastPlay = isLastPlay(state, playerIdx);
|
||||
const race = getRaceState(state, playerIdx);
|
||||
const quickScored = legalMoves.map(m => ({
|
||||
move: m,
|
||||
quick: quickEval(m, state, playerIdx, tracker, lastPlay),
|
||||
quick: quickEval(m, state, playerIdx, tracker, lastPlay, race),
|
||||
}));
|
||||
quickScored.sort((a, b) => b.quick - a.quick);
|
||||
const sortedMoves = quickScored.map(qs => qs.move);
|
||||
@@ -680,26 +824,56 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
||||
const moveScores = new Map<string, number>();
|
||||
for (const m of sortedMoves) moveScores.set(moveKey(m), 0);
|
||||
|
||||
// Deep endgame: use actual state (perfect info), otherwise sample
|
||||
const samples = isDeepEndgame
|
||||
? [state]
|
||||
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
||||
const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount);
|
||||
|
||||
let timedOut = false;
|
||||
let samplesCompleted = 0;
|
||||
let batchesCompleted = 0;
|
||||
let evaluationsCompleted = 0;
|
||||
const totalEvaluations = Math.max(1, samples.length * sortedMoves.length);
|
||||
|
||||
for (const sample of samples) {
|
||||
if (timedOut) break;
|
||||
for (const move of sortedMoves) {
|
||||
if (Date.now() > deadline) { timedOut = true; break; }
|
||||
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||
const score = alphaBeta(
|
||||
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity,
|
||||
myTeam, playerIdx, phase, deadline,
|
||||
);
|
||||
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
||||
for (let start = 0; start < samples.length; start += profile.batchSize) {
|
||||
if (timedOut || Date.now() > deadline) break;
|
||||
|
||||
const batch = samples.slice(start, start + profile.batchSize);
|
||||
for (const sample of batch) {
|
||||
for (const move of sortedMoves) {
|
||||
if (Date.now() > deadline) {
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||
const score = alphaBeta(
|
||||
result.nextState,
|
||||
profile.maxDepth - 1,
|
||||
-Infinity,
|
||||
Infinity,
|
||||
myTeam,
|
||||
playerIdx,
|
||||
phase,
|
||||
deadline,
|
||||
tracker,
|
||||
);
|
||||
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
||||
evaluationsCompleted++;
|
||||
}
|
||||
|
||||
if (timedOut) break;
|
||||
}
|
||||
|
||||
batchesCompleted++;
|
||||
reportDecisionProgress(
|
||||
onProgress,
|
||||
'master',
|
||||
startedAt,
|
||||
profile.timeBudgetMs,
|
||||
Math.max(evaluationsCompleted / totalEvaluations, Math.min(1, (Date.now() - startedAt) / profile.timeBudgetMs)),
|
||||
batchesCompleted,
|
||||
);
|
||||
|
||||
if (!timedOut && start + profile.batchSize < samples.length && Date.now() < deadline) {
|
||||
await yieldToBrowser();
|
||||
}
|
||||
samplesCompleted++;
|
||||
}
|
||||
|
||||
let bestMove = sortedMoves[0];
|
||||
@@ -709,12 +883,14 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
||||
if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; }
|
||||
}
|
||||
|
||||
reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, batchesCompleted);
|
||||
return bestMove;
|
||||
}
|
||||
|
||||
function quickEval(
|
||||
move: AIMove, state: GameState, playerIdx: PlayerIndex,
|
||||
tracker: CardTracker | undefined, lastPlay: boolean,
|
||||
race: RaceState,
|
||||
): number {
|
||||
let score = 0;
|
||||
const table = state.table;
|
||||
@@ -731,9 +907,9 @@ function quickEval(
|
||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900;
|
||||
if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000;
|
||||
|
||||
score += move.capture.length * 65;
|
||||
score += allCaptured.filter(c => c.suit === 'denara').length * 100;
|
||||
score += allCaptured.filter(c => c.value === 7).length * 80;
|
||||
score += move.capture.length * (race.behindInCards ? 75 : 55);
|
||||
score += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 135 : 95);
|
||||
score += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 110 : 75);
|
||||
for (const c of allCaptured) score += primieraVal(c) * 2.5;
|
||||
|
||||
if (move.capture.length === 0) {
|
||||
@@ -762,6 +938,8 @@ function quickEval(
|
||||
if (sum >= 1 && sum <= 10) score += 40; // partner might scopa
|
||||
}
|
||||
|
||||
score += tableControlPressure(afterTable, state, playerIdx, tracker, state.players[playerIdx].hand, race);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
@@ -810,12 +988,37 @@ function generateSamples(
|
||||
}
|
||||
|
||||
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
|
||||
return getUnseenCardsForEstimate(
|
||||
state,
|
||||
playerIdx,
|
||||
state.players[playerIdx].hand,
|
||||
state.table,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function getUnseenCardsForEstimate(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
myHand: Card[],
|
||||
table: Card[],
|
||||
tracker: CardTracker | undefined,
|
||||
): Card[] {
|
||||
if (tracker) {
|
||||
return tracker.getUnseenCards(myHand, table);
|
||||
}
|
||||
|
||||
const known = new Set<string>();
|
||||
for (const c of state.players[playerIdx].hand) known.add(c.id);
|
||||
for (const c of state.table) known.add(c.id);
|
||||
for (const p of state.players) { for (const c of p.pile) known.add(c.id); }
|
||||
for (const card of myHand) known.add(card.id);
|
||||
for (const card of table) known.add(card.id);
|
||||
for (const player of state.players) {
|
||||
for (const card of player.pile) {
|
||||
known.add(card.id);
|
||||
}
|
||||
}
|
||||
|
||||
const deck = buildDeck();
|
||||
return deck.filter(c => !known.has(c.id));
|
||||
return deck.filter(card => !known.has(card.id));
|
||||
}
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
@@ -830,6 +1033,7 @@ function alphaBeta(
|
||||
state: GameState, depth: number, alpha: number, beta: number,
|
||||
myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
||||
phase: number, deadline: number,
|
||||
tracker: CardTracker | undefined,
|
||||
): number {
|
||||
if (depth === 0 || state.roundOver || Date.now() > deadline) {
|
||||
return evaluateFast(state, myTeam, phase);
|
||||
@@ -843,29 +1047,16 @@ function alphaBeta(
|
||||
|
||||
// Move ordering: settebello captures first, then scopa, then captures by size, then dumps
|
||||
if (moves.length > 2) {
|
||||
moves.sort((a, b) => {
|
||||
const aSettebello = a.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
|
||||
const bSettebello = b.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
|
||||
if (aSettebello !== bSettebello) return bSettebello - aSettebello;
|
||||
|
||||
// Scopa moves first
|
||||
const aScopa = a.capture.length > 0 && state.table.filter(c => !a.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0;
|
||||
const bScopa = b.capture.length > 0 && state.table.filter(c => !b.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0;
|
||||
if (aScopa !== bScopa) return bScopa - aScopa;
|
||||
|
||||
// Captures before dumps
|
||||
if (a.capture.length > 0 && b.capture.length === 0) return -1;
|
||||
if (a.capture.length === 0 && b.capture.length > 0) return 1;
|
||||
// Larger captures first
|
||||
return b.capture.length - a.capture.length;
|
||||
});
|
||||
const race = getRaceState(state, cur);
|
||||
const lastPlay = isLastPlay(state, cur);
|
||||
moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race) - quickEval(a, state, cur, tracker, lastPlay, race));
|
||||
}
|
||||
|
||||
if (isMyTeam) {
|
||||
let value = -Infinity;
|
||||
for (const move of moves) {
|
||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline);
|
||||
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker);
|
||||
value = Math.max(value, child);
|
||||
alpha = Math.max(alpha, value);
|
||||
if (beta <= alpha) break;
|
||||
@@ -875,7 +1066,7 @@ function alphaBeta(
|
||||
let value = Infinity;
|
||||
for (const move of moves) {
|
||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline);
|
||||
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker);
|
||||
value = Math.min(value, child);
|
||||
beta = Math.min(beta, value);
|
||||
if (beta <= alpha) break;
|
||||
|
||||
@@ -61,6 +61,27 @@ export class CardTracker {
|
||||
return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length;
|
||||
}
|
||||
|
||||
/** Count how many unseen cards share a value */
|
||||
countRemainingValue(value: number, myHand: Card[], table: Card[]): number {
|
||||
return this.getUnseenCards(myHand, table).filter(c => c.value === value).length;
|
||||
}
|
||||
|
||||
/** Probability that a hidden hand contains at least one card with the requested value */
|
||||
probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number {
|
||||
if (handSize <= 0) return 0;
|
||||
|
||||
const unseen = this.getUnseenCards(myHand, table);
|
||||
const matching = unseen.filter(c => c.value === value).length;
|
||||
if (matching === 0) return 0;
|
||||
if (handSize >= unseen.length) return 1;
|
||||
|
||||
let probNone = 1;
|
||||
for (let i = 0; i < handSize; i++) {
|
||||
probNone *= Math.max(0, unseen.length - matching - i) / (unseen.length - i);
|
||||
}
|
||||
return 1 - probNone;
|
||||
}
|
||||
|
||||
/** Get count of all played/seen cards */
|
||||
get playedCount(): number {
|
||||
return this.played.size;
|
||||
|
||||
@@ -39,15 +39,14 @@ export function shuffle<T>(arr: T[]): T[] {
|
||||
* Returns array of capture sets (each is a list of cards taken from table).
|
||||
*/
|
||||
export function findCaptures(played: Card, table: Card[]): Card[][] {
|
||||
const results: Card[][] = [];
|
||||
|
||||
// Each direct-match card is a separate single-card capture option
|
||||
const directMatches = table.filter(c => c.value === played.value);
|
||||
for (const dm of directMatches) {
|
||||
results.push([dm]);
|
||||
if (directMatches.length > 0) {
|
||||
return directMatches.map((directMatch): Card[] => [directMatch]);
|
||||
}
|
||||
|
||||
// Also find multi-card subsets that sum to played.value
|
||||
const results: Card[][] = [];
|
||||
// Only sum captures are legal when no direct match is available.
|
||||
const subsets = getSubsets(table);
|
||||
for (const subset of subsets) {
|
||||
if (subset.length >= 2) {
|
||||
@@ -102,6 +101,7 @@ export function createInitialState(startingPlayer: PlayerIndex = 0): GameState {
|
||||
return {
|
||||
players,
|
||||
table,
|
||||
matchStartingPlayer: startingPlayer,
|
||||
currentPlayer: startingPlayer,
|
||||
roundOver: false,
|
||||
gameOver: false,
|
||||
@@ -293,6 +293,33 @@ export function getScoreBreakdown(state: GameState): ScoreBreakdown {
|
||||
return scoreRound(team0, team1);
|
||||
}
|
||||
|
||||
export function getMatchOutcome(teamScores: [TeamScore, TeamScore]): {
|
||||
winner: 0 | 1 | null;
|
||||
continueMatch: boolean;
|
||||
} {
|
||||
const [team0, team1] = teamScores;
|
||||
const thresholdReached = team0.totalPoints >= 11 || team1.totalPoints >= 11;
|
||||
|
||||
if (!thresholdReached) {
|
||||
return {
|
||||
winner: null,
|
||||
continueMatch: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (team0.totalPoints === team1.totalPoints) {
|
||||
return {
|
||||
winner: null,
|
||||
continueMatch: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
winner: team0.totalPoints > team1.totalPoints ? 0 : 1,
|
||||
continueMatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -325,6 +352,7 @@ export function cloneState(state: GameState): GameState {
|
||||
clonePlayer(state.players[3]),
|
||||
],
|
||||
table: state.table.map(cloneCard),
|
||||
matchStartingPlayer: state.matchStartingPlayer,
|
||||
currentPlayer: state.currentPlayer,
|
||||
roundOver: state.roundOver,
|
||||
gameOver: state.gameOver,
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Player {
|
||||
export interface GameState {
|
||||
players: [Player, Player, Player, Player];
|
||||
table: Card[];
|
||||
matchStartingPlayer: PlayerIndex;
|
||||
currentPlayer: PlayerIndex;
|
||||
roundOver: boolean;
|
||||
gameOver: boolean;
|
||||
|
||||
Reference in New Issue
Block a user