feat(SCOPONE-0009) improve ai, dealer, apparigliare e sparigliare

This commit is contained in:
Giancarmine Salucci
2026-04-09 22:30:27 +02:00
parent d0a44d295a
commit 77ab1f43a6
8 changed files with 3787 additions and 510 deletions

View File

@@ -4,7 +4,7 @@ import {
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome,
nextPlayer
} from '../game/engine';
import { AIDecisionProgress } from '../game/ai';
import { AIMove, AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
import { CardTracker } from '../game/card-tracker';
@@ -29,6 +29,8 @@ const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
// Scorebar height at top
const SCOREBAR_H = 54;
const AI_MIN_THINK_MS = 1000;
const MOVE_OUTCOME_STATUS_MS = 2000;
// Player positions:
// 0 = South (human, bottom), 1 = West (AI, left, rotated -90°)
@@ -74,6 +76,8 @@ export class GameScene extends Phaser.Scene {
// Status bar
private statusText!: Phaser.GameObjects.Text;
private statusTimer: Phaser.Time.TimerEvent | null = null;
private persistentStatusText = '';
// Think bar
private thinkBar!: Phaser.GameObjects.Graphics;
@@ -387,6 +391,38 @@ export class GameScene extends Phaser.Scene {
this.drawThinkBar(playerIdx, 1 - progress.progress);
}
private logMasterAIDiagnostics(
playerIdx: PlayerIndex,
move: AIMove,
progress: AIDecisionProgress | null,
): void {
if (this.difficulty !== 'master' || !progress) {
return;
}
console.info('[AI master diagnostics]', {
playerIdx,
roundNumber: this.state.roundNumber ?? 1,
moveCardId: move.card.id,
captureCount: move.capture.length,
timing: {
elapsedMs: progress.elapsedMs,
budgetMs: progress.budgetMs,
progress: progress.progress,
timedOut: progress.timedOut ?? false,
},
search: {
batchesCompleted: progress.batchesCompleted,
cardsRemaining: progress.cardsRemaining ?? null,
sampleCount: progress.sampleCount ?? null,
maxDepth: progress.maxDepth ?? null,
completedDepth: progress.completedDepth ?? null,
rootMoveCount: progress.rootMoveCount ?? null,
aspirationExpansions: progress.aspirationExpansions ?? 0,
},
});
}
private drawThinkBar(playerIdx: PlayerIndex, remainingRatio: number): void {
const W = this.scale.width;
const tg = this.thinkBar;
@@ -410,6 +446,35 @@ export class GameScene extends Phaser.Scene {
this.thinkBar.setVisible(false);
}
private clearStatusTimer(): void {
if (!this.statusTimer) {
return;
}
this.statusTimer.remove(false);
this.statusTimer.destroy();
this.statusTimer = null;
}
private waitForDelay(delayMs: number): Promise<void> {
if (delayMs <= 0 || !this.scene.isActive('GameScene')) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.time.delayedCall(delayMs, () => resolve());
});
}
private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string {
const player = this.state.players[playerIdx];
if (!capture || capture.length === 0) {
return `${player.name} gioca ${cardName(card)}`;
}
return `${player.name} cattura ${capture.map(cardName).join(', ')} con ${cardName(card)}`;
}
// ---------------------------------------------------------------------------
// Player labels (pulse on active turn)
// ---------------------------------------------------------------------------
@@ -597,7 +662,7 @@ export class GameScene extends Phaser.Scene {
if (this.state.roundOver) { this.showRoundEnd(); return; }
const cur = this.state.currentPlayer;
const player = this.state.players[cur];
this.setStatus(`Turno di ${player.name}`);
this.setStatus(`Turno di ${player.name}`, { persist: true });
this.pulseLabel(cur);
if (player.isHuman) {
@@ -611,6 +676,7 @@ export class GameScene extends Phaser.Scene {
}
private handleSceneShutdown(): void {
this.clearStatusTimer();
this.aiClient?.dispose();
this.aiClient = null;
this.aiThinking = false;
@@ -622,6 +688,8 @@ export class GameScene extends Phaser.Scene {
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
const turnState = this.state;
const aiClient = this.aiClient;
let finalProgress: AIDecisionProgress | null = null;
const thinkStartedAt = Date.now();
if (!aiClient) {
return;
@@ -636,13 +704,21 @@ export class GameScene extends Phaser.Scene {
(progress) => {
if (this.aiClient !== aiClient || !this.scene.isActive('GameScene') || this.state !== turnState) return;
this.updateThinkBar(playerIdx, progress);
if (progress.difficulty !== 'master') return;
finalProgress = progress;
}
);
const remainingThinkMs = AI_MIN_THINK_MS - (Date.now() - thinkStartedAt);
if (remainingThinkMs > 0) {
await this.waitForDelay(remainingThinkMs);
}
if (this.aiClient !== aiClient) return;
if (!this.scene.isActive('GameScene')) return;
if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return;
this.logMasterAIDiagnostics(playerIdx, move, finalProgress);
this.hideThinkBar();
this.aiThinking = false;
this.executeMove(playerIdx, move.card, move.capture);
@@ -923,10 +999,10 @@ export class GameScene extends Phaser.Scene {
if (isScopa) {
this.playSfx('scopa');
this.doScopaEffect(playerIdx, () =>
this.afterMove(nextState, oldState)
this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState)
);
} else {
this.afterMove(nextState, oldState);
this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState);
}
}
},
@@ -947,20 +1023,39 @@ export class GameScene extends Phaser.Scene {
targets: cardImg,
x: tablePos.x, y: tablePos.y, angle: randomAngle,
duration: 280, ease: 'Back.Out',
onComplete: () => this.afterMove(nextState, oldState),
onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState),
});
}
}
private afterMove(nextState: GameState, _old: GameState): void {
private afterMove(
playerIdx: PlayerIndex,
card: Card,
capture: Card[] | null,
nextState: GameState,
_old: GameState,
): void {
this.updateScoreBar();
this.relayoutTable();
if (nextState.roundOver) {
this.time.delayedCall(500, () => this.showRoundEnd());
} else {
if (!nextState.roundOver) {
this.relayoutHand(0);
this.nextTurn();
}
this.setStatus(this.buildMoveOutcomeStatus(playerIdx, card, capture), {
durationMs: MOVE_OUTCOME_STATUS_MS,
onExpire: () => {
if (!this.scene.isActive('GameScene')) {
return;
}
if (nextState.roundOver) {
this.showRoundEnd();
return;
}
this.nextTurn();
},
});
}
private relayoutHand(playerIdx: PlayerIndex): void {
@@ -1450,7 +1545,32 @@ export class GameScene extends Phaser.Scene {
});
}
private setStatus(msg: string): void { this.statusText.setText(msg); }
private setStatus(
msg: string,
options: {
persist?: boolean;
durationMs?: number;
onExpire?: () => void;
} = {},
): void {
const { persist = false, durationMs, onExpire } = options;
this.clearStatusTimer();
this.statusText.setText(msg);
if (persist) {
this.persistentStatusText = msg;
}
if (durationMs === undefined) {
return;
}
this.statusTimer = this.time.delayedCall(durationMs, () => {
this.statusTimer = null;
onExpire?.();
});
}
}
// ---------------------------------------------------------------------------