feat(SCOPONE-0005): complete iteration 0 — AI mastery levels, score bar fix, difficulty selection
This commit is contained in:
502
src/game/ai.ts
502
src/game/ai.ts
@@ -1,37 +1,61 @@
|
||||
import { Card, GameState, PlayerIndex } from './types';
|
||||
import { findCaptures, canCapture, calcPrimiera, teamOf } from './engine';
|
||||
import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES } from './types';
|
||||
import { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine';
|
||||
import { CardTracker } from './card-tracker';
|
||||
|
||||
export interface AIMove {
|
||||
card: Card;
|
||||
capture: Card[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic AI for Scopone Scientifico.
|
||||
* Evaluates moves by scoring the value of captured cards.
|
||||
*/
|
||||
export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point — dispatches by difficulty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function chooseMove(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
difficulty: Difficulty = 'advanced',
|
||||
tracker?: CardTracker,
|
||||
): AIMove {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return beginnerMove(state, playerIdx);
|
||||
case 'advanced': return advancedMove(state, playerIdx, tracker);
|
||||
case 'master': return masterMove(state, playerIdx, tracker);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BEGINNER — weakened heuristic with random noise
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function beginnerMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||
const player = state.players[playerIdx];
|
||||
const hand = player.hand;
|
||||
const table = state.table;
|
||||
const myTeam = teamOf(playerIdx);
|
||||
|
||||
// 20% chance to pick a completely random legal move
|
||||
if (Math.random() < 0.2) {
|
||||
return randomMove(state, playerIdx);
|
||||
}
|
||||
|
||||
let bestMove: AIMove | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const card of hand) {
|
||||
for (const card of player.hand) {
|
||||
const captures = findCaptures(card, table);
|
||||
if (captures.length > 0) {
|
||||
for (const captureSet of captures) {
|
||||
const score = scoreCapture(card, captureSet, table, state, myTeam);
|
||||
// Weakened heuristic: half weights + random noise
|
||||
const base = scoreCaptureBasic(card, captureSet, table);
|
||||
const score = base * 0.5 + (Math.random() - 0.5) * base * 0.6;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: captureSet };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No capture — score the "dump" move
|
||||
const score = scoreDump(card, table, state, myTeam);
|
||||
const base = scoreDumpBasic(card);
|
||||
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.6;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: [] };
|
||||
@@ -42,122 +66,396 @@ export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||
return bestMove!;
|
||||
}
|
||||
|
||||
function scoreCapture(
|
||||
played: Card,
|
||||
captured: Card[],
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
let score = 100; // base for capturing anything
|
||||
function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||
const hand = state.players[playerIdx].hand;
|
||||
const card = hand[Math.floor(Math.random() * hand.length)];
|
||||
const captures = findCaptures(card, state.table);
|
||||
if (captures.length > 0) {
|
||||
return { card, capture: captures[Math.floor(Math.random() * captures.length)] };
|
||||
}
|
||||
return { card, capture: [] };
|
||||
}
|
||||
|
||||
// Basic scoring (no cheating, no card counting)
|
||||
function scoreCaptureBasic(played: Card, captured: Card[], table: Card[]): number {
|
||||
let score = 100;
|
||||
const allCaptured = [played, ...captured];
|
||||
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
||||
const isScopa = afterTable.length === 0;
|
||||
|
||||
// Scopa is very valuable
|
||||
if (isScopa) score += 500;
|
||||
|
||||
// Settebello
|
||||
if (afterTable.length === 0) score += 500;
|
||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
|
||||
|
||||
// Table has settebello and this capture takes it
|
||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 200;
|
||||
|
||||
// Denari cards
|
||||
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
|
||||
|
||||
// More cards = better
|
||||
score += captured.length * 20;
|
||||
|
||||
// High-value primiera cards
|
||||
score += allCaptured.reduce((s, c) => s + primieraScore(c), 0);
|
||||
|
||||
// Avoid leaving settebello on table for opponent
|
||||
const settebelloOnTable = afterTable.some(c => c.suit === 'denara' && c.value === 7);
|
||||
if (settebelloOnTable) score -= 150;
|
||||
|
||||
// Opponent could easily capture remaining table
|
||||
score -= opponentThreatScore(afterTable, state, myTeam) * 30;
|
||||
score += allCaptured.reduce((s, c) => s + primieraValue(c), 0);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function scoreDump(
|
||||
card: Card,
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
function scoreDumpBasic(card: Card): number {
|
||||
let score = 0;
|
||||
|
||||
// Don't dump cards that complete opponent captures
|
||||
const afterTable = [...table, card];
|
||||
|
||||
// Check if opponent can make a scopa after this dump
|
||||
const opponentTeam = myTeam === 0 ? 1 : 0;
|
||||
const opponentPlayers = state.players.filter(p => teamOf(p.index) === opponentTeam);
|
||||
for (const opp of opponentPlayers) {
|
||||
for (const oppCard of opp.hand) {
|
||||
const caps = findCaptures(oppCard, afterTable);
|
||||
for (const cap of caps) {
|
||||
const leftAfter = afterTable.filter(c => !cap.some(cc => cc.id === c.id));
|
||||
if (leftAfter.length === 0) score -= 400; // would give opponent scopa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to dump low-value non-denari cards
|
||||
if (card.suit !== 'denara') score += 30;
|
||||
if (card.suit === 'denara') score -= 40;
|
||||
if (card.suit === 'denara' && card.value === 7) score -= 300; // never dump settebello
|
||||
|
||||
// Prefer dumping face cards (10, 9, 8) that are less useful
|
||||
if (card.suit === 'denara' && card.value === 7) score -= 300;
|
||||
if (card.value >= 8) score += 10;
|
||||
|
||||
// Don't dump 7s (primiera value)
|
||||
if (card.value === 7) score -= 50;
|
||||
|
||||
// Don't dump 1s (primiera value)
|
||||
if (card.value === 1) score -= 30;
|
||||
|
||||
// Penalty for making easy captures for opponents
|
||||
const capturable = state.players
|
||||
.filter(p => teamOf(p.index) !== myTeam)
|
||||
.flatMap(p => p.hand)
|
||||
.some(oppCard => canCapture(oppCard, afterTable));
|
||||
if (capturable) score -= 20;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function primieraScore(card: Card): number {
|
||||
// Reward for capturing high-primiera cards
|
||||
function primieraValue(card: Card): number {
|
||||
const vals: Record<number, number> = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
|
||||
return vals[card.value] ?? 0;
|
||||
}
|
||||
|
||||
function opponentThreatScore(
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
const opponentTeam = myTeam === 0 ? 1 : 0;
|
||||
let threat = 0;
|
||||
for (const player of state.players) {
|
||||
if (teamOf(player.index) !== opponentTeam) continue;
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADVANCED — improved heuristic with card counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
|
||||
const player = state.players[playerIdx];
|
||||
const table = state.table;
|
||||
const myTeam = teamOf(playerIdx);
|
||||
const allyIdx = ((playerIdx + 2) % 4) as PlayerIndex;
|
||||
const allyIsNext = state.currentPlayer === playerIdx &&
|
||||
((playerIdx + 1) % 4) === allyIdx;
|
||||
|
||||
let bestMove: AIMove | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const card of player.hand) {
|
||||
const caps = findCaptures(card, table);
|
||||
if (caps.length > 0) {
|
||||
threat += caps[0].length;
|
||||
// Extra threat if settebello is capturable
|
||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||
caps.some(cap => cap.some(c => c.suit === 'denara' && c.value === 7))) {
|
||||
threat += 5;
|
||||
const captures = findCaptures(card, table);
|
||||
if (captures.length > 0) {
|
||||
for (const captureSet of captures) {
|
||||
const score = scoreCaptureAdvanced(card, captureSet, table, state, myTeam, tracker, player.hand, allyIsNext);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: captureSet };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const score = scoreDumpAdvanced(card, table, state, myTeam, tracker, player.hand, allyIsNext);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return threat;
|
||||
|
||||
return bestMove!;
|
||||
}
|
||||
|
||||
function scoreCaptureAdvanced(
|
||||
played: Card, captured: Card[], table: Card[], state: GameState,
|
||||
myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[],
|
||||
allyIsNext: boolean,
|
||||
): number {
|
||||
let score = 100;
|
||||
const allCaptured = [played, ...captured];
|
||||
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
||||
const isScopa = afterTable.length === 0;
|
||||
|
||||
// Scopa bonus
|
||||
if (isScopa) score += 600;
|
||||
|
||||
// Settebello — highest priority
|
||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400;
|
||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 250;
|
||||
|
||||
// Denari cards
|
||||
score += allCaptured.filter(c => c.suit === 'denara').length * 60;
|
||||
|
||||
// Card count (carte majority)
|
||||
score += captured.length * 25;
|
||||
|
||||
// Primiera — prefer capturing 7s, then 6s, then aces
|
||||
for (const c of allCaptured) {
|
||||
score += PRIMIERA_VALUES[c.value] * 2;
|
||||
}
|
||||
|
||||
// Card counting: avoid leaving settebello on table
|
||||
if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) score -= 200;
|
||||
|
||||
// Anti-scopa: prefer leaving table total ≥ 11
|
||||
if (!isScopa) {
|
||||
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
|
||||
if (tableSum >= 11) score += 40;
|
||||
if (tableSum < 5) score -= 30;
|
||||
}
|
||||
|
||||
// Card tracking threat assessment (no cheating)
|
||||
if (tracker) {
|
||||
const unseen = tracker.getUnseenCards(myHand, afterTable);
|
||||
// Check if any unseen card could scopa the remaining table
|
||||
for (const uc of unseen) {
|
||||
const caps = findCaptures(uc, afterTable);
|
||||
for (const cap of caps) {
|
||||
if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) {
|
||||
score -= 200; // potential opponent scopa
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cooperative: if ally is next, allow leaving cards ally can capture
|
||||
if (allyIsNext && !isScopa) {
|
||||
score += 15; // slight preference if we leave cards and ally plays next
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function scoreDumpAdvanced(
|
||||
card: Card, table: Card[], state: GameState,
|
||||
myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[],
|
||||
allyIsNext: boolean,
|
||||
): number {
|
||||
let score = 0;
|
||||
const afterTable = [...table, card];
|
||||
|
||||
// Never dump settebello
|
||||
if (card.suit === 'denara' && card.value === 7) score -= 500;
|
||||
|
||||
// Avoid dumping denari
|
||||
if (card.suit === 'denara') score -= 50;
|
||||
if (card.suit !== 'denara') score += 30;
|
||||
|
||||
// Avoid dumping 7s (primiera)
|
||||
if (card.value === 7) score -= 60;
|
||||
if (card.value === 1) score -= 35;
|
||||
|
||||
// Prefer dumping face cards
|
||||
if (card.value >= 8) score += 15;
|
||||
|
||||
// Anti-scopa: prefer leaving table total ≥ 11
|
||||
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
|
||||
if (tableSum >= 11) score += 50;
|
||||
if (tableSum < 5) score -= 40;
|
||||
|
||||
// Card tracking: check if unseen cards could scopa after this dump
|
||||
if (tracker) {
|
||||
const unseen = tracker.getUnseenCards(myHand, afterTable);
|
||||
let scopaThreat = 0;
|
||||
for (const uc of unseen) {
|
||||
const caps = findCaptures(uc, afterTable);
|
||||
for (const cap of caps) {
|
||||
if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) {
|
||||
scopaThreat++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
score -= scopaThreat * 80;
|
||||
}
|
||||
|
||||
// Cooperative: if ally is next, leaving captures for ally is OK
|
||||
if (allyIsNext) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MASTER — minimax with alpha-beta pruning and determinization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
|
||||
const player = state.players[playerIdx];
|
||||
const table = state.table;
|
||||
const myTeam = teamOf(playerIdx);
|
||||
const NUM_SAMPLES = 12;
|
||||
const MAX_DEPTH = 4; // 4 plies = one full rotation
|
||||
|
||||
// Generate all legal moves for this player
|
||||
const legalMoves = getLegalMoves(state, playerIdx);
|
||||
if (legalMoves.length === 1) return legalMoves[0];
|
||||
|
||||
// Score accumulator for each move
|
||||
const moveScores = new Map<string, number>();
|
||||
for (const m of legalMoves) {
|
||||
moveScores.set(moveKey(m), 0);
|
||||
}
|
||||
|
||||
// Determinization: sample possible opponent hand assignments
|
||||
const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
||||
|
||||
for (const sample of samples) {
|
||||
for (const move of legalMoves) {
|
||||
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||
const score = alphaBeta(result.nextState, MAX_DEPTH - 1, -Infinity, Infinity, false, myTeam, playerIdx);
|
||||
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick move with highest total score across samples
|
||||
let bestMove = legalMoves[0];
|
||||
let bestScore = -Infinity;
|
||||
for (const move of legalMoves) {
|
||||
const totalScore = moveScores.get(moveKey(move)) ?? 0;
|
||||
if (totalScore > bestScore) {
|
||||
bestScore = totalScore;
|
||||
bestMove = move;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMove;
|
||||
}
|
||||
|
||||
function moveKey(move: AIMove): string {
|
||||
const capIds = move.capture.map(c => c.id).sort().join(',');
|
||||
return `${move.card.id}|${capIds}`;
|
||||
}
|
||||
|
||||
function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] {
|
||||
const moves: AIMove[] = [];
|
||||
const player = state.players[playerIdx];
|
||||
const table = state.table;
|
||||
|
||||
for (const card of player.hand) {
|
||||
const captures = findCaptures(card, table);
|
||||
if (captures.length > 0) {
|
||||
for (const captureSet of captures) {
|
||||
moves.push({ card, capture: captureSet });
|
||||
}
|
||||
} else {
|
||||
moves.push({ card, capture: [] });
|
||||
}
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
function generateSamples(
|
||||
state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number,
|
||||
): GameState[] {
|
||||
const myHand = state.players[playerIdx].hand;
|
||||
const samples: GameState[] = [];
|
||||
|
||||
// Cards we know about: our hand + table + tracked
|
||||
const unseen = tracker
|
||||
? tracker.getUnseenCards(myHand, state.table)
|
||||
: getUnseenWithoutTracker(state, playerIdx);
|
||||
|
||||
for (let s = 0; s < count; s++) {
|
||||
const sample = JSON.parse(JSON.stringify(state)) as GameState;
|
||||
const shuffled = shuffleArray([...unseen]);
|
||||
|
||||
// Distribute unseen cards among other players proportionally to their hand sizes
|
||||
let idx = 0;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
if (p === playerIdx) continue;
|
||||
const need = sample.players[p].hand.length;
|
||||
sample.players[p].hand = shuffled.slice(idx, idx + need);
|
||||
idx += need;
|
||||
}
|
||||
|
||||
samples.push(sample);
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
|
||||
// Without tracker, we only know our own hand and the 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);
|
||||
// Also know cards in all players' piles (they've been captured visibly)
|
||||
for (const p of state.players) {
|
||||
for (const c of p.pile) known.add(c.id);
|
||||
}
|
||||
|
||||
const unseen: Card[] = [];
|
||||
const deck = buildDeck();
|
||||
for (const c of deck) {
|
||||
if (!known.has(c.id)) unseen.push(c);
|
||||
}
|
||||
return unseen;
|
||||
}
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function alphaBeta(
|
||||
state: GameState, depth: number, alpha: number, beta: number,
|
||||
maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
||||
): number {
|
||||
if (depth === 0 || state.roundOver) {
|
||||
return evaluate(state, myTeam);
|
||||
}
|
||||
|
||||
const cur = state.currentPlayer;
|
||||
const isMyTeam = teamOf(cur) === myTeam;
|
||||
const moves = getLegalMoves(state, cur);
|
||||
|
||||
if (moves.length === 0) return evaluate(state, myTeam);
|
||||
|
||||
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, !isMyTeam, myTeam, rootPlayer);
|
||||
value = Math.max(value, child);
|
||||
alpha = Math.max(alpha, value);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
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, !isMyTeam, myTeam, rootPlayer);
|
||||
value = Math.min(value, child);
|
||||
beta = Math.min(beta, value);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(state: GameState, myTeam: 0 | 1): number {
|
||||
const oppTeam = myTeam === 0 ? 1 : 0;
|
||||
const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
|
||||
const oppPlayers = oppTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
|
||||
|
||||
const myPile = myPlayers.flatMap(p => p.pile);
|
||||
const oppPile = oppPlayers.flatMap(p => p.pile);
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Cards (20 = majority)
|
||||
score += (myPile.length - oppPile.length) * 10;
|
||||
|
||||
// Denari
|
||||
const myDenari = myPile.filter(c => c.suit === 'denara').length;
|
||||
const oppDenari = oppPile.filter(c => c.suit === 'denara').length;
|
||||
score += (myDenari - oppDenari) * 30;
|
||||
|
||||
// Settebello
|
||||
if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 200;
|
||||
if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 200;
|
||||
|
||||
// Primiera
|
||||
const myPrim = calcPrimiera(myPile);
|
||||
const oppPrim = calcPrimiera(oppPile);
|
||||
if (myPrim > 0 && oppPrim > 0) {
|
||||
score += (myPrim - oppPrim) * 2;
|
||||
} else if (myPrim > 0) {
|
||||
score += 100;
|
||||
} else if (oppPrim > 0) {
|
||||
score -= 100;
|
||||
}
|
||||
|
||||
// Scope
|
||||
const myScope = myPlayers.reduce((s, p) => s + p.scope, 0);
|
||||
const oppScope = oppPlayers.reduce((s, p) => s + p.scope, 0);
|
||||
score += (myScope - oppScope) * 150;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
68
src/game/card-tracker.ts
Normal file
68
src/game/card-tracker.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Card, Suit, SUITS } from './types';
|
||||
|
||||
/**
|
||||
* Tracks which cards have been played/captured during a round.
|
||||
* Used by AI to infer opponent hands WITHOUT cheating.
|
||||
*/
|
||||
export class CardTracker {
|
||||
private played: Set<string> = new Set(); // card IDs that have been seen
|
||||
|
||||
/** Record a card being played to the table */
|
||||
trackPlay(card: Card): void {
|
||||
this.played.add(card.id);
|
||||
}
|
||||
|
||||
/** Record cards captured from the table */
|
||||
trackCapture(cards: Card[]): void {
|
||||
for (const c of cards) {
|
||||
this.played.add(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset for a new round */
|
||||
reset(): void {
|
||||
this.played.clear();
|
||||
}
|
||||
|
||||
/** Has a specific card been seen played? */
|
||||
hasBeenPlayed(cardId: string): boolean {
|
||||
return this.played.has(cardId);
|
||||
}
|
||||
|
||||
/** Is the settebello (7 of denara) still unseen (not played/captured)? */
|
||||
isSettebelloUnseen(): boolean {
|
||||
return !this.played.has('denara_7');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cards that could be in opponent hands.
|
||||
* = full 40-card deck minus: already played, my hand, currently on table
|
||||
*/
|
||||
getUnseenCards(myHand: Card[], table: Card[]): Card[] {
|
||||
const known = new Set<string>();
|
||||
for (const id of this.played) known.add(id);
|
||||
for (const c of myHand) known.add(c.id);
|
||||
for (const c of table) known.add(c.id);
|
||||
|
||||
const unseen: Card[] = [];
|
||||
for (const suit of SUITS) {
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
const id = `${suit}_${v}`;
|
||||
if (!known.has(id)) {
|
||||
unseen.push({ suit, value: v, id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return unseen;
|
||||
}
|
||||
|
||||
/** Count how many cards of a suit are still unseen */
|
||||
countRemainingSuit(suit: Suit, myHand: Card[], table: Card[]): number {
|
||||
return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length;
|
||||
}
|
||||
|
||||
/** Get count of all played/seen cards */
|
||||
get playedCount(): number {
|
||||
return this.played.size;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface Capture {
|
||||
|
||||
export type PlayerIndex = 0 | 1 | 2 | 3;
|
||||
|
||||
export type Difficulty = 'beginner' | 'advanced' | 'master';
|
||||
|
||||
export interface Player {
|
||||
index: PlayerIndex;
|
||||
hand: Card[];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Phaser from 'phaser';
|
||||
import { Card, PlayerIndex, GameState } from '../game/types';
|
||||
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
||||
import {
|
||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
|
||||
} from '../game/engine';
|
||||
import { chooseMove } from '../game/ai';
|
||||
import { CardTracker } from '../game/card-tracker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout constants
|
||||
@@ -46,13 +47,20 @@ export class GameScene extends Phaser.Scene {
|
||||
private state!: GameState;
|
||||
private cardImages: Map<string, Phaser.GameObjects.Image> = new Map();
|
||||
|
||||
// Difficulty & card tracker
|
||||
private difficulty: Difficulty = 'advanced';
|
||||
private tracker: CardTracker = new CardTracker();
|
||||
|
||||
// Active player highlight
|
||||
private activeHighlightRect: Phaser.GameObjects.Graphics | null = null;
|
||||
|
||||
// Live score bar texts
|
||||
private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
||||
denari: Phaser.GameObjects.Text; prim: Phaser.GameObjects.Text;
|
||||
total: Phaser.GameObjects.Text };
|
||||
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
|
||||
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
|
||||
private hudB!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
||||
denari: Phaser.GameObjects.Text; prim: Phaser.GameObjects.Text;
|
||||
total: Phaser.GameObjects.Text };
|
||||
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
|
||||
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
|
||||
private roundText!: Phaser.GameObjects.Text;
|
||||
|
||||
// Status bar
|
||||
@@ -90,11 +98,15 @@ export class GameScene extends Phaser.Scene {
|
||||
// Create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
create(): void {
|
||||
create(data?: { difficulty?: Difficulty }): void {
|
||||
const W = this.scale.width;
|
||||
const H = this.scale.height;
|
||||
this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 };
|
||||
|
||||
// Read difficulty from scene data (MenuScene passes it)
|
||||
this.difficulty = data?.difficulty ?? 'advanced';
|
||||
this.tracker = new CardTracker();
|
||||
|
||||
this.generateParticleTextures();
|
||||
this.drawBackground(W, H);
|
||||
this.buildScoreBar(W);
|
||||
@@ -245,12 +257,12 @@ export class GameScene extends Phaser.Scene {
|
||||
this.roundText = mkTxt(W / 2, SCOREBAR_H / 2, 'Mano 1', '#ffd700', '16px');
|
||||
|
||||
// Column headers (shared, centered-ish)
|
||||
const cols = ['Scope', 'Carte', 'Denari', 'Primiera', 'TOTALE'];
|
||||
const xA = [240, 320, 410, 510, 620];
|
||||
const xB = [W - 240, W - 320, W - 410, W - 510, W - 620];
|
||||
const cols = ['Scope', 'Carte', 'Denari', '7Bello', 'Primiera', 'TOTALE'];
|
||||
const xA = [230, 295, 370, 445, 520, 610];
|
||||
const xB = [W - 230, W - 295, W - 370, W - 445, W - 520, W - 610];
|
||||
|
||||
cols.forEach((_, i) => {
|
||||
const label = ['Sc', 'Ca', 'De', 'Pr', 'Pt'][i];
|
||||
const label = ['Sc', 'Ca', 'De', '7B', 'Pr', 'Pt'][i];
|
||||
this.add.text(xA[i], SCOREBAR_H * 0.28, label, {
|
||||
fontFamily: 'monospace', fontSize: '10px', color: '#999999', resolution: 2,
|
||||
}).setOrigin(0.5).setDepth(9);
|
||||
@@ -264,15 +276,15 @@ export class GameScene extends Phaser.Scene {
|
||||
const mkB = (xi: number) => mkTxt(xB[xi], SCOREBAR_H * 0.72, '0', '#ffaaaa', '17px');
|
||||
|
||||
this.hudA = {
|
||||
scope: mkA(0), cards: mkA(1), denari: mkA(2), prim: mkA(3),
|
||||
total: this.add.text(xA[4], SCOREBAR_H * 0.72, '0', {
|
||||
scope: mkA(0), cards: mkA(1), denari: mkA(2), sette: mkA(3), prim: mkA(4),
|
||||
total: this.add.text(xA[5], SCOREBAR_H * 0.72, '0', {
|
||||
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#aaffaa',
|
||||
stroke: '#000', strokeThickness: 2, resolution: 2,
|
||||
}).setOrigin(0.5).setDepth(9),
|
||||
};
|
||||
this.hudB = {
|
||||
scope: mkB(0), cards: mkB(1), denari: mkB(2), prim: mkB(3),
|
||||
total: this.add.text(xB[4], SCOREBAR_H * 0.72, '0', {
|
||||
scope: mkB(0), cards: mkB(1), denari: mkB(2), sette: mkB(3), prim: mkB(4),
|
||||
total: this.add.text(xB[5], SCOREBAR_H * 0.72, '0', {
|
||||
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffaaaa',
|
||||
stroke: '#000', strokeThickness: 2, resolution: 2,
|
||||
}).setOrigin(0.5).setDepth(9),
|
||||
@@ -294,6 +306,8 @@ export class GameScene extends Phaser.Scene {
|
||||
const den1 = pile1.filter(c => c.suit === 'denara').length;
|
||||
const prim0 = calcPrimiera(pile0);
|
||||
const prim1 = calcPrimiera(pile1);
|
||||
const sette0 = pile0.some(c => c.suit === 'denara' && c.value === 7);
|
||||
const sette1 = pile1.some(c => c.suit === 'denara' && c.value === 7);
|
||||
|
||||
const setAnim = (txt: Phaser.GameObjects.Text, val: string | number) => {
|
||||
const v = String(val);
|
||||
@@ -305,12 +319,14 @@ export class GameScene extends Phaser.Scene {
|
||||
setAnim(this.hudA.scope, scope0);
|
||||
setAnim(this.hudA.cards, pile0.length);
|
||||
setAnim(this.hudA.denari, den0);
|
||||
setAnim(this.hudA.sette, sette0 ? '✓' : '–');
|
||||
setAnim(this.hudA.prim, prim0 > 0 ? prim0 : '-');
|
||||
setAnim(this.hudA.total, t0.totalPoints);
|
||||
|
||||
setAnim(this.hudB.scope, scope1);
|
||||
setAnim(this.hudB.cards, pile1.length);
|
||||
setAnim(this.hudB.denari, den1);
|
||||
setAnim(this.hudB.sette, sette1 ? '✓' : '–');
|
||||
setAnim(this.hudB.prim, prim1 > 0 ? prim1 : '-');
|
||||
setAnim(this.hudB.total, t1.totalPoints);
|
||||
|
||||
@@ -408,12 +424,37 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private pulseLabel(playerIdx: PlayerIndex): void {
|
||||
// Reset all
|
||||
// Reset all labels
|
||||
for (const [idx, lbl] of this.playerLabels) {
|
||||
lbl.setAlpha(idx === playerIdx ? 1 : 0.5);
|
||||
lbl.setAlpha(idx === playerIdx ? 1 : 0.4);
|
||||
}
|
||||
// Pulse active
|
||||
|
||||
// Remove old highlight
|
||||
if (this.activeHighlightRect) {
|
||||
this.activeHighlightRect.destroy();
|
||||
this.activeHighlightRect = null;
|
||||
}
|
||||
|
||||
// Draw glow rectangle behind active player label
|
||||
const lbl = this.playerLabels.get(playerIdx)!;
|
||||
const bounds = lbl.getBounds();
|
||||
const pad = 6;
|
||||
const color = teamOf(playerIdx) === 0 ? 0x00ff44 : 0xff4444;
|
||||
const gfx = this.add.graphics().setDepth(1);
|
||||
gfx.fillStyle(color, 0.25);
|
||||
gfx.fillRoundedRect(bounds.x - pad, bounds.y - pad, bounds.width + pad * 2, bounds.height + pad * 2, 6);
|
||||
gfx.lineStyle(2, color, 0.8);
|
||||
gfx.strokeRoundedRect(bounds.x - pad, bounds.y - pad, bounds.width + pad * 2, bounds.height + pad * 2, 6);
|
||||
this.activeHighlightRect = gfx;
|
||||
|
||||
// Pulse the glow
|
||||
this.tweens.add({
|
||||
targets: gfx,
|
||||
alpha: { from: 1, to: 0.4 },
|
||||
duration: 600, yoyo: true, repeat: -1, ease: 'Sine.InOut',
|
||||
});
|
||||
|
||||
// Pulse the label
|
||||
this.tweens.add({
|
||||
targets: lbl,
|
||||
scaleX: 1.2, scaleY: 1.2,
|
||||
@@ -548,7 +589,7 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private doAIMove(playerIdx: PlayerIndex): void {
|
||||
const move = chooseMove(this.state, playerIdx);
|
||||
const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker);
|
||||
this.aiThinking = false;
|
||||
this.executeMove(playerIdx, move.card, move.capture);
|
||||
}
|
||||
@@ -750,6 +791,12 @@ export class GameScene extends Phaser.Scene {
|
||||
const oldState = this.state;
|
||||
this.state = nextState;
|
||||
|
||||
// Update card tracker
|
||||
this.tracker.trackPlay(card);
|
||||
if (captureResult) {
|
||||
this.tracker.trackCapture(captureResult.captured);
|
||||
}
|
||||
|
||||
const cardImg = this.cardImages.get(card.id)!;
|
||||
cardImg.setDepth(15);
|
||||
|
||||
@@ -1318,6 +1365,7 @@ export class GameScene extends Phaser.Scene {
|
||||
const startingPlayer = ((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.teamScores[0].totalPoints = totals[0];
|
||||
this.state.teamScores[1].totalPoints = totals[1];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Phaser from 'phaser';
|
||||
import { Difficulty } from '../game/types';
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
@@ -13,7 +14,7 @@ export class MenuScene extends Phaser.Scene {
|
||||
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0);
|
||||
|
||||
// Title
|
||||
this.add.text(W / 2, H * 0.2, 'Scopone Scientifico', {
|
||||
this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '52px',
|
||||
color: '#ffd700',
|
||||
@@ -22,7 +23,7 @@ export class MenuScene extends Phaser.Scene {
|
||||
resolution: 2,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(W / 2, H * 0.32, '2 vs 2 · Tu + Compagno vs 2 AI', {
|
||||
this.add.text(W / 2, H * 0.30, '2 vs 2 · Tu + Compagno vs 2 AI', {
|
||||
fontFamily: 'serif',
|
||||
fontSize: '22px',
|
||||
color: '#ccffcc',
|
||||
@@ -37,36 +38,63 @@ export class MenuScene extends Phaser.Scene {
|
||||
'Prima squadra a 11 punti vince',
|
||||
];
|
||||
rules.forEach((line, i) => {
|
||||
this.add.text(W / 2, H * 0.44 + i * 28, line, {
|
||||
this.add.text(W / 2, H * 0.40 + i * 26, line, {
|
||||
fontFamily: 'serif',
|
||||
fontSize: '18px',
|
||||
fontSize: '17px',
|
||||
color: '#ffffff',
|
||||
resolution: 2,
|
||||
}).setOrigin(0.5);
|
||||
});
|
||||
|
||||
// Start button
|
||||
const btn = this.add.rectangle(W / 2, H * 0.72, 220, 60, 0xffd700, 1)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const btnText = this.add.text(W / 2, H * 0.72, 'INIZIA PARTITA', {
|
||||
// Difficulty selection label
|
||||
this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '22px',
|
||||
color: '#1a5c2a',
|
||||
fontSize: '20px',
|
||||
color: '#ffd700',
|
||||
resolution: 2,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
btn.on('pointerover', () => btn.setFillStyle(0xffec6e));
|
||||
btn.on('pointerout', () => btn.setFillStyle(0xffd700));
|
||||
// Difficulty buttons
|
||||
const difficulties: Array<{ label: string; value: Difficulty; color: number; hoverColor: number }> = [
|
||||
{ label: 'Principiante', value: 'beginner', color: 0x4caf50, hoverColor: 0x66bb6a },
|
||||
{ label: 'Avanzato', value: 'advanced', color: 0xff9800, hoverColor: 0xffb74d },
|
||||
{ label: 'Maestro', value: 'master', color: 0xf44336, hoverColor: 0xef5350 },
|
||||
];
|
||||
|
||||
const btnWidth = 200;
|
||||
const btnHeight = 50;
|
||||
const totalWidth = difficulties.length * btnWidth + (difficulties.length - 1) * 20;
|
||||
const startX = (W - totalWidth) / 2 + btnWidth / 2;
|
||||
|
||||
difficulties.forEach((d, i) => {
|
||||
const x = startX + i * (btnWidth + 20);
|
||||
const y = H * 0.70;
|
||||
|
||||
const btn = this.add.rectangle(x, y, btnWidth, btnHeight, d.color, 1)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.add.text(x, y, d.label, {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '20px',
|
||||
color: '#ffffff',
|
||||
stroke: '#000000',
|
||||
strokeThickness: 2,
|
||||
resolution: 2,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
btn.on('pointerover', () => btn.setFillStyle(d.hoverColor));
|
||||
btn.on('pointerout', () => btn.setFillStyle(d.color));
|
||||
btn.on('pointerdown', () => {
|
||||
this.cameras.main.fadeOut(300, 0, 30, 0);
|
||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||
this.scene.start('GameScene');
|
||||
this.scene.start('GameScene', { difficulty: d.value });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Show some face-down cards decoratively
|
||||
const positions = [
|
||||
[W * 0.1, H * 0.5], [W * 0.15, H * 0.52], [W * 0.9, H * 0.5], [W * 0.85, H * 0.52],
|
||||
[W * 0.08, H * 0.85], [W * 0.14, H * 0.87], [W * 0.92, H * 0.85], [W * 0.86, H * 0.87],
|
||||
];
|
||||
for (const [x, y] of positions) {
|
||||
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));
|
||||
|
||||
Reference in New Issue
Block a user