feat(SCOPONE-0008): complete iteration 0 improve ai rules
This commit is contained in:
@@ -118,6 +118,18 @@ Phaser `Text` objects render to an internal Canvas. When the game uses `Scale.FI
|
||||
- `scoreDump()` (ai.ts:103): Uses `caps` for threat checking — unaffected.
|
||||
- `GameScene.onCardClick()`: Already routes `captures.length > 1` to `highlightMultipleCaptures()` — works correctly with new behavior.
|
||||
|
||||
### SCOPONE-0008: Phaser AI Turn Progress Timing (2026-04-02)
|
||||
|
||||
**Source**: Phaser 3 API docs (Context7 — `/websites/phaser_io_api-documentation`)
|
||||
|
||||
- `Phaser.Time.TimerEvent#getProgress()` returns the current iteration progress as a normalized value between `0` and `1`.
|
||||
- `Phaser.Time.TimerEvent#getOverallProgress()` returns normalized overall progress when repeats are involved.
|
||||
- `TimerEvent` instances are managed by the scene clock, so they are suitable for rendering an AI decision countdown bar that reflects elapsed scene time.
|
||||
|
||||
**Planning impact**:
|
||||
- A visible AI countdown can be driven from elapsed timer progress instead of a blind tween.
|
||||
- If the AI search budget increases beyond the current short synchronous window, the search loop must yield back to the browser between batches or Phaser will not repaint the progress bar while the AI is thinking.
|
||||
|
||||
### Minimax Feasibility Analysis
|
||||
- 10 cards per player × 4 players = 40 total moves per round
|
||||
- Full game tree: ~10^12 nodes — infeasible for exhaustive search
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Phaser from 'phaser';
|
||||
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
||||
import {
|
||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
|
||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome
|
||||
} from '../game/engine';
|
||||
import { chooseMove } from '../game/ai';
|
||||
import { chooseMove, AIDecisionProgress } from '../game/ai';
|
||||
import { CardTracker } from '../game/card-tracker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -25,8 +25,6 @@ const CH_H = 645 * CARD_SCALE_HUMAN; // card height for human ≈ 106
|
||||
const CW_A = 402 * CARD_SCALE_AI; // card width for AI ≈ 50
|
||||
const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
|
||||
|
||||
const AI_DELAY = 1100; // ms — think bar fills over this time
|
||||
|
||||
// Scorebar height at top
|
||||
const SCOREBAR_H = 54;
|
||||
|
||||
@@ -76,8 +74,6 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
// Think bar
|
||||
private thinkBar!: Phaser.GameObjects.Graphics;
|
||||
private thinkTween: Phaser.Tweens.Tween | null = null;
|
||||
private thinkProgress = 0;
|
||||
|
||||
// Player label containers (pulsed on active turn)
|
||||
private playerLabels: Map<PlayerIndex, Phaser.GameObjects.Text> = new Map();
|
||||
@@ -128,7 +124,8 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this.input.once('pointerdown', () => this.startMusic());
|
||||
|
||||
this.state = createInitialState();
|
||||
const startingPlayer = Phaser.Math.Between(0, 3) as PlayerIndex;
|
||||
this.state = createInitialState(startingPlayer);
|
||||
this.dealAnimation(() => {
|
||||
this.updateScoreBar();
|
||||
this.nextTurn();
|
||||
@@ -374,39 +371,34 @@ export class GameScene extends Phaser.Scene {
|
||||
this.thinkBar = this.add.graphics().setDepth(11).setVisible(false);
|
||||
}
|
||||
|
||||
private showThinkBar(playerIdx: PlayerIndex): void {
|
||||
this.thinkProgress = 0;
|
||||
private showThinkBar(playerIdx: PlayerIndex, remainingRatio = 1): void {
|
||||
this.thinkBar.setVisible(true);
|
||||
this.thinkTween?.stop();
|
||||
this.drawThinkBar(playerIdx, remainingRatio);
|
||||
}
|
||||
|
||||
private updateThinkBar(playerIdx: PlayerIndex, progress: AIDecisionProgress): void {
|
||||
this.drawThinkBar(playerIdx, 1 - progress.progress);
|
||||
}
|
||||
|
||||
private drawThinkBar(playerIdx: PlayerIndex, remainingRatio: number): void {
|
||||
const W = this.scale.width;
|
||||
const tg = this.thinkBar;
|
||||
const color = (playerIdx === 0 || playerIdx === 2) ? 0x44ff88 : 0xff5555;
|
||||
const clampedRatio = Phaser.Math.Clamp(remainingRatio, 0, 1);
|
||||
const width = clampedRatio * W;
|
||||
|
||||
const tweenTarget = { v: 0 };
|
||||
this.thinkTween = this.tweens.add({
|
||||
targets: tweenTarget,
|
||||
v: 1,
|
||||
duration: AI_DELAY - 80,
|
||||
ease: 'Linear',
|
||||
onUpdate: () => {
|
||||
tg.clear();
|
||||
const w = tweenTarget.v * W;
|
||||
tg.fillStyle(0x000000, 0.4);
|
||||
tg.fillRect(0, SCOREBAR_H, W, 4);
|
||||
tg.fillStyle(color, 0.85);
|
||||
tg.fillRect(0, SCOREBAR_H, w, 4);
|
||||
// Glow tip
|
||||
tg.fillStyle(0xffffff, 0.6);
|
||||
tg.fillRect(w - 6, SCOREBAR_H, 6, 4);
|
||||
},
|
||||
onComplete: () => { tg.clear(); tg.setVisible(false); },
|
||||
});
|
||||
tg.clear();
|
||||
tg.fillStyle(0x000000, 0.4);
|
||||
tg.fillRect(0, SCOREBAR_H, W, 4);
|
||||
if (width <= 0) return;
|
||||
|
||||
tg.fillStyle(color, 0.85);
|
||||
tg.fillRect(0, SCOREBAR_H, width, 4);
|
||||
tg.fillStyle(0xffffff, 0.6);
|
||||
tg.fillRect(Math.max(0, width - 6), SCOREBAR_H, Math.min(6, width), 4);
|
||||
}
|
||||
|
||||
private hideThinkBar(): void {
|
||||
this.thinkTween?.stop();
|
||||
this.thinkTween = null;
|
||||
this.thinkBar.clear();
|
||||
this.thinkBar.setVisible(false);
|
||||
}
|
||||
@@ -602,21 +594,47 @@ export class GameScene extends Phaser.Scene {
|
||||
this.pulseLabel(cur);
|
||||
|
||||
if (player.isHuman) {
|
||||
this.hideThinkBar();
|
||||
this.enableHumanInteraction();
|
||||
} else {
|
||||
this.aiThinking = true;
|
||||
this.showThinkBar(cur);
|
||||
this.time.delayedCall(AI_DELAY, () => {
|
||||
this.hideThinkBar();
|
||||
this.doAIMove(cur);
|
||||
});
|
||||
this.showThinkBar(cur, 1);
|
||||
void this.doAIMove(cur);
|
||||
}
|
||||
}
|
||||
|
||||
private doAIMove(playerIdx: PlayerIndex): void {
|
||||
const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker);
|
||||
this.aiThinking = false;
|
||||
this.executeMove(playerIdx, move.card, move.capture);
|
||||
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
|
||||
const turnState = this.state;
|
||||
|
||||
try {
|
||||
const move = await chooseMove(
|
||||
this.state,
|
||||
playerIdx,
|
||||
this.difficulty,
|
||||
this.tracker,
|
||||
(progress) => {
|
||||
if (!this.scene.isActive('GameScene') || this.state !== turnState) return;
|
||||
this.updateThinkBar(playerIdx, progress);
|
||||
}
|
||||
);
|
||||
|
||||
if (!this.scene.isActive('GameScene')) return;
|
||||
if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return;
|
||||
|
||||
this.hideThinkBar();
|
||||
this.aiThinking = false;
|
||||
this.executeMove(playerIdx, move.card, move.capture);
|
||||
} catch (error) {
|
||||
console.error('AI move failed', error);
|
||||
if (this.scene.isActive('GameScene') && this.state === turnState) {
|
||||
this.setStatus('Errore durante la mossa AI');
|
||||
}
|
||||
} finally {
|
||||
if (this.scene.isActive('GameScene') && this.state === turnState) {
|
||||
this.hideThinkBar();
|
||||
this.aiThinking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1316,7 +1334,8 @@ export class GameScene extends Phaser.Scene {
|
||||
}).setOrigin(0.5).setDepth(32);
|
||||
});
|
||||
|
||||
const gameOver = t0.totalPoints >= 11 || t1.totalPoints >= 11;
|
||||
const outcome = getMatchOutcome(this.state.teamScores);
|
||||
const gameOver = !outcome.continueMatch;
|
||||
const btnLabel = gameOver ? 'Fine Partita' : 'Prossima Mano';
|
||||
|
||||
const btnG = this.add.graphics().setDepth(32);
|
||||
@@ -1344,7 +1363,8 @@ export class GameScene extends Phaser.Scene {
|
||||
const H = this.scale.height;
|
||||
const t0 = this.state.teamScores[0];
|
||||
const t1 = this.state.teamScores[1];
|
||||
const win = t0.totalPoints >= t1.totalPoints;
|
||||
const outcome = getMatchOutcome(this.state.teamScores);
|
||||
const win = outcome.winner === 0;
|
||||
this.stopMusic();
|
||||
|
||||
// Victory confetti
|
||||
@@ -1392,11 +1412,13 @@ export class GameScene extends Phaser.Scene {
|
||||
private startNewRound(): void {
|
||||
const totals = this.state.teamScores.map(t => t.totalPoints);
|
||||
const nextRound = (this.state.roundNumber ?? 1) + 1;
|
||||
const startingPlayer = ((nextRound - 1) % 4) as PlayerIndex;
|
||||
const matchStartingPlayer = this.state.matchStartingPlayer;
|
||||
const startingPlayer = ((matchStartingPlayer + nextRound - 1) % 4) as PlayerIndex;
|
||||
for (const img of this.cardImages.values()) img.destroy();
|
||||
this.cardImages.clear();
|
||||
this.tracker.reset();
|
||||
this.state = createInitialState(startingPlayer);
|
||||
this.state.matchStartingPlayer = matchStartingPlayer;
|
||||
this.state.teamScores[0].totalPoints = totals[0];
|
||||
this.state.teamScores[1].totalPoints = totals[1];
|
||||
this.state.roundNumber = nextRound;
|
||||
|
||||
Reference in New Issue
Block a user