From 747da35190f976a600c9b886247b207d11715c0f Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Thu, 2 Apr 2026 20:10:55 +0200 Subject: [PATCH] feat(SCOPONE-0008): complete iteration 0 improve ai rules --- docs/FINDINGS.md | 12 ++ src/game/ai.ts | 339 ++++++++++++++++++++++++++++++--------- src/game/card-tracker.ts | 21 +++ src/game/engine.ts | 38 ++++- src/game/types.ts | 1 + src/scenes/GameScene.ts | 108 ++++++++----- 6 files changed, 397 insertions(+), 122 deletions(-) diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 07b5b67..529b8fd 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -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 diff --git a/src/game/ai.ts b/src/game/ai.ts index 9d08ddf..161eb4a 100644 --- a/src/game/ai.ts +++ b/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 = { + 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 { + 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 { + 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 { 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(); 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(); - 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(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; diff --git a/src/game/card-tracker.ts b/src/game/card-tracker.ts index 33432ef..f111796 100644 --- a/src/game/card-tracker.ts +++ b/src/game/card-tracker.ts @@ -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; diff --git a/src/game/engine.ts b/src/game/engine.ts index 13bdcc8..c446f9c 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -39,15 +39,14 @@ export function shuffle(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, diff --git a/src/game/types.ts b/src/game/types.ts index aef0f40..430b5bb 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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; diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index a1c9d4f..6e41ab6 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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 = 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 { + 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;