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.
|
- `scoreDump()` (ai.ts:103): Uses `caps` for threat checking — unaffected.
|
||||||
- `GameScene.onCardClick()`: Already routes `captures.length > 1` to `highlightMultipleCaptures()` — works correctly with new behavior.
|
- `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
|
### Minimax Feasibility Analysis
|
||||||
- 10 cards per player × 4 players = 40 total moves per round
|
- 10 cards per player × 4 players = 40 total moves per round
|
||||||
- Full game tree: ~10^12 nodes — infeasible for exhaustive search
|
- 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[];
|
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
|
// Helpers shared across all difficulty levels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -51,28 +72,78 @@ function countValueInHand(hand: Card[], value: number): number {
|
|||||||
return n;
|
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) */
|
/** Check if partner likely holds a card of given value (via tracker inference) */
|
||||||
function partnerLikelyHolds(
|
function partnerLikelyHolds(
|
||||||
value: number, playerIdx: PlayerIndex, state: GameState,
|
value: number, playerIdx: PlayerIndex, state: GameState,
|
||||||
tracker: CardTracker | undefined, myHand: Card[], table: Card[],
|
tracker: CardTracker | undefined, myHand: Card[], table: Card[],
|
||||||
): number {
|
): number {
|
||||||
const partner = partnerOf(playerIdx);
|
const partner = partnerOf(playerIdx);
|
||||||
const partnerHandSize = state.players[partner].hand.length;
|
return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Race state: who's winning each scoring category */
|
/** Race state: who's winning each scoring category */
|
||||||
@@ -196,16 +267,30 @@ function hypergeometricNone(total: number, threats: number, drawn: number): numb
|
|||||||
// Main entry point
|
// Main entry point
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function chooseMove(
|
export async function chooseMove(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
playerIdx: PlayerIndex,
|
playerIdx: PlayerIndex,
|
||||||
difficulty: Difficulty = 'advanced',
|
difficulty: Difficulty = 'advanced',
|
||||||
tracker?: CardTracker,
|
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) {
|
switch (difficulty) {
|
||||||
case 'beginner': return beginnerMove(state, playerIdx, tracker);
|
case 'beginner': {
|
||||||
case 'advanced': return advancedMove(state, playerIdx, tracker);
|
const move = beginnerMove(state, playerIdx, tracker);
|
||||||
case 'master': return masterMove(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
|
// 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 myTeam = teamOf(playerIdx);
|
||||||
const phase = gamePhase(state);
|
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);
|
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 = startedAt + profile.timeBudgetMs;
|
||||||
const deadline = Date.now() + 1500;
|
|
||||||
|
|
||||||
// Quick-eval move ordering for better pruning
|
// Quick-eval move ordering for better pruning
|
||||||
const lastPlay = isLastPlay(state, playerIdx);
|
const lastPlay = isLastPlay(state, playerIdx);
|
||||||
|
const race = getRaceState(state, playerIdx);
|
||||||
const quickScored = legalMoves.map(m => ({
|
const quickScored = legalMoves.map(m => ({
|
||||||
move: 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);
|
quickScored.sort((a, b) => b.quick - a.quick);
|
||||||
const sortedMoves = quickScored.map(qs => qs.move);
|
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>();
|
const moveScores = new Map<string, number>();
|
||||||
for (const m of sortedMoves) moveScores.set(moveKey(m), 0);
|
for (const m of sortedMoves) moveScores.set(moveKey(m), 0);
|
||||||
|
|
||||||
// Deep endgame: use actual state (perfect info), otherwise sample
|
const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount);
|
||||||
const samples = isDeepEndgame
|
|
||||||
? [state]
|
|
||||||
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
|
||||||
|
|
||||||
let timedOut = false;
|
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) {
|
for (let start = 0; start < samples.length; start += profile.batchSize) {
|
||||||
if (timedOut) break;
|
if (timedOut || Date.now() > deadline) break;
|
||||||
for (const move of sortedMoves) {
|
|
||||||
if (Date.now() > deadline) { timedOut = true; break; }
|
const batch = samples.slice(start, start + profile.batchSize);
|
||||||
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
|
for (const sample of batch) {
|
||||||
const score = alphaBeta(
|
for (const move of sortedMoves) {
|
||||||
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity,
|
if (Date.now() > deadline) {
|
||||||
myTeam, playerIdx, phase, deadline,
|
timedOut = true;
|
||||||
);
|
break;
|
||||||
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
}
|
||||||
|
|
||||||
|
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];
|
let bestMove = sortedMoves[0];
|
||||||
@@ -709,12 +883,14 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
|||||||
if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; }
|
if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, batchesCompleted);
|
||||||
return bestMove;
|
return bestMove;
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickEval(
|
function quickEval(
|
||||||
move: AIMove, state: GameState, playerIdx: PlayerIndex,
|
move: AIMove, state: GameState, playerIdx: PlayerIndex,
|
||||||
tracker: CardTracker | undefined, lastPlay: boolean,
|
tracker: CardTracker | undefined, lastPlay: boolean,
|
||||||
|
race: RaceState,
|
||||||
): number {
|
): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
const table = state.table;
|
const table = state.table;
|
||||||
@@ -731,9 +907,9 @@ function quickEval(
|
|||||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900;
|
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;
|
if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000;
|
||||||
|
|
||||||
score += move.capture.length * 65;
|
score += move.capture.length * (race.behindInCards ? 75 : 55);
|
||||||
score += allCaptured.filter(c => c.suit === 'denara').length * 100;
|
score += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 135 : 95);
|
||||||
score += allCaptured.filter(c => c.value === 7).length * 80;
|
score += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 110 : 75);
|
||||||
for (const c of allCaptured) score += primieraVal(c) * 2.5;
|
for (const c of allCaptured) score += primieraVal(c) * 2.5;
|
||||||
|
|
||||||
if (move.capture.length === 0) {
|
if (move.capture.length === 0) {
|
||||||
@@ -762,6 +938,8 @@ function quickEval(
|
|||||||
if (sum >= 1 && sum <= 10) score += 40; // partner might scopa
|
if (sum >= 1 && sum <= 10) score += 40; // partner might scopa
|
||||||
}
|
}
|
||||||
|
|
||||||
|
score += tableControlPressure(afterTable, state, playerIdx, tracker, state.players[playerIdx].hand, race);
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,12 +988,37 @@ function generateSamples(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
|
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>();
|
const known = new Set<string>();
|
||||||
for (const c of state.players[playerIdx].hand) known.add(c.id);
|
for (const card of myHand) known.add(card.id);
|
||||||
for (const c of state.table) known.add(c.id);
|
for (const card of table) known.add(card.id);
|
||||||
for (const p of state.players) { for (const c of p.pile) known.add(c.id); }
|
for (const player of state.players) {
|
||||||
|
for (const card of player.pile) {
|
||||||
|
known.add(card.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deck = buildDeck();
|
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[] {
|
function shuffleArray<T>(arr: T[]): T[] {
|
||||||
@@ -830,6 +1033,7 @@ function alphaBeta(
|
|||||||
state: GameState, depth: number, alpha: number, beta: number,
|
state: GameState, depth: number, alpha: number, beta: number,
|
||||||
myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
||||||
phase: number, deadline: number,
|
phase: number, deadline: number,
|
||||||
|
tracker: CardTracker | undefined,
|
||||||
): number {
|
): number {
|
||||||
if (depth === 0 || state.roundOver || Date.now() > deadline) {
|
if (depth === 0 || state.roundOver || Date.now() > deadline) {
|
||||||
return evaluateFast(state, myTeam, phase);
|
return evaluateFast(state, myTeam, phase);
|
||||||
@@ -843,29 +1047,16 @@ function alphaBeta(
|
|||||||
|
|
||||||
// Move ordering: settebello captures first, then scopa, then captures by size, then dumps
|
// Move ordering: settebello captures first, then scopa, then captures by size, then dumps
|
||||||
if (moves.length > 2) {
|
if (moves.length > 2) {
|
||||||
moves.sort((a, b) => {
|
const race = getRaceState(state, cur);
|
||||||
const aSettebello = a.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
|
const lastPlay = isLastPlay(state, cur);
|
||||||
const bSettebello = b.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
|
moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race) - quickEval(a, state, cur, tracker, lastPlay, race));
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMyTeam) {
|
if (isMyTeam) {
|
||||||
let value = -Infinity;
|
let value = -Infinity;
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
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);
|
value = Math.max(value, child);
|
||||||
alpha = Math.max(alpha, value);
|
alpha = Math.max(alpha, value);
|
||||||
if (beta <= alpha) break;
|
if (beta <= alpha) break;
|
||||||
@@ -875,7 +1066,7 @@ function alphaBeta(
|
|||||||
let value = Infinity;
|
let value = Infinity;
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
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);
|
value = Math.min(value, child);
|
||||||
beta = Math.min(beta, value);
|
beta = Math.min(beta, value);
|
||||||
if (beta <= alpha) break;
|
if (beta <= alpha) break;
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ export class CardTracker {
|
|||||||
return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length;
|
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 count of all played/seen cards */
|
||||||
get playedCount(): number {
|
get playedCount(): number {
|
||||||
return this.played.size;
|
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).
|
* Returns array of capture sets (each is a list of cards taken from table).
|
||||||
*/
|
*/
|
||||||
export function findCaptures(played: Card, table: Card[]): Card[][] {
|
export function findCaptures(played: Card, table: Card[]): Card[][] {
|
||||||
const results: Card[][] = [];
|
|
||||||
|
|
||||||
// Each direct-match card is a separate single-card capture option
|
// Each direct-match card is a separate single-card capture option
|
||||||
const directMatches = table.filter(c => c.value === played.value);
|
const directMatches = table.filter(c => c.value === played.value);
|
||||||
for (const dm of directMatches) {
|
if (directMatches.length > 0) {
|
||||||
results.push([dm]);
|
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);
|
const subsets = getSubsets(table);
|
||||||
for (const subset of subsets) {
|
for (const subset of subsets) {
|
||||||
if (subset.length >= 2) {
|
if (subset.length >= 2) {
|
||||||
@@ -102,6 +101,7 @@ export function createInitialState(startingPlayer: PlayerIndex = 0): GameState {
|
|||||||
return {
|
return {
|
||||||
players,
|
players,
|
||||||
table,
|
table,
|
||||||
|
matchStartingPlayer: startingPlayer,
|
||||||
currentPlayer: startingPlayer,
|
currentPlayer: startingPlayer,
|
||||||
roundOver: false,
|
roundOver: false,
|
||||||
gameOver: false,
|
gameOver: false,
|
||||||
@@ -293,6 +293,33 @@ export function getScoreBreakdown(state: GameState): ScoreBreakdown {
|
|||||||
return scoreRound(team0, team1);
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -325,6 +352,7 @@ export function cloneState(state: GameState): GameState {
|
|||||||
clonePlayer(state.players[3]),
|
clonePlayer(state.players[3]),
|
||||||
],
|
],
|
||||||
table: state.table.map(cloneCard),
|
table: state.table.map(cloneCard),
|
||||||
|
matchStartingPlayer: state.matchStartingPlayer,
|
||||||
currentPlayer: state.currentPlayer,
|
currentPlayer: state.currentPlayer,
|
||||||
roundOver: state.roundOver,
|
roundOver: state.roundOver,
|
||||||
gameOver: state.gameOver,
|
gameOver: state.gameOver,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface Player {
|
|||||||
export interface GameState {
|
export interface GameState {
|
||||||
players: [Player, Player, Player, Player];
|
players: [Player, Player, Player, Player];
|
||||||
table: Card[];
|
table: Card[];
|
||||||
|
matchStartingPlayer: PlayerIndex;
|
||||||
currentPlayer: PlayerIndex;
|
currentPlayer: PlayerIndex;
|
||||||
roundOver: boolean;
|
roundOver: boolean;
|
||||||
gameOver: boolean;
|
gameOver: boolean;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
||||||
import {
|
import {
|
||||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
|
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { chooseMove } from '../game/ai';
|
import { chooseMove, AIDecisionProgress } from '../game/ai';
|
||||||
import { CardTracker } from '../game/card-tracker';
|
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 CW_A = 402 * CARD_SCALE_AI; // card width for AI ≈ 50
|
||||||
const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
|
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
|
// Scorebar height at top
|
||||||
const SCOREBAR_H = 54;
|
const SCOREBAR_H = 54;
|
||||||
|
|
||||||
@@ -76,8 +74,6 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Think bar
|
// Think bar
|
||||||
private thinkBar!: Phaser.GameObjects.Graphics;
|
private thinkBar!: Phaser.GameObjects.Graphics;
|
||||||
private thinkTween: Phaser.Tweens.Tween | null = null;
|
|
||||||
private thinkProgress = 0;
|
|
||||||
|
|
||||||
// Player label containers (pulsed on active turn)
|
// Player label containers (pulsed on active turn)
|
||||||
private playerLabels: Map<PlayerIndex, Phaser.GameObjects.Text> = new Map();
|
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.input.once('pointerdown', () => this.startMusic());
|
||||||
|
|
||||||
this.state = createInitialState();
|
const startingPlayer = Phaser.Math.Between(0, 3) as PlayerIndex;
|
||||||
|
this.state = createInitialState(startingPlayer);
|
||||||
this.dealAnimation(() => {
|
this.dealAnimation(() => {
|
||||||
this.updateScoreBar();
|
this.updateScoreBar();
|
||||||
this.nextTurn();
|
this.nextTurn();
|
||||||
@@ -374,39 +371,34 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.thinkBar = this.add.graphics().setDepth(11).setVisible(false);
|
this.thinkBar = this.add.graphics().setDepth(11).setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showThinkBar(playerIdx: PlayerIndex): void {
|
private showThinkBar(playerIdx: PlayerIndex, remainingRatio = 1): void {
|
||||||
this.thinkProgress = 0;
|
|
||||||
this.thinkBar.setVisible(true);
|
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 W = this.scale.width;
|
||||||
const tg = this.thinkBar;
|
const tg = this.thinkBar;
|
||||||
const color = (playerIdx === 0 || playerIdx === 2) ? 0x44ff88 : 0xff5555;
|
const color = (playerIdx === 0 || playerIdx === 2) ? 0x44ff88 : 0xff5555;
|
||||||
|
const clampedRatio = Phaser.Math.Clamp(remainingRatio, 0, 1);
|
||||||
|
const width = clampedRatio * W;
|
||||||
|
|
||||||
const tweenTarget = { v: 0 };
|
tg.clear();
|
||||||
this.thinkTween = this.tweens.add({
|
tg.fillStyle(0x000000, 0.4);
|
||||||
targets: tweenTarget,
|
tg.fillRect(0, SCOREBAR_H, W, 4);
|
||||||
v: 1,
|
if (width <= 0) return;
|
||||||
duration: AI_DELAY - 80,
|
|
||||||
ease: 'Linear',
|
tg.fillStyle(color, 0.85);
|
||||||
onUpdate: () => {
|
tg.fillRect(0, SCOREBAR_H, width, 4);
|
||||||
tg.clear();
|
tg.fillStyle(0xffffff, 0.6);
|
||||||
const w = tweenTarget.v * W;
|
tg.fillRect(Math.max(0, width - 6), SCOREBAR_H, Math.min(6, width), 4);
|
||||||
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); },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideThinkBar(): void {
|
private hideThinkBar(): void {
|
||||||
this.thinkTween?.stop();
|
|
||||||
this.thinkTween = null;
|
|
||||||
this.thinkBar.clear();
|
this.thinkBar.clear();
|
||||||
this.thinkBar.setVisible(false);
|
this.thinkBar.setVisible(false);
|
||||||
}
|
}
|
||||||
@@ -602,21 +594,47 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.pulseLabel(cur);
|
this.pulseLabel(cur);
|
||||||
|
|
||||||
if (player.isHuman) {
|
if (player.isHuman) {
|
||||||
|
this.hideThinkBar();
|
||||||
this.enableHumanInteraction();
|
this.enableHumanInteraction();
|
||||||
} else {
|
} else {
|
||||||
this.aiThinking = true;
|
this.aiThinking = true;
|
||||||
this.showThinkBar(cur);
|
this.showThinkBar(cur, 1);
|
||||||
this.time.delayedCall(AI_DELAY, () => {
|
void this.doAIMove(cur);
|
||||||
this.hideThinkBar();
|
|
||||||
this.doAIMove(cur);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private doAIMove(playerIdx: PlayerIndex): void {
|
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
|
||||||
const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker);
|
const turnState = this.state;
|
||||||
this.aiThinking = false;
|
|
||||||
this.executeMove(playerIdx, move.card, move.capture);
|
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);
|
}).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 btnLabel = gameOver ? 'Fine Partita' : 'Prossima Mano';
|
||||||
|
|
||||||
const btnG = this.add.graphics().setDepth(32);
|
const btnG = this.add.graphics().setDepth(32);
|
||||||
@@ -1344,7 +1363,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const H = this.scale.height;
|
const H = this.scale.height;
|
||||||
const t0 = this.state.teamScores[0];
|
const t0 = this.state.teamScores[0];
|
||||||
const t1 = this.state.teamScores[1];
|
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();
|
this.stopMusic();
|
||||||
|
|
||||||
// Victory confetti
|
// Victory confetti
|
||||||
@@ -1392,11 +1412,13 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private startNewRound(): void {
|
private startNewRound(): void {
|
||||||
const totals = this.state.teamScores.map(t => t.totalPoints);
|
const totals = this.state.teamScores.map(t => t.totalPoints);
|
||||||
const nextRound = (this.state.roundNumber ?? 1) + 1;
|
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();
|
for (const img of this.cardImages.values()) img.destroy();
|
||||||
this.cardImages.clear();
|
this.cardImages.clear();
|
||||||
this.tracker.reset();
|
this.tracker.reset();
|
||||||
this.state = createInitialState(startingPlayer);
|
this.state = createInitialState(startingPlayer);
|
||||||
|
this.state.matchStartingPlayer = matchStartingPlayer;
|
||||||
this.state.teamScores[0].totalPoints = totals[0];
|
this.state.teamScores[0].totalPoints = totals[0];
|
||||||
this.state.teamScores[1].totalPoints = totals[1];
|
this.state.teamScores[1].totalPoints = totals[1];
|
||||||
this.state.roundNumber = nextRound;
|
this.state.roundNumber = nextRound;
|
||||||
|
|||||||
Reference in New Issue
Block a user