feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s

- Replace minimax with PIMC (Perfect Information Monte Carlo) search
- Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390)
  → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%)
  → Advanced win rate: 97.5% vs beginner AI (target ≥55%)
  → Scope gap in losses: 6.54 → 3.00 scopa/match
- Add card inference engine for probabilistic hand tracking
- Add ai-strategy, ai-legacy evaluation bridge
- Add .gitea/workflows/android-build.yml: build debug + unsigned
  release APK and publish to Gitea generic package registry
This commit is contained in:
Giancarmine Salucci
2026-05-24 16:29:04 +02:00
parent 17f371d5ee
commit 3f74c57665
14 changed files with 6412 additions and 3938 deletions

View File

@@ -0,0 +1,125 @@
name: Android Build & Publish
on:
push:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
android:
runs-on: ubuntu-latest
steps:
# ── 1. Source ────────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
# ── 2. Java ──────────────────────────────────────────────────────────────
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
# ── 3. Node.js ───────────────────────────────────────────────────────────
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
# ── 4. Android SDK ───────────────────────────────────────────────────────
- name: Install Android SDK command-line tools
run: |
SDK_DIR="$HOME/android-sdk"
echo "ANDROID_HOME=$SDK_DIR" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$SDK_DIR" >> "$GITHUB_ENV"
mkdir -p "$SDK_DIR/cmdline-tools"
# Download cmdline-tools 12.0 (build 11076708) — stable known-good version
TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
curl -fsSL "$TOOLS_URL" -o /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d "$SDK_DIR/cmdline-tools"
# The zip unpacks to "cmdline-tools/"; rename to "latest" per SDK layout
mv "$SDK_DIR/cmdline-tools/cmdline-tools" "$SDK_DIR/cmdline-tools/latest"
echo "$SDK_DIR/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "$SDK_DIR/platform-tools" >> "$GITHUB_PATH"
- name: Accept SDK licenses & install platform/build-tools
run: |
yes | sdkmanager --licenses > /dev/null
sdkmanager \
"platforms;android-36" \
"build-tools;35.0.0" \
"platform-tools"
# ── 5. Caches ────────────────────────────────────────────────────────────
- name: Cache Gradle files
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# ── 6. JS build ──────────────────────────────────────────────────────────
- name: Install JS dependencies
run: npm ci
- name: Build web assets
run: npm run build
# ── 7. Capacitor sync ────────────────────────────────────────────────────
- name: Capacitor sync android
run: npx cap sync android
# ── 8. Android build ─────────────────────────────────────────────────────
- name: Make gradlew executable
run: chmod +x android/gradlew
- name: Build Debug APK
working-directory: android
run: ./gradlew assembleDebug --no-daemon
- name: Build Release APK (unsigned — no signing key required)
working-directory: android
run: ./gradlew assembleRelease --no-daemon
# ── 9. Publish to Gitea generic package registry ─────────────────────────
- name: Publish APKs to Gitea package registry
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ github.run_number }}"
BASE="https://git.sal.giize.com/api/packages/mozempk/generic/scopone-android"
upload() {
local src="$1" dst_name="$2"
echo "→ Uploading $dst_name (version $VERSION)…"
HTTP=$(curl --silent --show-error --write-out "%{http_code}" \
-X PUT \
-H "Authorization: token $TOKEN" \
--upload-file "$src" \
"$BASE/$VERSION/$dst_name")
if [[ "$HTTP" != "20"* ]]; then
echo "✗ Upload failed — HTTP $HTTP"
exit 1
fi
echo "✓ $dst_name → $BASE/$VERSION/$dst_name"
}
upload android/app/build/outputs/apk/debug/app-debug.apk \
app-debug.apk
upload android/app/build/outputs/apk/release/app-release-unsigned.apk \
app-release-unsigned.apk
echo ""
echo "📦 Package index: $BASE/$VERSION/"

View File

@@ -1,5 +1,7 @@
import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine';
import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai';
import { chooseMove as chooseMoveOld } from './ai-legacy';
import { CardInferenceEngine } from './card-inference';
import {
AI_BENCHMARK_FIXTURES,
AIBenchmarkCriticalConcept,
@@ -199,6 +201,11 @@ const SELF_PLAY_SEAT_SWAPS = [0, 1] as const;
const SELF_PLAY_MATCH_SEEDS = Array.from({ length: 250 }, (_, index) => 1000 + index);
const MAX_SELF_PLAY_ROUNDS = 20;
const HEAD_TO_HEAD_SEEDS = Array.from({ length: 100 }, (_, i) => 2000 + i);
const HEAD_TO_HEAD_SEAT_SWAPS = [0, 1] as const;
const HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE = 0.60;
const HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE = 0.55;
interface SelfPlaySuiteConfig {
id: SelfPlaySuiteId;
label: string;
@@ -855,10 +862,195 @@ export async function runAIBenchmark(): Promise<AIBenchmarkSummary> {
};
}
interface HeadToHeadMatchResult {
suite: 'head-to-head-master' | 'head-to-head-advanced';
seed: number;
dealer: PlayerIndex;
newAITeam: 0 | 1;
newAIDifficulty: Difficulty;
winner: 0 | 1 | null;
newAIResult: 'win' | 'loss' | 'draw';
rounds: number;
totalPoints: [number, number];
}
interface HeadToHeadSuiteSummary {
suite: 'head-to-head-master' | 'head-to-head-advanced';
newAIDifficulty: Difficulty;
matches: number;
wins: number;
losses: number;
draws: number;
winRate: number;
targetWinRate: number;
passed: boolean;
results: HeadToHeadMatchResult[];
}
const HEAD_TO_HEAD_SUITE_SEED_KEYS: Record<'head-to-head-master' | 'head-to-head-advanced', number> = {
'head-to-head-master': 0x4d42,
'head-to-head-advanced': 0x4142,
};
async function simulateHeadToHeadMatch(
suite: 'head-to-head-master' | 'head-to-head-advanced',
difficulty: Difficulty,
seed: number,
newAITeam: 0 | 1,
): Promise<HeadToHeadMatchResult> {
const suiteSeedKey = HEAD_TO_HEAD_SUITE_SEED_KEYS[suite];
const initialDealer = (seed % 4) as PlayerIndex;
let state = createInitialState(initialDealer, createMulberry32(seedFromParts(suiteSeedKey, seed, 1, 0)));
const matchStartingPlayer = state.matchStartingPlayer;
const tracker = new CardTracker();
const inference = new CardInferenceEngine(tracker);
let rounds = 1;
let truncated = false;
let turnCount = 0;
while (rounds <= MAX_SELF_PLAY_ROUNDS) {
while (!state.roundOver) {
const playerIdx = state.currentPlayer;
const actingTeam = teamOf(playerIdx);
const isNewAI = actingTeam === newAITeam;
const timingSource = createSimulatedBenchmarkTimingSource();
const rng = createMulberry32(seedFromParts(suiteSeedKey, seed, rounds, turnCount, playerIdx));
let move: AIMove;
if (isNewAI) {
move = await chooseMove(state, playerIdx, difficulty, tracker, undefined, {
rng, timingSource, inference,
});
} else {
move = await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, {
rng, timingSource,
});
}
const tableBeforeMove = [...state.table];
const { nextState, capture } = applyMove(
state,
playerIdx,
move.card,
move.capture.length > 0 ? move.capture : undefined,
);
tracker.trackPlay(move.card);
if (capture) tracker.trackCapture(capture.captured);
inference.onMove(playerIdx, move, tableBeforeMove);
state = nextState;
turnCount++;
}
const outcome = getMatchOutcome(state.teamScores);
if (!outcome.continueMatch) {
break;
}
if (rounds === MAX_SELF_PLAY_ROUNDS) {
truncated = true;
break;
}
rounds++;
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
const nextDealer = nextPlayer(state.dealer);
tracker.reset();
inference.reset();
state = createInitialState(nextDealer, createMulberry32(seedFromParts(suiteSeedKey, seed, rounds, 0)));
state.matchStartingPlayer = matchStartingPlayer;
state.teamScores[0].totalPoints = totals[0];
state.teamScores[1].totalPoints = totals[1];
state.roundNumber = rounds;
}
const outcome = getMatchOutcome(state.teamScores);
const winner = outcome.winner;
const newAIResult = winner === null ? 'draw' : winner === newAITeam ? 'win' : 'loss';
void truncated; // tracked internally; not surfaced in the result interface
return {
suite,
seed,
dealer: initialDealer,
newAITeam,
newAIDifficulty: difficulty,
winner,
newAIResult,
rounds,
totalPoints: [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints],
};
}
export async function runHeadToHeadBenchmark(): Promise<HeadToHeadSuiteSummary[]> {
const configs: Array<{
suite: 'head-to-head-master' | 'head-to-head-advanced';
difficulty: Difficulty;
targetWinRate: number;
}> = [
{ suite: 'head-to-head-master', difficulty: 'master', targetWinRate: HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE },
{ suite: 'head-to-head-advanced', difficulty: 'advanced', targetWinRate: HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE },
];
const summaries: HeadToHeadSuiteSummary[] = [];
for (const { suite, difficulty, targetWinRate } of configs) {
const results: HeadToHeadMatchResult[] = [];
const totalMatches = HEAD_TO_HEAD_SEEDS.length * HEAD_TO_HEAD_SEAT_SWAPS.length;
let completedMatches = 0;
logBenchmarkProgress(`Starting ${suite} (${totalMatches} matches: ${HEAD_TO_HEAD_SEEDS.length} seeds × ${HEAD_TO_HEAD_SEAT_SWAPS.length} seat swaps).`);
for (const seed of HEAD_TO_HEAD_SEEDS) {
for (const newAITeam of HEAD_TO_HEAD_SEAT_SWAPS) {
const result = await simulateHeadToHeadMatch(suite, difficulty, seed, newAITeam);
results.push(result);
completedMatches++;
if (completedMatches === 1 || completedMatches % 25 === 0 || completedMatches === totalMatches) {
logBenchmarkProgress(
`${suite} ${completedMatches}/${totalMatches}: seed ${seed}, newAITeam ${newAITeam}, result ${result.newAIResult}, rounds ${result.rounds}.`,
);
}
}
}
const wins = results.filter(r => r.newAIResult === 'win').length;
const losses = results.filter(r => r.newAIResult === 'loss').length;
const draws = results.filter(r => r.newAIResult === 'draw').length;
const winRate = results.length === 0 ? 0 : wins / results.length;
summaries.push({
suite,
newAIDifficulty: difficulty,
matches: results.length,
wins,
losses,
draws,
winRate,
targetWinRate,
passed: winRate >= targetWinRate,
results,
});
}
return summaries;
}
async function runBenchmarkCli(): Promise<void> {
const summary = await runAIBenchmark();
logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.');
printReadableSummary(summary);
logBenchmarkProgress('Starting HEAD_TO_HEAD benchmark (new AI vs legacy AI)...');
const h2hSuites = await runHeadToHeadBenchmark();
for (const h2h of h2hSuites) {
console.log(`\nHEAD_TO_HEAD: ${h2h.suite} (${h2h.matches} games)`);
console.log(`New AI wins: ${h2h.wins} (${formatPercentage(h2h.winRate)})`);
console.log(`Legacy AI wins: ${h2h.losses} (${formatPercentage(h2h.matches === 0 ? 0 : h2h.losses / h2h.matches)})`);
console.log(`Ties: ${h2h.draws}`);
console.log(`Target win rate: ${formatPercentage(h2h.targetWinRate)}${h2h.passed ? 'PASS' : 'FAIL'}`);
}
}
if (typeof window === 'undefined') {

213
src/game/ai-h2h-diagnose.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* Diagnostic H2H: logs category breakdown for every LOSS (master difficulty).
* Run with: npx tsx src/game/ai-h2h-diagnose.ts
*/
import { chooseMove } from './ai';
import { chooseMove as chooseMoveOld } from './ai-legacy';
import { CardTracker } from './card-tracker';
import { CardInferenceEngine } from './card-inference';
import { applyMove, teamOf, nextPlayer, createInitialState, getMatchOutcome } from './engine';
import { AIMove, Difficulty, GameState, PlayerIndex } from './types';
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), s | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedFromParts(...parts: number[]): number {
let h = 2166136261;
for (const p of parts) { h ^= p >>> 0; h = Math.imul(h, 16777619); }
return h >>> 0;
}
function simulatedTiming() {
let t = 0;
return { now: () => t, advance: (ms: number) => { t += ms; return t; }, isSimulated: true as const };
}
interface MatchDetail {
seed: number;
newAITeam: 0 | 1;
result: 'new' | 'old' | 'draw';
newPts: number;
oldPts: number;
// cumulative per-match category wins: +1 new won, -1 old won, 0 tied
carte: number; // +1 = new won
denari: number;
settebello: number;
primiera: number;
scopeNew: number;
scopeOld: number;
}
async function runMatch(
difficulty: Difficulty,
seed: number,
newAITeam: 0 | 1,
): Promise<MatchDetail> {
const SUITE_KEY = 0xabcd1234;
const MAX_ROUNDS = 20;
const initialDealer = (seed % 4) as PlayerIndex;
let state = createInitialState(
initialDealer,
mulberry32(seedFromParts(SUITE_KEY, seed, 1, 0)),
);
const matchStartingPlayer = state.matchStartingPlayer;
const tracker = new CardTracker();
const inference = new CardInferenceEngine(tracker);
let rounds = 1;
let turn = 0;
// Cumulative category wins across all rounds
let carte = 0, denari = 0, settebello = 0, primiera = 0;
let scopeNew = 0, scopeOld = 0;
while (rounds <= MAX_ROUNDS) {
while (!state.roundOver) {
const playerIdx = state.currentPlayer;
const isNew = teamOf(playerIdx) === newAITeam;
const timing = simulatedTiming();
const rng = mulberry32(seedFromParts(SUITE_KEY, seed, rounds, turn, playerIdx));
const move: AIMove = isNew
? await chooseMove(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing, inference })
: await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing });
const tableBeforeMove = [...state.table];
const { nextState, capture } = applyMove(
state, playerIdx, move.card,
move.capture.length > 0 ? move.capture : undefined,
);
tracker.trackPlay(move.card);
if (capture) tracker.trackCapture(capture.captured);
inference.onMove(playerIdx, move, tableBeforeMove);
state = nextState;
turn++;
}
// Accumulate per-round category outcomes
const ts = state.teamScores;
const newT = newAITeam;
const oldT = (1 - newAITeam) as 0 | 1;
// Cards
const newCards = ts[newT].cards, oldCards = ts[oldT].cards;
if (newCards > 20) carte += 1;
else if (oldCards > 20) carte -= 1;
// Denari
const newDen = ts[newT].denari, oldDen = ts[oldT].denari;
if (newDen >= 6) denari += 1;
else if (oldDen >= 6) denari -= 1;
// Settebello
settebello += ts[newT].settebello ? 1 : -1;
// Primiera
const newPrim = ts[newT].primiera, oldPrim = ts[oldT].primiera;
if (newPrim > oldPrim) primiera += 1;
else if (oldPrim > newPrim) primiera -= 1;
// Scope this round
scopeNew += ts[newT].scope;
scopeOld += ts[oldT].scope;
const outcome = getMatchOutcome(state.teamScores);
if (!outcome.continueMatch) break;
if (rounds === MAX_ROUNDS) break;
rounds++;
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
tracker.reset();
inference.reset();
state = createInitialState(
nextPlayer(state.dealer),
mulberry32(seedFromParts(SUITE_KEY, seed, rounds, 0)),
);
state.matchStartingPlayer = matchStartingPlayer;
state.teamScores[0].totalPoints = totals[0];
state.teamScores[1].totalPoints = totals[1];
state.roundNumber = rounds;
}
const outcome = getMatchOutcome(state.teamScores);
const result = outcome.winner === null ? 'draw' : outcome.winner === newAITeam ? 'new' : 'old';
return {
seed,
newAITeam,
result,
newPts: state.teamScores[newAITeam].totalPoints,
oldPts: state.teamScores[1 - newAITeam as 0 | 1].totalPoints,
carte, denari, settebello, primiera, scopeNew, scopeOld,
};
}
async function main() {
const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i);
const SWAPS = [0, 1] as const;
const difficulty: Difficulty = 'master';
const losses: MatchDetail[] = [];
const wins: MatchDetail[] = [];
let done = 0;
const total = SEEDS.length * SWAPS.length;
console.log(`\nDIAGNOSTIC ${difficulty.toUpperCase()}${total} matches\n`);
for (const seed of SEEDS) {
for (const newAITeam of SWAPS) {
const d = await runMatch(difficulty, seed, newAITeam);
if (d.result === 'old') losses.push(d);
else wins.push(d);
done++;
if (done % 10 === 0 || done === total) {
console.log(` [${done}/${total}] wins=${wins.length} losses=${losses.length}`);
}
}
}
// --- Per-match loss report ---
console.log(`\n=== LOSSES (${losses.length}) ===`);
console.log(`${'seed'.padEnd(6)} ${'team'.padEnd(5)} ${'score'.padEnd(8)} ${'carte'.padEnd(7)} ${'denari'.padEnd(8)} ${'sette'.padEnd(7)} ${'prim'.padEnd(6)} ${'scopeN'.padEnd(8)} ${'scopeO'}`);
for (const d of losses) {
const score = `${d.newPts}-${d.oldPts}`;
const sign = (n: number) => n > 0 ? '+new' : n < 0 ? '+old' : 'tie';
console.log(
`${String(d.seed).padEnd(6)} t${d.newAITeam} ${score.padEnd(8)} ` +
`${sign(d.carte).padEnd(7)} ${sign(d.denari).padEnd(8)} ${sign(d.settebello).padEnd(7)} ` +
`${sign(d.primiera).padEnd(6)} ${String(d.scopeNew).padEnd(8)} ${d.scopeOld}`,
);
}
// --- Aggregate: across all losses, how often did legacy win each category? ---
console.log('\n=== CATEGORY LOSS FREQUENCY (across all lost matches) ===');
const countOldWon = (arr: MatchDetail[], key: keyof Pick<MatchDetail, 'carte'|'denari'|'settebello'|'primiera'>) =>
arr.filter(d => (d[key] as number) < 0).length;
const categories = ['carte', 'denari', 'settebello', 'primiera'] as const;
for (const cat of categories) {
const oldWins = countOldWon(losses, cat);
const newWins = losses.filter(d => (d[cat] as number) > 0).length;
const tied = losses.length - oldWins - newWins;
console.log(` ${cat.padEnd(12)}: old won ${oldWins}/${losses.length}, new won ${newWins}/${losses.length}, tied ${tied}/${losses.length}`);
}
const avgScopeGap = losses.reduce((s, d) => s + (d.scopeOld - d.scopeNew), 0) / (losses.length || 1);
console.log(` ${'scope gap'.padEnd(12)}: avg old advantage ${avgScopeGap.toFixed(2)} scopa/match`);
// --- Same for wins, to compare ---
console.log('\n=== CATEGORY WIN FREQUENCY (across all won matches) ===');
for (const cat of categories) {
const newWins = wins.filter(d => (d[cat] as number) > 0).length;
const oldWins = wins.filter(d => (d[cat] as number) < 0).length;
const tied = wins.length - newWins - oldWins;
console.log(` ${cat.padEnd(12)}: new won ${newWins}/${wins.length}, old won ${oldWins}/${wins.length}, tied ${tied}/${wins.length}`);
}
const avgScopeGapW = wins.reduce((s, d) => s + (d.scopeNew - d.scopeOld), 0) / (wins.length || 1);
console.log(` ${'scope gap'.padEnd(12)}: avg new advantage ${avgScopeGapW.toFixed(2)} scopa/match`);
}
main().catch(console.error);

137
src/game/ai-h2h-quick.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Quick HEAD_TO_HEAD: new AI vs legacy AI — 20 seeds × 2 seat swaps = 40 matches each.
* Run with: tsx src/game/ai-h2h-quick.ts
*/
import { chooseMove } from './ai';
import { chooseMove as chooseMoveOld } from './ai-legacy';
import { CardTracker } from './card-tracker';
import { CardInferenceEngine } from './card-inference';
import { applyMove, teamOf, nextPlayer, createInitialState, getMatchOutcome } from './engine';
import { AIMove, Difficulty, GameState, PlayerIndex } from './types';
// ---- seeded RNG -------------------------------------------------------
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), s | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedFromParts(...parts: number[]): number {
let h = 2166136261;
for (const p of parts) { h ^= p >>> 0; h = Math.imul(h, 16777619); }
return h >>> 0;
}
// ---- simulated timing (same as benchmark) ----------------------------
function simulatedTiming() {
let t = 0;
return {
now: () => t,
advance: (ms: number) => { t += ms; return t; },
isSimulated: true as const,
};
}
// ---- single match ----------------------------------------------------
async function runMatch(
difficulty: Difficulty,
seed: number,
newAITeam: 0 | 1,
): Promise<'new' | 'old' | 'draw'> {
const SUITE_KEY = 0xabcd1234;
const MAX_ROUNDS = 20;
const initialDealer = (seed % 4) as PlayerIndex;
let state = createInitialState(
initialDealer,
mulberry32(seedFromParts(SUITE_KEY, seed, 1, 0)),
);
const matchStartingPlayer = state.matchStartingPlayer;
const tracker = new CardTracker();
const inference = new CardInferenceEngine(tracker);
let rounds = 1;
let turn = 0;
while (rounds <= MAX_ROUNDS) {
while (!state.roundOver) {
const playerIdx = state.currentPlayer;
const isNew = teamOf(playerIdx) === newAITeam;
const timing = simulatedTiming();
const rng = mulberry32(seedFromParts(SUITE_KEY, seed, rounds, turn, playerIdx));
const move: AIMove = isNew
? await chooseMove(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing, inference })
: await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing });
const tableBeforeMove = [...state.table];
const { nextState, capture } = applyMove(
state, playerIdx, move.card,
move.capture.length > 0 ? move.capture : undefined,
);
tracker.trackPlay(move.card);
if (capture) tracker.trackCapture(capture.captured);
inference.onMove(playerIdx, move, tableBeforeMove);
state = nextState;
turn++;
}
const outcome = getMatchOutcome(state.teamScores);
if (!outcome.continueMatch) break;
if (rounds === MAX_ROUNDS) break;
rounds++;
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
tracker.reset();
inference.reset();
state = createInitialState(
nextPlayer(state.dealer),
mulberry32(seedFromParts(SUITE_KEY, seed, rounds, 0)),
);
state.matchStartingPlayer = matchStartingPlayer;
state.teamScores[0].totalPoints = totals[0];
state.teamScores[1].totalPoints = totals[1];
state.roundNumber = rounds;
}
const outcome = getMatchOutcome(state.teamScores);
if (outcome.winner === null) return 'draw';
return outcome.winner === newAITeam ? 'new' : 'old';
}
// ---- main ------------------------------------------------------------
async function main() {
const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i);
const SWAPS = [0, 1] as const;
for (const difficulty of ['master', 'advanced'] as Difficulty[]) {
let wins = 0, losses = 0, draws = 0;
const total = SEEDS.length * SWAPS.length;
console.log(`\nH2H ${difficulty.toUpperCase()}${total} matches`);
for (const seed of SEEDS) {
for (const newAITeam of SWAPS) {
const result = await runMatch(difficulty, seed, newAITeam);
if (result === 'new') wins++;
else if (result === 'old') losses++;
else draws++;
const done = wins + losses + draws;
if (done === 1 || done % 10 === 0 || done === total) {
const pct = ((wins / done) * 100).toFixed(1);
console.log(` [${done}/${total}] new=${wins} old=${losses} draw=${draws} new-win%=${pct}%`);
}
}
}
const target = difficulty === 'master' ? 60 : 55;
const winRate = (wins / total) * 100;
const pass = winRate >= target;
console.log(`RESULT: new AI wins ${winRate.toFixed(1)}% — target ≥${target}% — ${pass ? '✓ PASS' : '✗ FAIL'}`);
}
}
main().catch(console.error);

4222
src/game/ai-legacy.ts Normal file

File diff suppressed because it is too large Load Diff

629
src/game/ai-pimc.ts Normal file
View File

@@ -0,0 +1,629 @@
// ---------------------------------------------------------------------------
// PIMC — Perfect Information Monte Carlo Search Engine
// For each root move: generate D determinizations, run α-β per determinization,
// score = 0.7 × normalizedAvg + 0.3 × winRate
// ---------------------------------------------------------------------------
import { GameState, PlayerIndex, Card, Suit, SUITS, AIMove, PRIMIERA_VALUES } from './types';
import { applyMove, findCaptures, cloneState, teamOf, nextPlayer } from './engine';
import { CardInferenceEngine } from './card-inference';
import { CategoryStates, ParityState } from './ai-strategy';
import { evaluateTeamPositionPIMC, quickEvalRootMovePIMC, generateSamplesForPIMC } from './ai-legacy';
import { CardTracker } from './card-tracker';
// PIMC systematically gets fewer scope than legacy because the alpha-beta search trades
// guaranteed scope for material gains it values at >390. Boost the accumulated scope
// weight from 390 (in evaluateTeamPositionPIMC) to 540 so scopa-taking is preferred
// unless the material advantage exceeds 150 extra points.
const PIMC_SCOPE_BOOST = 150; // effective scope weight: 390 + 150 = 540
// Correct the 150-point gap between scoreKnownImmediateCapturePressure's scopa bonus (240)
// and the actual match point value (390). In determinized PIMC states, all hands are exact,
// so we can directly check which upcoming players hold a scopa card and apply the delta.
// The weights [1, 0.72, 0.44] mirror UPCOMING_TABLE_EXPOSURE_WEIGHTS in ai-legacy.ts.
const PIMC_SCOPA_CORRECTION = 150; // 390 - 240
const PIMC_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const;
function leafEval(
state: GameState,
maximizingTeam: 0 | 1,
rootPlayer: PlayerIndex | undefined,
): number {
let score = evaluateTeamPositionPIMC(state, maximizingTeam, rootPlayer);
// Boost accumulated scope count beyond the 390 already in evaluateTeamPositionPIMC.
let myScope = 0, oppScope = 0;
for (let p = 0; p < 4; p++) {
if (teamOf(p as PlayerIndex) === maximizingTeam) myScope += state.players[p].scope;
else oppScope += state.players[p].scope;
}
score += (myScope - oppScope) * PIMC_SCOPE_BOOST;
if (state.table.length > 0) {
let p = state.currentPlayer;
for (const w of PIMC_EXPOSURE_WEIGHTS) {
if (state.players[p].hand.length > 0) {
const canScopa = state.players[p].hand.some(c => {
const caps = findCaptures(c, state.table);
return caps.some(cap => cap.length === state.table.length);
});
if (canScopa) {
score += Math.round((teamOf(p) === maximizingTeam ? 1 : -1) * PIMC_SCOPA_CORRECTION * w);
}
}
p = nextPlayer(p);
}
}
return score;
}
export interface PIMCOptions {
determinizations: number; // default 12
maxDepthMidgame: number; // default 5
maxDepthEndgame: number; // default 8
timeBudgetMs: number; // default 4300
stabilityWeight: number; // default 0.3
timingSource?: { now(): number }; // optional; defaults to Date.now
}
export interface PIMCMoveScore {
move: AIMove;
averageScore: number;
winRate: number;
pimcScore: number;
}
export type RandomSource = () => number;
export const DEFAULT_PIMC_OPTIONS: PIMCOptions = {
determinizations: 12,
maxDepthMidgame: 5,
maxDepthEndgame: 8,
timeBudgetMs: 4300,
stabilityWeight: 0.3,
};
// ---------------------------------------------------------------------------
// Main PIMC Search Entry Point
// ---------------------------------------------------------------------------
export function pimcSearch(
state: GameState,
playerIdx: PlayerIndex,
legalMoves: AIMove[],
inference: CardInferenceEngine,
categoryStates: CategoryStates,
parityState: ParityState,
options: Partial<PIMCOptions> = {},
rng: RandomSource = Math.random,
tracker?: CardTracker,
): PIMCMoveScore[] {
const opts: PIMCOptions = { ...DEFAULT_PIMC_OPTIONS, ...options };
if (legalMoves.length === 0) return [];
if (legalMoves.length === 1) {
return [{ move: legalMoves[0], averageScore: 0, winRate: 1, pimcScore: 1 }];
}
const nowFn: () => number = opts.timingSource ? () => opts.timingSource!.now() : Date.now;
const deadline = nowFn() + opts.timeBudgetMs;
const myTeam = teamOf(playerIdx);
const myHand = state.players[playerIdx].hand;
const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
const isEndgame = totalRemaining <= 16;
const depth = isEndgame ? opts.maxDepthEndgame : opts.maxDepthMidgame;
// Accumulators per move
const scoreSums = new Map<string, number>();
const winCounts = new Map<string, number>();
const trialCounts = new Map<string, number>();
for (const m of legalMoves) {
const key = moveKey(m);
scoreSums.set(key, 0);
winCounts.set(key, 0);
trialCounts.set(key, 0);
}
let completedDeterminizations = 0;
// Killer moves shared across ALL determinizations — structural patterns
// (e.g. always dump sevens, avoid giving scopa) transfer across hand assignments.
const killers: (AIMove | null)[][] = Array.from({ length: depth + 2 }, () => [null, null]);
// Generate determinized game states using the legacy's stratified bucketing algorithm.
// Much more accurate than random inference sampling: assigns high-value cards (7s, denari)
// to the most likely holders. buildExactSampleStates (triggered in deep endgame) can
// return UP TO 48 states; cap to opts.determinizations to keep per-move time predictable.
const determinizations = generateSamplesForPIMC(
state, playerIdx, tracker, opts.determinizations, rng, undefined,
).slice(0, opts.determinizations);
for (const det of determinizations) {
if (nowFn() >= deadline) break;
// Pre-sort root moves by the FULL legacy quickEval (position + scoreMoveObjectiveBias)
// when a tracker is available. This directly uses the legacy AI's tactical intelligence
// for root ordering, which is the key advantage the legacy AI has over plain PIMC.
// Without tracker, fall back to evaluateTeamPositionPIMC.
const rootMoveOrder = legalMoves.map(move => {
const score = tracker
? quickEvalRootMovePIMC(move, det, playerIdx, tracker)
: (() => { const { nextState: ns } = applyMove(det, playerIdx, move.card, move.capture); return evaluateTeamPositionPIMC(ns, myTeam); })();
return { move, score };
});
rootMoveOrder.sort((a, b) => b.score - a.score);
const sortedMoves = rootMoveOrder.map(x => x.move);
// Score each move under this determinization.
// Cross-move α: after each root move, if its score improves α, subsequent moves
// get a tighter α window — mirroring the legacy master's root-level PVS.
let sampleAlpha = -Infinity;
let bestScoreThisDet = -Infinity;
const moveScoresThisDet = new Map<string, number>();
for (const move of sortedMoves) {
if (nowFn() >= deadline) break;
const key = moveKey(move);
// Apply move to determinized state
const { nextState: stateAfterMove } = applyMove(det, playerIdx, move.card, move.capture);
// Run α-β minimax. Use sampleAlpha (cross-move α) for pruning.
const score = alphaBetaPIMC(
stateAfterMove,
depth - 1,
sampleAlpha,
Infinity,
myTeam,
killers,
deadline,
nowFn,
playerIdx,
tracker,
);
// Update cross-move α — subsequent root moves must beat this to be relevant
if (score > sampleAlpha) sampleAlpha = score;
moveScoresThisDet.set(key, score);
scoreSums.set(key, (scoreSums.get(key) ?? 0) + score);
trialCounts.set(key, (trialCounts.get(key) ?? 0) + 1);
if (score > bestScoreThisDet) bestScoreThisDet = score;
}
// Track win counts (how many moves tied for best)
for (const move of legalMoves) {
const key = moveKey(move);
const s = moveScoresThisDet.get(key) ?? -Infinity;
if (s >= bestScoreThisDet - 1) {
winCounts.set(key, (winCounts.get(key) ?? 0) + 1);
}
}
completedDeterminizations++;
}
if (completedDeterminizations === 0) completedDeterminizations = 1;
// Compute final PIMC scores
const results: PIMCMoveScore[] = legalMoves.map(move => {
const key = moveKey(move);
const trials = trialCounts.get(key) ?? 1;
const avgScore = (scoreSums.get(key) ?? 0) / trials;
const winRate = (winCounts.get(key) ?? 0) / completedDeterminizations;
const pimcScore = (1 - opts.stabilityWeight) * avgScore + opts.stabilityWeight * winRate * 1000;
return { move, averageScore: avgScore, winRate, pimcScore };
});
results.sort((a, b) => b.pimcScore - a.pimcScore);
return results;
}
// ---------------------------------------------------------------------------
// Determinization Builder
// ---------------------------------------------------------------------------
function buildDeterminizedState(
state: GameState,
playerIdx: PlayerIndex,
inference: CardInferenceEngine,
myHand: import('./types').Card[],
rng: RandomSource,
): GameState {
const det = cloneState(state);
const table = state.table;
// Collect all cards that need to be assigned to other players
const assignedIds = new Set<string>(myHand.map(c => c.id));
for (const c of table) assignedIds.add(c.id);
// For each other player with cards, sample from constrained unseen
for (let i = 0; i < 4; i++) {
if (i === playerIdx) continue;
const p = state.players[i];
if (p.hand.length === 0) continue;
const constrained = inference.getLikelyHandForSampling(
i as PlayerIndex,
p.hand.length,
myHand,
table,
rng,
).filter(c => !assignedIds.has(c.id));
// Take as many as we can from constrained, pad with random if needed
const assigned = constrained.slice(0, p.hand.length);
// If not enough, fill with random unseen not yet assigned
if (assigned.length < p.hand.length) {
const allUnseen = inference.getConstrainedUnseen(i as PlayerIndex, myHand, table)
.filter(c => !assignedIds.has(c.id) && !assigned.some(a => a.id === c.id));
// Shuffle allUnseen
for (let j = allUnseen.length - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
[allUnseen[j], allUnseen[k]] = [allUnseen[k], allUnseen[j]];
}
while (assigned.length < p.hand.length && allUnseen.length > 0) {
assigned.push(allUnseen.shift()!);
}
}
// Assign to determinized state
det.players[i].hand = assigned;
for (const c of assigned) assignedIds.add(c.id);
}
return det;
}
// ---------------------------------------------------------------------------
// α-β Minimax for PIMC
// Uses 1-ply eval ordering at top levels (depth >= 3) for good pruning without
// excessive overhead, PVS for principal-variation search efficiency, and
// killer moves at all levels for cheap but effective ordering.
// ---------------------------------------------------------------------------
function movesMatch(a: AIMove, b: AIMove): boolean {
if (a.card.id !== b.card.id) return false;
if (a.capture.length !== b.capture.length) return false;
const bIds = new Set(b.capture.map(c => c.id));
return a.capture.every(c => bIds.has(c.id));
}
function applyKillerOrdering(moves: AIMove[], killerSlot: (AIMove | null)[]): AIMove[] {
// Captures are already first; within non-captures, try killers first
const captures: AIMove[] = [];
const nonCaps: AIMove[] = [];
for (const m of moves) {
(m.capture.length > 0 ? captures : nonCaps).push(m);
}
if (nonCaps.length === 0) return captures;
const killerFirst: AIMove[] = [];
const rest: AIMove[] = [];
for (const m of nonCaps) {
const isKiller = killerSlot.some(k => k !== null && movesMatch(m, k));
(isKiller ? killerFirst : rest).push(m);
}
return [...captures, ...killerFirst, ...rest];
}
function alphaBetaPIMC(
state: GameState,
depth: number,
alpha: number,
beta: number,
maximizingTeam: 0 | 1,
killers: (AIMove | null)[][], // indexed by depth
deadline: number,
nowFn: () => number,
rootPlayer?: PlayerIndex,
tracker?: CardTracker,
): number {
// Terminal / leaf
if (state.roundOver || state.gameOver || nowFn() >= deadline) {
return leafEval(state, maximizingTeam, rootPlayer);
}
if (depth === 0) {
return leafEval(state, maximizingTeam, rootPlayer);
}
const currentPlayer = state.currentPlayer;
const currentTeam = teamOf(currentPlayer);
const isMaximizing = currentTeam === maximizingTeam;
const hand = state.players[currentPlayer].hand;
if (hand.length === 0) {
return leafEval(state, maximizingTeam, rootPlayer);
}
// Generate candidate moves (capture-first from movePIMCOrderScore)
const rawMoves = generateMovesForPIMC(state, currentPlayer);
if (rawMoves.length === 0) {
return leafEval(state, maximizingTeam, rootPlayer);
}
// Move ordering: at depth ≥ 3 use 1-ply leafEval with scopa-gift penalty (expensive but
// worth it near the root). At depth 12 fall back to cheap killer heuristic.
let orderedMoves: AIMove[];
if (rawMoves.length > 1) {
if (depth >= 3) {
const nextP = nextPlayer(currentPlayer);
const nextIsOpp = teamOf(nextP) !== teamOf(currentPlayer);
const scored = rawMoves.map(m => {
const { nextState: ns } = applyMove(state, currentPlayer, m.card, m.capture);
let s = leafEval(ns, maximizingTeam, rootPlayer);
if (isMaximizing && nextIsOpp && ns.table.length > 0 && ns.players[nextP].hand.length > 0) {
const giftsScopa = ns.players[nextP].hand.some(c => {
const caps = findCaptures(c, ns.table);
return caps.some(cap => cap.length === ns.table.length);
});
if (giftsScopa) s -= 720;
}
return { m, s };
});
scored.sort((a, b) => isMaximizing ? b.s - a.s : a.s - b.s);
orderedMoves = scored.map(x => x.m);
} else {
const slot = depth < killers.length ? killers[depth] : [null, null];
orderedMoves = applyKillerOrdering(rawMoves, slot);
}
} else {
orderedMoves = rawMoves;
}
let bestScore = isMaximizing ? -Infinity : Infinity;
let bestMove: AIMove | null = null;
let isFirstMove = true;
for (const move of orderedMoves) {
const { nextState: next } = applyMove(state, currentPlayer, move.card, move.capture);
let score: number;
if (isFirstMove) {
// Full-window search on expected best move
score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
} else if (isMaximizing) {
// PVS: null-window scout, re-search if it fails high
score = alphaBetaPIMC(next, depth - 1, alpha, alpha + 1, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
if (score > alpha && score < beta) {
score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
}
} else {
// Minimizing: null-window scout, re-search if fails low
score = alphaBetaPIMC(next, depth - 1, beta - 1, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
if (score < beta && score > alpha) {
score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
}
}
if (isMaximizing) {
if (score > bestScore) { bestScore = score; bestMove = move; }
if (score > alpha) alpha = score;
} else {
if (score < bestScore) { bestScore = score; bestMove = move; }
if (score < beta) beta = score;
}
if (beta <= alpha) {
// β-cutoff: store killer if this is a non-capture (quiet) move
if (bestMove && bestMove.capture.length === 0 && depth < killers.length) {
const slot = killers[depth];
if (!slot[0] || !movesMatch(slot[0], bestMove)) {
slot[1] = slot[0];
slot[0] = bestMove;
}
}
break;
}
isFirstMove = false;
}
return bestScore;
}
// ---------------------------------------------------------------------------
// Move Generator (for PIMC tree — all hands are known)
// ---------------------------------------------------------------------------
/**
* Heuristic score for α-β move ordering.
* Captures ranked before dumps; within captures, high-value cards first.
* Good ordering → earlier α/β cutoffs → effectively deeper search.
*/
function movePIMCOrderScore(move: AIMove, table: Card[]): number {
if (move.capture.length === 0) return 0;
let score = 1000; // All captures beat all dumps
const allCards = [move.card, ...move.capture];
for (const c of allCards) {
if (c.suit === 'denara' && c.value === 7) score += 500; // Settebello capture
else if (c.suit === 'denara') score += 100;
else if (c.value === 7) score += 80;
score += PRIMIERA_VALUES[c.value] ?? 10;
}
if (move.capture.length === table.length) score += 300; // Scopa
return score;
}
function generateMovesForPIMC(state: GameState, playerIdx: PlayerIndex): AIMove[] {
const hand = state.players[playerIdx].hand;
const table = state.table;
const moves: AIMove[] = [];
for (const card of hand) {
const captures = findCaptures(card, table);
if (captures.length > 0) {
for (const cap of captures) {
moves.push({ card, capture: cap });
}
} else {
moves.push({ card, capture: [] });
}
}
// Initial capture-first sort (cheap; 1-ply eval in alphaBetaPIMC will refine this)
if (moves.length > 1) {
moves.sort((a, b) => movePIMCOrderScore(b, table) - movePIMCOrderScore(a, table));
}
return moves;
}
// ---------------------------------------------------------------------------
// Majority-race helper (mirrors legacy scoreMajorityRace)
// ---------------------------------------------------------------------------
function scoreMajorityRaceLocal(
myValue: number,
oppValue: number,
target: number,
unitWeight: number,
thresholdBonus: number,
): number {
let s = (myValue - oppValue) * unitWeight;
if (myValue >= target && oppValue < target) {
s += thresholdBonus;
} else if (oppValue >= target && myValue < target) {
s -= thresholdBonus;
} else {
const myDist = Math.max(0, target - myValue);
const oppDist = Math.max(0, target - oppValue);
s += (oppDist - myDist) * Math.round(unitWeight * 0.75);
}
return s;
}
// ---------------------------------------------------------------------------
// Enhanced Team Evaluation — computed from raw state, no stale context
// ---------------------------------------------------------------------------
function evaluateTeamPositionEnhanced(
state: GameState,
team: 0 | 1,
_categoryStates?: CategoryStates | null,
_parityState?: ParityState | null,
): number {
const opp = team === 0 ? 1 : 0;
const myScore = state.teamScores[team];
const oppScore = state.teamScores[opp];
// Build team snapshots in a single pass
let myCards = 0, myDenari = 0, mySettebello = false, myScope = 0;
let myPrimiera = 0, myPrimieraSuits = 0, mySevens = 0, mySevenSuits = 0, mySixes = 0, myAces = 0;
let oppCards = 0, oppDenari = 0, oppSettebello = false, oppScope = 0;
let oppPrimiera = 0, oppPrimieraSuits = 0, oppSevens = 0, oppSevenSuits = 0, oppSixes = 0, oppAces = 0;
const myBestBySuit: Partial<Record<Suit, number>> = {};
const oppBestBySuit: Partial<Record<Suit, number>> = {};
const mySevenSuitsSet = new Set<Suit>();
const oppSevenSuitsSet = new Set<Suit>();
for (let i = 0; i < 4; i++) {
const isMine = teamOf(i as PlayerIndex) === team;
const p = state.players[i];
if (isMine) myScope += p.scope; else oppScope += p.scope;
const bestBySuit = isMine ? myBestBySuit : oppBestBySuit;
const sevenSet = isMine ? mySevenSuitsSet : oppSevenSuitsSet;
for (const card of p.pile) {
const primScore = PRIMIERA_VALUES[card.value] ?? 10;
if (isMine) {
myCards++;
if (card.suit === 'denara') { myDenari++; if (card.value === 7) mySettebello = true; }
if (card.value === 7) { mySevens++; sevenSet.add(card.suit); }
if (card.value === 6) mySixes++;
if (card.value === 1) myAces++;
} else {
oppCards++;
if (card.suit === 'denara') { oppDenari++; if (card.value === 7) oppSettebello = true; }
if (card.value === 7) { oppSevens++; sevenSet.add(card.suit); }
if (card.value === 6) oppSixes++;
if (card.value === 1) oppAces++;
}
const best = bestBySuit[card.suit] ?? 0;
if (primScore > best) bestBySuit[card.suit] = primScore;
}
}
for (const suit of SUITS) {
const myS = myBestBySuit[suit] ?? 0;
const oppS = oppBestBySuit[suit] ?? 0;
if (myS > 0) { myPrimiera += myS; myPrimieraSuits++; }
if (oppS > 0) { oppPrimiera += oppS; oppPrimieraSuits++; }
}
mySevenSuits = mySevenSuitsSet.size;
oppSevenSuits = oppSevenSuitsSet.size;
const totalPlayed = myCards + oppCards;
const phase = Math.min(1, totalPlayed / 28);
let score = 0;
// Match point leadership (dominant term)
const matchWeight = (myScore.totalPoints >= 9 || oppScore.totalPoints >= 9) ? 360 : 260;
score += (myScore.totalPoints - oppScore.totalPoints) * (matchWeight + phase * 40);
if (myScore.totalPoints >= 10 && oppScore.totalPoints < 10) score += 260;
if (oppScore.totalPoints >= 10 && myScore.totalPoints < 10) score -= 260;
// Scope
score += (myScope - oppScope) * 390;
// Settebello
if (mySettebello) score += 420;
if (oppSettebello) score -= 420;
// Cards majority race (target: 21)
score += scoreMajorityRaceLocal(myCards, oppCards, 21, Math.round(24 + phase * 22), 240);
// Denari majority race (target: 6)
score += scoreMajorityRaceLocal(myDenari, oppDenari, 6, Math.round(70 + phase * 22), 220);
// Primiera — best-per-suit totals + suit coverage + sevens/sixes/aces
score += (myPrimiera - oppPrimiera) * Math.round(4.5 + phase * 3);
score += (myPrimieraSuits - oppPrimieraSuits) * 124;
if (myPrimieraSuits === 4 && oppPrimieraSuits < 4) score += 180;
if (oppPrimieraSuits === 4 && myPrimieraSuits < 4) score -= 180;
score += (mySevenSuits - oppSevenSuits) * 92;
score += (mySevens - oppSevens) * 68;
score += (mySixes - oppSixes) * 16;
score += (myAces - oppAces) * 12;
// Table ownership: last capturing team gets all remaining table cards at round end
if (state.table.length > 0 && state.lastCapturTeam !== null) {
const cardsRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75;
let tableValue = 0;
for (const c of state.table) {
let w = 18 + (PRIMIERA_VALUES[c.value] ?? 10) * 3;
if (c.suit === 'denara') w += 42;
if (c.value === 7) w += 44;
if (c.suit === 'denara' && c.value === 7) w += 120;
tableValue += w;
}
score += (state.lastCapturTeam === team ? 1 : -1) * Math.round(tableValue * urgency);
}
// Table exposure: coins and 7s on table reward the team that plays next
if (state.table.length > 0) {
const nextTeamSign = teamOf(state.currentPlayer) === team ? 1 : -1;
let pressure = 0;
let shortTable = state.table.length <= 2;
for (const c of state.table) {
if (c.suit === 'denara') pressure += 34;
if (c.value === 7) pressure += 42;
if (c.suit === 'denara' && c.value === 7) pressure += 120;
}
if (shortTable) pressure += 36;
score += nextTeamSign * Math.round(pressure * (shortTable ? 1.2 : 0.8));
}
return score;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function moveKey(move: AIMove): string {
const capIds = move.capture.map(c => c.id).sort().join(',');
return `${move.card.id}|${capIds}`;
}

341
src/game/ai-strategy.ts Normal file
View File

@@ -0,0 +1,341 @@
// ---------------------------------------------------------------------------
// AI Strategy — table parity, spariglio, mulinello, category states, endgame
// ---------------------------------------------------------------------------
import { Card, GameState, PlayerIndex, Suit, SUITS, PRIMIERA_VALUES } from './types';
import { AIMove } from './types';
import { teamOf } from './engine';
import { CardInferenceEngine } from './card-inference';
// ---------------------------------------------------------------------------
// Exported interfaces
// ---------------------------------------------------------------------------
export interface ParityState {
pairedRanks: number[];
unpairedRanks: number[];
spariglioDegree: number;
isEvenParity: boolean;
}
export interface SpariglioPotential {
card: Card;
spariglioDelta: number; // how spariglioDegree changes if this card is dumped
isSpariglio3Card: boolean; // true if this is a 3-card spariglio (highest priority)
}
export interface MulinelloState {
active: boolean;
favorableFor: 'us' | 'them' | null;
breakingMoves: AIMove[];
}
export interface CategoryEntry {
state: 'secured' | 'lost' | 'contested';
closeness: number; // 0-1, higher = closer to winning
}
export interface PrimieraCategoryEntry {
perSuit: Record<Suit, CategoryEntry>;
overallCloseness: number;
}
export interface CategoryStates {
denari: CategoryEntry;
carte: CategoryEntry;
primiera: PrimieraCategoryEntry;
scope: 'always_contested';
settebello: 'always_contested';
}
export interface PrimieraRaceState {
teamLeadsBySuit: Record<Suit, boolean | null>; // true=we lead, false=they lead, null=tied/unknown
contestedSuits: Suit[];
unseenPrimieraCards: Card[]; // unseen 7s, 6s, 1s (in primiera value order)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function canCardCapture(card: Card, table: Card[]): boolean {
// Direct match
if (table.some(c => c.value === card.value)) return true;
// Sum capture (subsets of 2+)
if (table.length < 2) return false;
for (let mask = 1; mask < (1 << table.length); mask++) {
const subset = table.filter((_, i) => mask & (1 << i));
if (subset.length >= 2 && subset.reduce((s, c) => s + c.value, 0) === card.value) return true;
}
return false;
}
function bestPrimieraValue(pile: Card[], suit: Suit): number {
const cards = pile.filter(c => c.suit === suit);
if (cards.length === 0) return 0;
return Math.max(...cards.map(c => PRIMIERA_VALUES[c.value] ?? 10));
}
function buildPrimieraCategoryEntry(
state: GameState,
team: 0 | 1,
myPile: Card[],
oppPile: Card[],
): PrimieraCategoryEntry {
const perSuit: Record<Suit, CategoryEntry> = {} as Record<Suit, CategoryEntry>;
let totalCloseness = 0;
for (const suit of SUITS) {
const myBest = bestPrimieraValue(myPile, suit);
const oppBest = bestPrimieraValue(oppPile, suit);
let entry: CategoryEntry;
if (myBest > oppBest && myBest >= PRIMIERA_VALUES[6]) {
// We have 7 or 6 in this suit and it's the best
entry = { state: 'secured', closeness: 1 };
} else if (oppBest > myBest && oppBest >= PRIMIERA_VALUES[6]) {
entry = { state: 'lost', closeness: 0 };
} else {
// Contested: closer to 1 if we have a better card
const closeness = Math.max(0, Math.min(1, (myBest - oppBest + 10) / 20));
entry = { state: 'contested', closeness };
}
perSuit[suit] = entry;
totalCloseness += entry.closeness;
}
return {
perSuit,
overallCloseness: totalCloseness / 4,
};
}
// ---------------------------------------------------------------------------
// Exported functions
// ---------------------------------------------------------------------------
export function analyzeTableParity(table: Card[]): ParityState {
const countByValue = new Map<number, number>();
for (const card of table) {
countByValue.set(card.value, (countByValue.get(card.value) ?? 0) + 1);
}
const pairedRanks: number[] = [];
const unpairedRanks: number[] = [];
for (const [value, count] of countByValue) {
if (count % 2 === 0) pairedRanks.push(value);
else unpairedRanks.push(value);
}
return {
pairedRanks,
unpairedRanks,
spariglioDegree: unpairedRanks.length,
isEvenParity: unpairedRanks.length === 0,
};
}
export function rankDumpsBySpariglio(
hand: Card[],
table: Card[],
isNonDealerTeam: boolean,
): SpariglioPotential[] {
const current = analyzeTableParity(table);
const potentials: SpariglioPotential[] = [];
for (const card of hand) {
if (canCardCapture(card, table)) continue; // only dump moves
const tableAfter = [...table, card];
const after = analyzeTableParity(tableAfter);
const spariglioDelta = after.spariglioDegree - current.spariglioDegree;
potentials.push({
card,
spariglioDelta,
isSpariglio3Card: spariglioDelta >= 2,
});
}
// Non-dealer wants highest positive delta first; dealer wants lowest (most negative) first
if (isNonDealerTeam) {
potentials.sort((a, b) => b.spariglioDelta - a.spariglioDelta);
} else {
potentials.sort((a, b) => a.spariglioDelta - b.spariglioDelta);
}
return potentials;
}
export function detectMulinello(
state: GameState,
playerIdx: PlayerIndex,
inference: CardInferenceEngine,
): MulinelloState {
const table = state.table;
if (table.length === 0 || table.length > 4) {
return { active: false, favorableFor: null, breakingMoves: [] };
}
const myHand = state.players[playerIdx].hand;
const myCaptures = myHand.filter(c => canCardCapture(c, table));
if (myCaptures.length === 0 && table.length <= 2) {
// We can't capture — potential mulinello against us
return {
active: true,
favorableFor: 'them',
breakingMoves: myHand.map(c => ({ card: c, capture: [] })),
};
}
return { active: false, favorableFor: null, breakingMoves: [] };
}
export function getPhase(state: GameState): 'opening' | 'midgame' | 'endgame' {
const totalCardsInHands = state.players.reduce((sum, p) => sum + p.hand.length, 0);
const totalPlayed = 40 - totalCardsInHands - state.table.length;
if (totalPlayed <= 12) return 'opening';
if (totalCardsInHands <= 16) return 'endgame';
return 'midgame';
}
export function getCategoryStates(state: GameState, team: 0 | 1): CategoryStates {
const myPile: Card[] = [];
const oppPile: Card[] = [];
for (let i = 0; i < 4; i++) {
const p = state.players[i];
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
else oppPile.push(...p.pile);
}
const myCoins = myPile.filter(c => c.suit === 'denara').length;
const oppCoins = oppPile.filter(c => c.suit === 'denara').length;
const totalCards = myPile.length + oppPile.length + state.table.length;
const unseenCoins = 10 - myCoins - oppCoins - state.table.filter(c => c.suit === 'denara').length;
const unseenCards = 40 - totalCards;
// Denari
const denari: CategoryEntry = myCoins >= 6
? { state: 'secured', closeness: 1 }
: oppCoins >= 6
? { state: 'lost', closeness: 0 }
: {
state: 'contested',
closeness: Math.max(0, Math.min(1, (myCoins - oppCoins + 1) / Math.max(1, unseenCoins + 1))),
};
// Carte
const myCards = myPile.length;
const oppCards = oppPile.length;
const carte: CategoryEntry = myCards >= 21
? { state: 'secured', closeness: 1 }
: oppCards >= 21
? { state: 'lost', closeness: 0 }
: {
state: 'contested',
closeness: Math.max(0, Math.min(1, (myCards - oppCards + 1) / Math.max(1, unseenCards + 1))),
};
// Primiera: per suit
const primiera = buildPrimieraCategoryEntry(state, team, myPile, oppPile);
return {
denari,
carte,
primiera,
scope: 'always_contested',
settebello: 'always_contested',
};
}
export function solveEndgame(
state: GameState,
playerIdx: PlayerIndex,
inference: CardInferenceEngine,
legalMoves: AIMove[],
): AIMove | null {
// Only attempt when very few cards remain
const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
if (totalRemaining > 8) return null;
const myHand = state.players[playerIdx].hand;
for (let i = 0; i < 4; i++) {
if (i === playerIdx) continue;
const p = state.players[i];
if (p.hand.length === 0) continue;
const constrained = inference.getConstrainedUnseen(i as PlayerIndex, myHand, state.table);
if (constrained.length < p.hand.length) {
// Can't fully determine this player's hand
return null;
}
// If constrained pool equals exactly handSize, we know exactly what they have
if (constrained.length > p.hand.length) return null; // still ambiguous
}
// We know all hands — pick the best move by greedy evaluation
let bestMove: AIMove | null = null;
let bestScore = -Infinity;
for (const move of legalMoves) {
let score = 0;
// Scopa
const tableAfter = state.table.filter(c => !move.capture.some(cap => cap.id === c.id));
if (move.capture.length > 0 && tableAfter.length === 0) score += 100;
// Settebello
if (move.capture.some(c => c.id === 'denara_7')) score += 80;
// 7s
score += move.capture.filter(c => c.value === 7).length * 40;
// Coins
score += move.capture.filter(c => c.suit === 'denara').length * 15;
// Cards
score += move.capture.length * 3;
if (score > bestScore) {
bestScore = score;
bestMove = move;
}
}
return bestMove;
}
export function analyzePrimieraRace(state: GameState, team: 0 | 1): PrimieraRaceState {
const myPile: Card[] = [];
const oppPile: Card[] = [];
for (let i = 0; i < 4; i++) {
const p = state.players[i];
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
else oppPile.push(...p.pile);
}
const allKnown = new Set([...myPile, ...oppPile, ...state.table].map(c => c.id));
const unseenPrimieraCards: Card[] = [];
const PRIMIERA_ORDER = [7, 6, 1, 5, 4, 3, 2, 8, 9, 10];
for (const value of PRIMIERA_ORDER) {
for (const suit of SUITS) {
const id = `${suit}_${value}`;
if (!allKnown.has(id) && (value === 7 || value === 6 || value === 1)) {
unseenPrimieraCards.push({ suit, value, id });
}
}
}
const teamLeadsBySuit: Record<Suit, boolean | null> = {} as Record<Suit, boolean | null>;
const contestedSuits: Suit[] = [];
for (const suit of SUITS) {
const myBest = bestPrimieraValue(myPile, suit);
const oppBest = bestPrimieraValue(oppPile, suit);
if (myBest > oppBest) teamLeadsBySuit[suit] = true;
else if (oppBest > myBest) teamLeadsBySuit[suit] = false;
else {
teamLeadsBySuit[suit] = null;
contestedSuits.push(suit);
}
}
return { teamLeadsBySuit, contestedSuits, unseenPrimieraCards };
}

View File

@@ -1,4 +1,4 @@
import { AIDecisionProgress, AIMove, chooseMove } from './ai';
import { AIChooseMoveOptions, AIDecisionProgress, AIMove, chooseMove } from './ai';
import {
AIWorkerErrorMessage,
AIWorkerRequestMessage,
@@ -14,6 +14,7 @@ export interface AIWorkerClientLike {
difficulty?: Difficulty,
tracker?: CardTracker,
onProgress?: (progress: AIDecisionProgress) => void,
options?: AIChooseMoveOptions,
): Promise<AIMove>;
dispose(): void;
}
@@ -26,6 +27,7 @@ interface PendingRequest {
difficulty: Difficulty;
tracker?: CardTracker;
onProgress?: (progress: AIDecisionProgress) => void;
options?: AIChooseMoveOptions;
resolve: (move: AIMove) => void;
reject: (error: Error) => void;
}
@@ -66,6 +68,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
difficulty: Difficulty = 'advanced',
tracker?: CardTracker,
onProgress?: (progress: AIDecisionProgress) => void,
options?: AIChooseMoveOptions,
): Promise<AIMove> {
if (this.disposed) {
throw new Error('AIWorkerClient has been disposed');
@@ -85,6 +88,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
difficulty,
tracker,
onProgress,
options,
resolve,
reject,
};
@@ -98,6 +102,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
playerIdx,
difficulty,
trackerSnapshot: tracker ? tracker.toSnapshot() : null,
inferenceSnapshot: options?.inference?.toSnapshot() ?? null,
};
try {
@@ -179,6 +184,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
pending.difficulty,
pending.tracker,
pending.onProgress,
pending.options,
);
pending.resolve(move);
} catch (error) {

View File

@@ -1,4 +1,5 @@
import type { AIDecisionProgress, AIMove } from './ai';
import type { CardInferenceSnapshot } from './card-inference';
import type { CardTrackerSnapshot } from './card-tracker';
import type { Difficulty, GameState, PlayerIndex } from './types';
@@ -9,6 +10,7 @@ export interface AIWorkerChooseMoveRequest {
playerIdx: PlayerIndex;
difficulty: Difficulty;
trackerSnapshot: CardTrackerSnapshot | null;
inferenceSnapshot: CardInferenceSnapshot | null;
}
export interface AIWorkerProgressMessage {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import {
AIWorkerRequestMessage,
AIWorkerResponseMessage,
} from './ai-worker-protocol';
import { CardInferenceEngine } from './card-inference';
import { CardTracker } from './card-tracker';
interface AIWorkerScope {
@@ -42,6 +43,10 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
? CardTracker.fromSnapshot(request.trackerSnapshot)
: undefined;
const inference = request.inferenceSnapshot && tracker
? CardInferenceEngine.fromSnapshot(request.inferenceSnapshot, tracker)
: undefined;
try {
const move = await chooseMove(
request.state,
@@ -55,6 +60,7 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
progress,
});
},
inference ? { inference } : undefined,
);
workerScope.postMessage({

184
src/game/card-inference.ts Normal file
View File

@@ -0,0 +1,184 @@
// ---------------------------------------------------------------------------
// Card Inference Engine
// Tracks per-player card constraints and probability distributions.
// Additive to CardTracker — does NOT replace it.
// ---------------------------------------------------------------------------
import { Card, PlayerIndex, Suit, SUITS } from './types';
import { AIMove } from './types';
import { CardTracker } from './card-tracker';
export interface CardInferenceSnapshot {
cannotHave: Array<[PlayerIndex, string[]]>;
confirmedHeld: Array<[PlayerIndex, string[]]>;
}
export class CardInferenceEngine {
private cannotHave: Map<PlayerIndex, Set<string>>;
private confirmedHeld: Map<PlayerIndex, Set<string>>;
private tracker: CardTracker;
constructor(tracker: CardTracker) {
this.tracker = tracker;
this.cannotHave = new Map();
this.confirmedHeld = new Map();
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
this.cannotHave.set(p, new Set());
this.confirmedHeld.set(p, new Set());
}
}
reset(): void {
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
this.cannotHave.get(p)!.clear();
this.confirmedHeld.get(p)!.clear();
}
}
/**
* Call after every move is applied.
* @param playerIdx The player who just played
* @param move The move that was played (card + capture)
* @param tableBeforeMove The table state BEFORE the move was applied
*/
onMove(playerIdx: PlayerIndex, move: AIMove, tableBeforeMove: Card[]): void {
// 1. Player confirmed held the card they played
this.confirmedHeld.get(playerIdx)!.add(move.card.id);
// 2. If dump (no capture): player could not capture any table card with this card.
// Infer: player does NOT hold any card whose value matches any table card
// (because if they did, they'd have had a capture option for the dump-value already,
// but more importantly: the dump tells us no capture was available for move.card.value,
// which means the table does NOT have move.card.value — engine enforces mandatory capture).
// Additional inference: for each table card value, player cannot have the matching
// card OF THE SAME VALUE as their dumped card (engine rule: if matching value on table, capture is mandatory).
// Simplified: if player dumps value V, and table had cards of various values,
// it means table had NO card of value V. We can mark: player "cannot have" cards
// whose value == any table card value (because those would have been capturable).
// Actually the correct inference is: player dumped, so findCaptures(move.card, tableBeforeMove) === [].
// This means: no single table card matches move.card.value AND no subset sums to move.card.value.
// We mark: player definitely did NOT have a card that could capture from this table state.
// The simplest safe inference: mark that the player cannot have any card of the SAME VALUE
// as any card on the table before the move (since having such a card would have forced a capture).
if (move.capture.length === 0 && tableBeforeMove.length > 0) {
// Player dumped — they had no capture for move.card.
// Since capture is mandatory in Scopone, this means no card on table matches move.card.value
// and no subset of table cards sums to move.card.value.
// Safe inference: player doesn't hold any card of the same value as table cards
// (because if they had such a card, they'd have been forced to capture it instead).
// NOTE: This inference applies to OTHER cards in their hand, not the dumped card itself.
for (const tableCard of tableBeforeMove) {
// If player had a card of value tableCard.value, they would have captured it
// (since direct match = mandatory capture). So: player cannot have any card of tableCard.value
// in their REMAINING hand (they may have had one but already played it — but since we're
// tracking "cannot have NOW", this is correct: if they just dumped and had tableCard.value,
// they would have captured instead).
for (const suit of SUITS) {
const inferredId = `${suit}_${tableCard.value}`;
if (inferredId !== move.card.id) {
this.cannotHave.get(playerIdx)!.add(inferredId);
}
}
}
}
// 3. Exhaustion inference: for any value where all 4 suits are now accounted for
this.applyExhaustionInference();
}
private applyExhaustionInference(): void {
for (let value = 1; value <= 10; value++) {
const allPlayed = SUITS.every(suit => this.tracker.hasBeenPlayed(`${suit}_${value}`));
if (allPlayed) {
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
for (const suit of SUITS) {
this.cannotHave.get(p)!.add(`${suit}_${value}`);
}
}
}
}
}
/**
* Returns the pool of unseen cards for a given player, filtered by their constraints.
*/
getConstrainedUnseen(excludePlayerIdx: PlayerIndex, myHand: Card[], table: Card[]): Card[] {
const unseen = this.tracker.getUnseenCards(myHand, table);
const excluded = this.cannotHave.get(excludePlayerIdx)!;
return unseen.filter(c => !excluded.has(c.id));
}
/**
* Hypergeometric probability: P(player holds at least one card of given value).
*/
probabilityPlayerHasValue(
playerIdx: PlayerIndex,
value: number,
handSize: number,
myHand: Card[],
table: Card[],
): number {
if (handSize <= 0) return 0;
const pool = this.getConstrainedUnseen(playerIdx, myHand, table);
const matching = pool.filter(c => c.value === value).length;
if (matching === 0) return 0;
if (handSize >= pool.length) return 1;
let probNone = 1;
for (let i = 0; i < handSize; i++) {
probNone *= Math.max(0, (pool.length - matching - i)) / (pool.length - i);
}
return 1 - probNone;
}
/**
* Returns a likely hand for sampling (for PIMC determinization).
* Cards are weighted by strategic importance: 7s first, then 6s, then coins, then aces.
*/
getLikelyHandForSampling(
playerIdx: PlayerIndex,
handSize: number,
myHand: Card[],
table: Card[],
rng: () => number = Math.random,
): Card[] {
const pool = this.getConstrainedUnseen(playerIdx, myHand, table);
if (pool.length === 0) return [];
// Weight cards by strategic importance
const weighted = pool.map(card => {
let weight = 0;
if (card.value === 7) weight += 4;
else if (card.value === 6) weight += 3;
else if (card.value === 1) weight += 2;
if (card.suit === 'denara') weight += 1;
weight += rng() * 0.5; // noise to vary sampling
return { card, weight };
});
weighted.sort((a, b) => b.weight - a.weight);
return weighted.slice(0, Math.min(handSize, pool.length)).map(w => w.card);
}
toSnapshot(): CardInferenceSnapshot {
return {
cannotHave: ([0, 1, 2, 3] as PlayerIndex[]).map(p =>
[p, [...this.cannotHave.get(p)!]] as [PlayerIndex, string[]],
),
confirmedHeld: ([0, 1, 2, 3] as PlayerIndex[]).map(p =>
[p, [...this.confirmedHeld.get(p)!]] as [PlayerIndex, string[]],
),
};
}
static fromSnapshot(snapshot: CardInferenceSnapshot, tracker: CardTracker): CardInferenceEngine {
const engine = new CardInferenceEngine(tracker);
for (const [p, ids] of snapshot.cannotHave) {
engine.cannotHave.set(p as PlayerIndex, new Set(ids));
}
for (const [p, ids] of snapshot.confirmedHeld) {
engine.confirmedHeld.set(p as PlayerIndex, new Set(ids));
}
return engine;
}
}

View File

@@ -73,3 +73,8 @@ export const PRIMIERA_VALUES: Record<number, number> = {
9: 10,
10: 10,
};
export interface AIMove {
card: Card;
capture: Card[];
}

View File

@@ -7,6 +7,7 @@ import {
import { AIMove, AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
import { CardTracker } from '../game/card-tracker';
import { CardInferenceEngine } from '../game/card-inference';
import {
DEFAULT_AUDIO_PREFERENCES,
GameSceneData,
@@ -71,6 +72,7 @@ export class GameScene extends Phaser.Scene {
// Difficulty & card tracker
private difficulty: Difficulty = 'advanced';
private tracker: CardTracker = new CardTracker();
private inference: CardInferenceEngine = new CardInferenceEngine(this.tracker);
private aiClient: AIWorkerClientLike | null = null;
// Active player highlight
@@ -133,6 +135,7 @@ export class GameScene extends Phaser.Scene {
? normalizeAudioPreferences(data.audioPreferences)
: loadAudioPreferences();
this.tracker = new CardTracker();
this.inference = new CardInferenceEngine(this.tracker);
this.aiClient?.dispose();
this.aiClient = new AIWorkerClient();
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.handleSceneShutdown, this);
@@ -793,7 +796,8 @@ export class GameScene extends Phaser.Scene {
this.updateThinkBar(playerIdx, progress);
if (progress.difficulty !== 'master') return;
finalProgress = progress;
}
},
{ inference: this.inference },
);
const remainingThinkMs = AI_MIN_THINK_MS - (Date.now() - thinkStartedAt);
@@ -1018,6 +1022,7 @@ export class GameScene extends Phaser.Scene {
// ---------------------------------------------------------------------------
private executeMove(playerIdx: PlayerIndex, card: Card, capture: Card[]): void {
const tableBeforeMove = [...this.state.table];
const { nextState, capture: captureResult, isScopa } = applyMove(
this.state, playerIdx, card, capture.length > 0 ? capture : undefined
);
@@ -1030,6 +1035,13 @@ export class GameScene extends Phaser.Scene {
this.tracker.trackCapture(captureResult.captured);
}
// Update inference engine
this.inference.onMove(
playerIdx,
{ card, capture: captureResult?.captured ?? [] },
tableBeforeMove,
);
const cardImg = this.cardImages.get(card.id)!;
cardImg.setDepth(15);
@@ -1650,6 +1662,7 @@ export class GameScene extends Phaser.Scene {
for (const img of this.cardImages.values()) img.destroy();
this.cardImages.clear();
this.tracker.reset();
this.inference.reset();
this.state = createInitialState(nextDealer);
this.state.matchStartingPlayer = matchStartingPlayer;
this.state.teamScores[0].totalPoints = totals[0];