From 3f74c5766523249ecb9effe2cea623a2804c86f6 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:29:04 +0200 Subject: [PATCH 1/7] feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/android-build.yml | 125 + src/game/ai-benchmark.ts | 192 ++ src/game/ai-h2h-diagnose.ts | 213 ++ src/game/ai-h2h-quick.ts | 137 + src/game/ai-legacy.ts | 4222 +++++++++++++++++++++++++++ src/game/ai-pimc.ts | 629 ++++ src/game/ai-strategy.ts | 341 +++ src/game/ai-worker-client.ts | 8 +- src/game/ai-worker-protocol.ts | 2 + src/game/ai.ts | 4271 +++------------------------- src/game/ai.worker.ts | 6 + src/game/card-inference.ts | 184 ++ src/game/types.ts | 5 + src/scenes/GameScene.ts | 15 +- 14 files changed, 6412 insertions(+), 3938 deletions(-) create mode 100644 .gitea/workflows/android-build.yml create mode 100644 src/game/ai-h2h-diagnose.ts create mode 100644 src/game/ai-h2h-quick.ts create mode 100644 src/game/ai-legacy.ts create mode 100644 src/game/ai-pimc.ts create mode 100644 src/game/ai-strategy.ts create mode 100644 src/game/card-inference.ts diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml new file mode 100644 index 0000000..0b32f04 --- /dev/null +++ b/.gitea/workflows/android-build.yml @@ -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/" diff --git a/src/game/ai-benchmark.ts b/src/game/ai-benchmark.ts index 348afa0..4650021 100644 --- a/src/game/ai-benchmark.ts +++ b/src/game/ai-benchmark.ts @@ -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 { }; } +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 { + 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 { + 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 { 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') { diff --git a/src/game/ai-h2h-diagnose.ts b/src/game/ai-h2h-diagnose.ts new file mode 100644 index 0000000..1325c00 --- /dev/null +++ b/src/game/ai-h2h-diagnose.ts @@ -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 { + 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) => + 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); diff --git a/src/game/ai-h2h-quick.ts b/src/game/ai-h2h-quick.ts new file mode 100644 index 0000000..f679aaa --- /dev/null +++ b/src/game/ai-h2h-quick.ts @@ -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); diff --git a/src/game/ai-legacy.ts b/src/game/ai-legacy.ts new file mode 100644 index 0000000..d6b8547 --- /dev/null +++ b/src/game/ai-legacy.ts @@ -0,0 +1,4222 @@ +import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole, AIMove } from './types'; +import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole, RandomSource } from './engine'; +import { CardTracker } from './card-tracker'; + +export type { AIMove }; + +export interface AIDecisionProgress { + difficulty: Difficulty; + progress: number; + elapsedMs: number; + budgetMs: number; + batchesCompleted: number; + cardsRemaining?: number; + sampleCount?: number; + maxDepth?: number; + completedDepth?: number; + rootMoveCount?: number; + timedOut?: boolean; + aspirationExpansions?: number; +} + +interface MasterProgressDetails { + cardsRemaining: number; + sampleCount: number; + maxDepth: number; + completedDepth: number; + rootMoveCount: number; + timedOut: boolean; + aspirationExpansions: number; +} + +interface SearchProfile { + timeBudgetMs: number; + sampleCount: number; + maxDepth: number; + batchSize: number; +} + +export interface AISearchProfileOverride { + timeBudgetMs?: number; + sampleCount?: number; + maxDepth?: number; + batchSize?: number; +} + +export interface AITimingSource { + now(): number; + advance?(elapsedMs: number): number; + isSimulated?: boolean; +} + +export interface AIChooseMoveOptions { + rng?: RandomSource; + profileOverride?: AISearchProfileOverride; + timingSource?: AITimingSource; +} + +interface DealerRoleContext { + role: DealerRelativeRole; + onDealerSide: boolean; + defendingDealerAdvantage: boolean; + attackingDealerAdvantage: boolean; + aggressionBias: number; + controlBias: number; + pairPreservingBias: number; + pairBreakingBias: number; + tablePressureBias: number; +} + +interface RankResidueSnapshot { + unseenSameRankCounts: number[]; + hasSingletonResidue: boolean[]; + hasPairedResidue: boolean[]; +} + +interface SearchTimingContext { + now(): number; + checkpoint(costMs?: number): number; + yieldToHost(): Promise; +} + +const DEALER_ROLE_WEIGHTS: Record> = { + 'first-hand': { + aggressionBias: 1.28, + controlBias: 0.9, + pairPreservingBias: 0.88, + pairBreakingBias: 1.26, + tablePressureBias: 1.3, + }, + 'second-hand': { + aggressionBias: 1, + controlBias: 1.08, + pairPreservingBias: 1.12, + pairBreakingBias: 0.96, + tablePressureBias: 1, + }, + 'third-hand': { + aggressionBias: 1.16, + controlBias: 0.94, + pairPreservingBias: 0.94, + pairBreakingBias: 1.16, + tablePressureBias: 1.12, + }, + dealer: { + aggressionBias: 0.84, + controlBias: 1.32, + pairPreservingBias: 1.34, + pairBreakingBias: 0.82, + tablePressureBias: 0.78, + }, +}; + +const SEARCH_PROFILES: Record = { + beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, + advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 }, + master: { timeBudgetMs: 4300, sampleCount: 8, maxDepth: 5, batchSize: 2 }, +}; + +const REAL_TIME_SOURCE: AITimingSource = { + now: () => Date.now(), +}; + +const UPCOMING_TABLE_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const; + +const REPRESENTATIVE_CARD_BY_VALUE = (() => { + const cardsByValue = new Map(); + for (const card of buildDeck()) { + if (!cardsByValue.has(card.value)) { + cardsByValue.set(card.value, card); + } + } + return cardsByValue; +})(); + +const SIMULATED_SEARCH_NODE_COST_MS = 48; +const SIMULATED_ROOT_MOVE_COST_MS = 12; +const SIMULATED_YIELD_COST_MS = 1; + +function createSearchTimingContext(timingSource?: AITimingSource): SearchTimingContext { + const source = timingSource ?? REAL_TIME_SOURCE; + + return { + now: () => source.now(), + checkpoint: (costMs = 0) => { + if (source.advance && costMs > 0) { + return source.advance(costMs); + } + return source.now(); + }, + yieldToHost: () => { + if (source.advance) { + source.advance(SIMULATED_YIELD_COST_MS); + return Promise.resolve(); + } + return new Promise(resolve => setTimeout(resolve, 0)); + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers shared across all difficulty levels +// --------------------------------------------------------------------------- + +function nextPlayer(p: PlayerIndex): PlayerIndex { + return ((p + 1) % 4) as PlayerIndex; +} +function partnerOf(p: PlayerIndex): PlayerIndex { + return ((p + 2) % 4) as PlayerIndex; +} +function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { + return teamOf(me) !== teamOf(other); +} +function primieraVal(card: Card): number { + return PRIMIERA_VALUES[card.value] ?? 0; +} +function gamePhase(state: GameState): number { + const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); + return 1 - totalCards / 40; +} + +function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] { + return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile]; +} + +/** Is this the very last play of the round? (all hands have 0 or 1 cards, and it's this player's turn) */ +function isLastPlay(state: GameState, playerIdx: PlayerIndex): boolean { + for (let i = 0; i < 4; i++) { + if (i === playerIdx) { + if (state.players[i].hand.length !== 1) return false; + } else { + if (state.players[i].hand.length !== 0) return false; + } + } + return true; +} + +/** Count how many cards in hand match a given value (anchor candidates) */ +function countValueInHand(hand: Card[], value: number): number { + let n = 0; + for (const c of hand) if (c.value === value) n++; + return n; +} + +function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerRoleContext { + const role = getDealerRelativeRole(state.dealer, playerIdx); + const onDealerSide = role === 'dealer' || role === 'second-hand'; + return { + role, + onDealerSide, + defendingDealerAdvantage: onDealerSide, + attackingDealerAdvantage: !onDealerSide, + ...DEALER_ROLE_WEIGHTS[role], + }; +} + +function getRankResidueSnapshot( + tracker: CardTracker | undefined, + myHand: Card[], + table: Card[], +): RankResidueSnapshot | null { + if (!tracker) return null; + + const unseenSameRankCounts = Array.from({ length: 11 }, () => 0); + const hasSingletonResidue = Array.from({ length: 11 }, () => false); + const hasPairedResidue = Array.from({ length: 11 }, () => false); + const summary = tracker.getValueRankResidueSummary(myHand, table); + + for (const residue of summary) { + unseenSameRankCounts[residue.value] = residue.unseenCount; + hasSingletonResidue[residue.value] = residue.hasSingletonUnseenRankResidue; + hasPairedResidue[residue.value] = residue.hasPairedUnseenRankResidue; + } + + return { unseenSameRankCounts, hasSingletonResidue, hasPairedResidue }; +} + +function countRankResidueValuesOnTable( + afterTable: Card[], + rankResidue: RankResidueSnapshot | null, +): { singletonValues: number; pairedValues: number } { + if (!rankResidue || afterTable.length === 0) { + return { singletonValues: 0, pairedValues: 0 }; + } + + let singletonValues = 0; + let pairedValues = 0; + const seenValues = new Set(); + + for (const card of afterTable) { + if (seenValues.has(card.value)) continue; + seenValues.add(card.value); + if (rankResidue.hasSingletonResidue[card.value]) singletonValues++; + else if (rankResidue.hasPairedResidue[card.value]) pairedValues++; + } + + return { singletonValues, pairedValues }; +} + +function getExposedTableCardWeight( + card: Card, + race: RaceState, + tableSize: number, +): number { + let weight = 52 + primieraVal(card) * 2.5; + + if (card.suit === 'denara') { + weight += race.behindInDenari ? 150 : 95; + } + + if (card.value === 7) { + weight += race.need7s ? 190 : 120; + } + + if (card.suit === 'denara' && card.value === 7) { + weight += 220; + } + + if (tableSize === 1) weight += 150; + else if (tableSize === 2) weight += 70; + + return weight; +} + +function scoreExposedTableCards( + afterTable: Card[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + myHand: Card[], + race: RaceState, +): number { + if (afterTable.length === 0) return 0; + + const next = nextPlayer(playerIdx); + const partner = partnerOf(playerIdx); + const nextHandSize = state.players[next].hand.length; + const partnerHandSize = state.players[partner].hand.length; + const nextIsOpp = isOpponent(playerIdx, next); + const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); + const tableHasDenari = afterTable.some(card => card.suit === 'denara'); + const tableHasSeven = afterTable.some(card => card.value === 7); + let score = 0; + + for (const tableCard of afterTable) { + const weight = getExposedTableCardWeight(tableCard, race, afterTable.length); + + if (nextIsOpp && nextHandSize > 0) { + const nextProb = handLikelyHasValue( + tableCard.value, + nextHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score -= Math.round(nextProb * weight); + } + + if (!nextIsOpp && partnerHandSize > 0) { + const partnerProb = handLikelyHasValue( + tableCard.value, + partnerHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score += Math.round(partnerProb * weight * 0.55); + } + } + + if (nextIsOpp && afterTable.length === 1) { + score -= 380; + } else if (nextIsOpp && afterTable.length === 2) { + score -= tableSum <= 10 ? 180 : 110; + if (tableHasDenari) score -= race.behindInDenari ? 130 : 60; + if (tableHasSeven) score -= race.need7s ? 170 : 80; + } else if (nextIsOpp && afterTable.length >= 5 && tableSum >= 24) { + if (tableHasDenari) score += 70; + if (tableHasSeven) score += 55; + } + + return score; +} + +function scoreRoleTablePlan( + afterTable: Card[], + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (afterTable.length === 0) return 0; + + const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); + let score = 0; + + if (roleContext.role === 'first-hand') { + if (afterTable.length >= 2) score += 22 * roleContext.tablePressureBias; + if (tableSum >= 8 && tableSum <= 15) score += 18 * roleContext.aggressionBias; + } + + if (roleContext.role === 'third-hand') { + if (afterTable.length >= 2) score += 14 * roleContext.tablePressureBias; + if (tableSum >= 10) score += 10 * roleContext.aggressionBias; + } + + if (roleContext.role === 'second-hand') { + if (nextIsOpp && tableSum >= 11) score += 16 * roleContext.controlBias; + if (!nextIsOpp && tableSum <= 10) score += 10 * roleContext.tablePressureBias; + } + + if (roleContext.role === 'dealer') { + if (tableSum >= 11) score += 28 * roleContext.controlBias; + if (tableSum <= 10 && nextIsOpp) score -= 24 * roleContext.controlBias; + if (afterTable.length === 1 && nextIsOpp) score -= 16 * roleContext.controlBias; + } + + return Math.round(score); +} + +function scoreRankResidueTableState( + afterTable: Card[], + rankResidue: RankResidueSnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + const { singletonValues, pairedValues } = countRankResidueValuesOnTable(afterTable, rankResidue); + if (singletonValues === 0 && pairedValues === 0) return 0; + + let score = 0; + if (roleContext.defendingDealerAdvantage) { + score += pairedValues * 18 * roleContext.controlBias; + score -= singletonValues * 22 * roleContext.controlBias; + if (nextIsOpp) score += pairedValues * 8 - singletonValues * 10; + } else { + score += singletonValues * 20 * roleContext.tablePressureBias; + score -= pairedValues * 10; + if (nextIsOpp) score += singletonValues * 12; + } + + return Math.round(score); +} + +function scoreCaptureRankResiduePlan( + played: Card, + captured: Card[], + afterTable: Card[], + rankResidue: RankResidueSnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (!rankResidue || captured.length === 0) return 0; + + let score = 0; + const directCapture = captured.length === 1 && captured[0].value === played.value; + + if (directCapture) { + const unseenCount = rankResidue.unseenSameRankCounts[played.value] ?? 0; + const base = rankResidue.hasPairedResidue[played.value] ? 58 : 30; + score += base * roleContext.pairPreservingBias; + if (roleContext.defendingDealerAdvantage && unseenCount > 0) score += 18 * roleContext.controlBias; + } else { + let pairBreaks = 0; + let singletonTargets = 0; + const seenValues = new Set(); + + for (const card of captured) { + if (seenValues.has(card.value)) continue; + seenValues.add(card.value); + if ((rankResidue.unseenSameRankCounts[card.value] ?? 0) > 0) pairBreaks++; + if (rankResidue.hasSingletonResidue[card.value]) singletonTargets++; + } + + const disruption = pairBreaks * 20 + singletonTargets * 18 + Math.max(0, captured.length - 1) * 12; + score += disruption * roleContext.pairBreakingBias; + if (roleContext.defendingDealerAdvantage) score -= 18 * roleContext.controlBias; + } + + score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); + return Math.round(score); +} + +function scoreDumpRankResiduePlan( + card: Card, + afterTable: Card[], + rankResidue: RankResidueSnapshot | null, + roleContext: DealerRoleContext, + nextIsOpp: boolean, +): number { + if (!rankResidue) return 0; + + let score = scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); + if (rankResidue.hasSingletonResidue[card.value]) { + score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias; + } + if (rankResidue.hasPairedResidue[card.value]) { + score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6; + } + + return Math.round(score); +} + +function applySearchProfileOverride( + profile: SearchProfile, + profileOverride?: AISearchProfileOverride, +): SearchProfile { + if (!profileOverride) return profile; + + return { + timeBudgetMs: profileOverride.timeBudgetMs ?? profile.timeBudgetMs, + sampleCount: profileOverride.sampleCount ?? profile.sampleCount, + maxDepth: profileOverride.maxDepth ?? profile.maxDepth, + batchSize: profileOverride.batchSize ?? profile.batchSize, + }; +} + +function getSearchProfile( + state: GameState, + difficulty: Difficulty, + profileOverride?: AISearchProfileOverride, +): SearchProfile { + if (difficulty !== 'master') { + return applySearchProfileOverride(SEARCH_PROFILES[difficulty], profileOverride); + } + + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + if (cardsRemaining <= 4) { + return applySearchProfileOverride( + { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); + } + if (cardsRemaining <= 6) { + return applySearchProfileOverride( + { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); + } + if (cardsRemaining <= 8) { + return applySearchProfileOverride( + { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); + } + if (cardsRemaining <= 12) { + return applySearchProfileOverride( + { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 }, + profileOverride, + ); + } + if (cardsRemaining <= 20) { + return applySearchProfileOverride( + { timeBudgetMs: 4350, sampleCount: 12, maxDepth: 5, batchSize: 2 }, + profileOverride, + ); + } + return applySearchProfileOverride(SEARCH_PROFILES.master, profileOverride); +} + +function reportDecisionProgress( + onProgress: ((progress: AIDecisionProgress) => void) | undefined, + difficulty: Difficulty, + startedAt: number, + timing: SearchTimingContext, + budgetMs: number, + progress: number, + batchesCompleted: number, + masterDetails?: MasterProgressDetails, +): void { + if (!onProgress) return; + + onProgress({ + difficulty, + progress: Math.max(0, Math.min(1, progress)), + elapsedMs: timing.now() - startedAt, + budgetMs, + batchesCompleted, + ...(masterDetails ?? {}), + }); +} + +function handLikelyHasValue( + value: number, + handSize: number, + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + myHand: Card[], + table: Card[], +): number { + if (handSize <= 0) return 0; + + if (tracker) { + return tracker.probabilityHandHasValue(value, handSize, myHand, table); + } + + const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker); + let unseenWithValue = 0; + for (const card of unseen) { + if (card.value === value) unseenWithValue++; + } + + if (unseenWithValue === 0 || unseen.length === 0) return 0; + const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize); + return 1 - probNone; +} + +/** Check if partner likely holds a card of given value (via tracker inference) */ +function partnerLikelyHolds( + value: number, playerIdx: PlayerIndex, state: GameState, + tracker: CardTracker | undefined, myHand: Card[], table: Card[], +): number { + const partner = partnerOf(playerIdx); + return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table); +} + +/** Race state: who's winning each scoring category */ +interface RaceState { + myCards: number; oppCards: number; + myDenari: number; oppDenari: number; + mySettebello: boolean; oppSettebello: boolean; + my7s: number; opp7s: number; + myScope: number; oppScope: number; + behindInCards: boolean; + behindInDenari: boolean; + denariRaceLive: boolean; + needSettebello: boolean; + need7s: boolean; + sevenRaceLive: boolean; + aheadOverall: boolean; +} + +function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { + const myTeam = teamOf(playerIdx); + const mine = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + const opps = myTeam === 0 ? [state.players[1], state.players[3]] : [state.players[0], state.players[2]]; + const myPile = mine.flatMap(p => p.pile); + const oppPile = opps.flatMap(p => p.pile); + const myCards = myPile.length, oppCards = oppPile.length; + const myDenari = myPile.filter(c => c.suit === 'denara').length; + const oppDenari = oppPile.filter(c => c.suit === 'denara').length; + const mySettebello = myPile.some(c => c.suit === 'denara' && c.value === 7); + const oppSettebello = oppPile.some(c => c.suit === 'denara' && c.value === 7); + const my7s = myPile.filter(c => c.value === 7).length; + const opp7s = oppPile.filter(c => c.value === 7).length; + const myScope = mine.reduce((s, p) => s + p.scope, 0); + const oppScope = opps.reduce((s, p) => s + p.scope, 0); + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + + // Simple overall advantage estimate + let myAdv = 0; + if (myCards > oppCards) myAdv++; else if (oppCards > myCards) myAdv--; + if (myDenari > oppDenari) myAdv++; else if (oppDenari > myDenari) myAdv--; + if (mySettebello) myAdv++; else if (oppSettebello) myAdv--; + myAdv += myScope - oppScope; + + return { + myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello, + my7s, opp7s, myScope, oppScope, + behindInCards: myCards < oppCards, + behindInDenari: myDenari < oppDenari, + denariRaceLive: cardsRemaining > 0 && myDenari < 6 && oppDenari < 6 && Math.abs(myDenari - oppDenari) <= 1, + needSettebello: !mySettebello && !oppSettebello, + need7s: my7s <= opp7s, + sevenRaceLive: cardsRemaining > 0 && my7s < 3 && opp7s < 3 && Math.abs(my7s - opp7s) <= 1, + aheadOverall: myAdv > 0, + }; +} + +/** + * Count scopa threats: how many unseen cards can clear a given table. + * Uses probabilistic assessment per-player based on hand sizes. + */ +function countScopaThreats( + afterTable: Card[], + myHand: Card[], + tracker: CardTracker | undefined, + state: GameState, + playerIdx: PlayerIndex, +): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean; partnerCanScopa: boolean } { + if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false, partnerCanScopa: false }; + + const unseen = tracker + ? tracker.getUnseenCards(myHand, afterTable) + : getUnseenWithoutTracker(state, playerIdx); + + // Count every unseen card that has at least one capture clearing the full table + let totalThreats = 0; + const threatCardIds = new Set(); + for (const uc of unseen) { + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (cap.length === afterTable.length) { + totalThreats++; + threatCardIds.add(uc.id); + break; + } + } + } + + // Probabilistic check for each player + const next = nextPlayer(playerIdx); + const second = nextPlayer(next); + const third = nextPlayer(second); // = partner + const unseenCount = unseen.length; + + let nextOppCanScopa = false; + let secondOppCanScopa = false; + let partnerCanScopa = false; + + if (totalThreats > 0 && unseenCount > 0) { + for (const other of [next, second, third]) { + const hs = state.players[other].hand.length; + if (hs === 0) continue; + const probNone = hypergeometricNone(unseenCount, totalThreats, hs); + const prob = 1 - probNone; + if (isOpponent(playerIdx, other)) { + if (other === next) nextOppCanScopa = prob > 0.20; + else secondOppCanScopa = prob > 0.20; + } else if (other !== playerIdx) { + partnerCanScopa = prob > 0.30; + } + } + } + + return { totalThreats, nextOppCanScopa, secondOppCanScopa, partnerCanScopa }; +} + +interface ScopaThreatSummary { + totalThreats: number; + nextOppCanScopa: boolean; + secondOppCanScopa: boolean; + partnerCanScopa: boolean; +} + +interface TacticalPriorityLadder { + scopa: number; + settebello: number; + antiScopa: number; + partnerSetup: number; + sevenDenial: number; + denariDenial: number; + material: number; +} + +const TACTICAL_PRIORITY_WEIGHTS = { + scopa: 120000000, + settebello: 20000000, + antiScopa: 5000000, + partnerSetup: 45000, + sevenDenial: 2101, + denariDenial: 101, +} as const; + +function clampPriorityBand(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.round(value))); +} + +function sumCardValues(cards: Card[]): number { + return cards.reduce((sum, card) => sum + card.value, 0); +} + +function getPriorityThreatSummary( + afterTable: Card[], + myHand: Card[], + tracker: CardTracker | undefined, + state: GameState, + playerIdx: PlayerIndex, +): ScopaThreatSummary | null { + if (afterTable.length === 0 || sumCardValues(afterTable) > 10) { + return null; + } + + return countScopaThreats(afterTable, myHand, tracker, state, playerIdx); +} + +function isImmediateTacticalConcession( + afterTable: Card[], + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): boolean { + if (!nextIsOpp || afterTable.length === 0) return false; + + const tableSum = sumCardValues(afterTable); + if (afterTable.length === 1 && tableSum <= 10) return true; + if (afterTable.length === 2 && tableSum <= 10) return true; + + return Boolean(threats?.nextOppCanScopa); +} + +function evaluateSafeScopaPriority( + clearsTable: boolean, + afterTable: Card[], + lastPlay: boolean, + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): number { + if (!clearsTable || lastPlay) return 0; + return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2; +} + +function evaluateFirstHandOpeningReleasePriority( + card: Card, + myHand: Card[], + projectedHand: Card[], + afterTable: Card[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): number { + if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) { + return 0; + } + + const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length; + if (nextHandSize <= 0) return 0; + + const sameValueCount = countValueInHand(myHand, card.value); + const immediateScopaRisk = handLikelyHasValue( + card.value, + nextHandSize, + state, + playerIdx, + tracker, + projectedHand, + afterTable, + ); + + let score = 0; + score += Math.max(0, sameValueCount - 1) * 2; + if (sameValueCount >= 3) score += 2; + score += Math.round((0.32 - immediateScopaRisk) * 12); + + if (sameValueCount >= 2 && card.value >= 8) score += 2; + if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 3; + if (sameValueCount >= 2 && card.suit === 'denara') score -= 2; + if (card.suit === 'denara') score -= 1; + if (card.value === 7) score -= 1; + if (sameValueCount === 1 && card.value <= 3) score -= 2; + + return clampPriorityBand(score, -8, 8); +} + +function evaluateAntiScopaPriority( + afterTable: Card[], + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): number { + if (afterTable.length === 0) return 8; + + const tableSum = sumCardValues(afterTable); + const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; + const exposedSevens = afterTable.filter(card => card.value === 7).length; + let score = tableSum >= 14 ? 7 : tableSum >= 11 ? 6 : 0; + + if (nextIsOpp) { + if (tableSum <= 12) score -= 3; + if (tableSum <= 10) score -= 6; + if (tableSum <= 6) score -= 4; + if (afterTable.length === 1) score -= 9; + else if (afterTable.length === 2 && tableSum <= 12) score -= 6; + else if (afterTable.length === 3 && tableSum <= 12) score -= 3; + if (afterTable.length === 3 && tableSum <= 18) score -= 2; + + score -= exposedDenari * 2; + score -= exposedSevens * 3; + if (afterTable.length <= 2 && (exposedDenari > 0 || exposedSevens > 0)) { + score -= 4 + exposedDenari + exposedSevens; + } + if (afterTable.length === 3 && tableSum <= 18 && (exposedDenari > 0 || exposedSevens > 0)) { + score -= 4 + exposedDenari * 2 + exposedSevens * 2; + } + if (afterTable.length >= 4 && tableSum >= 20) score += 4; + if (afterTable.length >= 5 && tableSum >= 24) score += 2; + } + + if (threats) { + if (threats.nextOppCanScopa) score -= 10; + if (threats.secondOppCanScopa) score -= 5; + score -= Math.min(8, threats.totalThreats); + if (threats.partnerCanScopa) { + score += nextIsOpp && !threats.nextOppCanScopa ? 4 : 2; + } + } + + if (!nextIsOpp && tableSum >= 11) score += 2; + if (!nextIsOpp && afterTable.length >= 4 && tableSum >= 15) score += 2; + + return clampPriorityBand(score, -20, 20); +} + +function evaluatePartnerSetupPriority( + afterTable: Card[], + nextIsOpp: boolean, + partnerHandSize: number, + threats: ScopaThreatSummary | null, +): number { + if (afterTable.length === 0 || partnerHandSize === 0) return 0; + + const tableSum = sumCardValues(afterTable); + const denariOnTable = afterTable.filter(card => card.suit === 'denara').length; + const sevensOnTable = afterTable.filter(card => card.value === 7).length; + let score = 0; + + if (!nextIsOpp) { + score += 4; + if (tableSum >= 1 && tableSum <= 10) score += threats?.partnerCanScopa ? 10 : 5; + if (afterTable.length >= 2) score += 2; + if (denariOnTable > 0) score += Math.min(3, denariOnTable); + if (sevensOnTable > 0) score += 2; + } else { + if (tableSum >= 11) { + score += 2; + if (afterTable.length >= 4) score += 1; + if (denariOnTable > 0) score += 2; + if (sevensOnTable > 0) score += 1; + } + + if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { + score += tableSum <= 12 ? 7 : 4; + if (afterTable.length >= 4) score += 3; + if (denariOnTable === 0) score += 1; + if (sevensOnTable === 0) score += 1; + } + + if (threats?.secondOppCanScopa) score -= 2; + } + + return clampPriorityBand(score, -20, 20); +} + +function evaluateSevenDenialPriority( + afterTable: Card[], + capturedCards: Card[], + releasedCard: Card | null, + nextIsOpp: boolean, + need7s: boolean, +): number { + let score = 0; + const capturedSevens = capturedCards.filter(card => card.value === 7).length; + const exposedSevens = afterTable.filter(card => card.value === 7).length; + const strippedAllSevens = capturedSevens > 0 && exposedSevens === 0; + + score += capturedSevens * (need7s ? 8 : 5); + if (capturedSevens > 0) score += need7s ? 4 : 2; + if (strippedAllSevens) score += need7s ? 5 : 3; + if (nextIsOpp) { + score -= exposedSevens * (need7s ? 10 : 6); + if (exposedSevens > 0 && afterTable.length <= 2) { + score -= need7s ? 6 : 4; + } + } else { + score += exposedSevens; + } + + if (releasedCard?.value === 7) { + score -= need7s ? 12 : 7; + if (nextIsOpp && afterTable.length <= 2) score -= need7s ? 6 : 4; + } + + return clampPriorityBand(score, -20, 20); +} + +function evaluateDenariDenialPriority( + afterTable: Card[], + capturedCards: Card[], + releasedCard: Card | null, + nextIsOpp: boolean, + behindInDenari: boolean, +): number { + let score = 0; + const capturedDenari = capturedCards.filter(card => card.suit === 'denara').length; + const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; + const strippedAllDenari = capturedDenari > 0 && exposedDenari === 0; + + score += capturedDenari * (behindInDenari ? 7 : 4); + if (capturedDenari > 0) score += behindInDenari ? 4 : 2; + if (strippedAllDenari) score += behindInDenari ? 5 : 3; + if (nextIsOpp) { + score -= exposedDenari * (behindInDenari ? 10 : 6); + if (exposedDenari > 0 && afterTable.length <= 2) { + score -= behindInDenari ? 6 : 4; + } + } else { + score += Math.min(2, exposedDenari); + } + + if (releasedCard?.suit === 'denara') { + score -= behindInDenari ? 11 : 6; + if (nextIsOpp && afterTable.length <= 2) score -= behindInDenari ? 6 : 4; + } + + return clampPriorityBand(score, -20, 20); +} + +function scoreTacticalPriorityLadder(priorities: TacticalPriorityLadder): number { + const scopa = clampPriorityBand(priorities.scopa, -2, 2); + const settebello = clampPriorityBand(priorities.settebello, -4, 4); + const antiScopa = clampPriorityBand(priorities.antiScopa, -20, 20); + const partnerSetup = clampPriorityBand(priorities.partnerSetup, -20, 20); + const sevenDenial = clampPriorityBand(priorities.sevenDenial, -20, 20); + const denariDenial = clampPriorityBand(priorities.denariDenial, -20, 20); + const material = clampPriorityBand(priorities.material, -200, 200); + + return ( + scopa * TACTICAL_PRIORITY_WEIGHTS.scopa + + settebello * TACTICAL_PRIORITY_WEIGHTS.settebello + + antiScopa * TACTICAL_PRIORITY_WEIGHTS.antiScopa + + partnerSetup * TACTICAL_PRIORITY_WEIGHTS.partnerSetup + + sevenDenial * TACTICAL_PRIORITY_WEIGHTS.sevenDenial + + denariDenial * TACTICAL_PRIORITY_WEIGHTS.denariDenial + + material + ); +} + +/** P(0 threat cards drawn) using hypergeometric approx */ +function hypergeometricNone(total: number, threats: number, drawn: number): number { + if (drawn >= total) return threats > 0 ? 0 : 1; + let p = 1; + for (let i = 0; i < drawn; i++) { + p *= Math.max(0, (total - threats - i)) / (total - i); + } + return p; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function chooseMove( + state: GameState, + playerIdx: PlayerIndex, + difficulty: Difficulty = 'advanced', + tracker?: CardTracker, + onProgress?: (progress: AIDecisionProgress) => void, + options?: AIChooseMoveOptions, +): Promise { + const timing = createSearchTimingContext(options?.timingSource); + const startedAt = timing.now(); + const profile = getSearchProfile(state, difficulty, options?.profileOverride); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); + + switch (difficulty) { + case 'beginner': { + const move = beginnerMove(state, playerIdx, tracker); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); + return move; + } + case 'advanced': { + const move = advancedMove(state, playerIdx, tracker); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); + return move; + } + case 'master': + return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, options?.rng ?? Math.random); + } +} + +// =========================================================================== +// BEGINNER — beatable but not stupid, basic strategy awareness +// =========================================================================== + +function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { + const player = state.players[playerIdx]; + const table = state.table; + const phase = gamePhase(state); + const next = nextPlayer(playerIdx); + const nextIsOpp = isOpponent(playerIdx, next); + const lastPlay = isLastPlay(state, playerIdx); + + // 5% pure random (reduced from 8%) + if (Math.random() < 0.05) { + return randomMove(state, playerIdx); + } + + let bestMove: AIMove | null = null; + let bestScore = -Infinity; + + for (const card of player.hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + for (const captureSet of captures) { + const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp, lastPlay); + const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.2); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } + } + } else { + const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp, player.hand); + const score = base + (Math.random() - 0.5) * Math.max(50, Math.abs(base) * 0.2); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } + } + } + + return bestMove!; +} + +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: [] }; +} + +function scoreCaptureBeginner( + played: Card, captured: Card[], table: Card[], + state: GameState, playerIdx: PlayerIndex, phase: number, + nextIsOpp: boolean, lastPlay: boolean, +): number { + const allCaptured = [played, ...captured]; + const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); + const isScopa = afterTable.length === 0; + const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); + const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); + const threats = getPriorityThreatSummary(afterTable, state.players[playerIdx].hand, undefined, state, playerIdx); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + let material = 20 + captured.length * 14 + phase * captured.length * 4; + + material += allCaptured.filter(c => c.suit === 'denara').length * 8; + material += allCaptured.filter(c => c.value === 7).length * 6; + for (const card of allCaptured) material += primieraVal(card) * 1.2; + + if (!isScopa) { + for (const tableCard of afterTable) { + const dupes = countValueInHand(state.players[playerIdx].hand, tableCard.value); + if (dupes >= 1) material += 6; + if (dupes >= 2) material += 4; + } + } + + return scoreTacticalPriorityLadder({ + scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0, + settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), + denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), + material, + }) + (isScopa && lastPlay ? 30 : 0); +} + +function scoreDumpBeginner( + card: Card, table: Card[], state: GameState, + playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, + hand: Card[], +): number { + const afterTable = [...table, card]; + + // NEVER dump settebello + if (card.suit === 'denara' && card.value === 7) return -5000; + const threats = getPriorityThreatSummary(afterTable, hand, undefined, state, playerIdx); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + let material = -12 + phase * 4; + + if (card.suit === 'denara') material -= 20; + if (card.value === 7) material -= 22; + if (card.value === 6) material -= 10; + if (card.value === 1) material -= 8; + if (card.value >= 8) material += 12 + card.value; + + const dupes = countValueInHand(hand, card.value); + if (dupes >= 2) material += 18; + if (dupes >= 3) material += 8; + + return scoreTacticalPriorityLadder({ + scopa: 0, + settebello: 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, false), + denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, false), + material, + }); +} + +// =========================================================================== +// ADVANCED — strong heuristic with card counting, race tracking, cooperation +// anchor strategy, whirlwind detection, team signaling +// =========================================================================== + +function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { + const player = state.players[playerIdx]; + const table = state.table; + const phase = gamePhase(state); + const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const rankResidue = getRankResidueSnapshot(tracker, player.hand, table); + const next = nextPlayer(playerIdx); + const nextIsOpp = isOpponent(playerIdx, next); + const partner = partnerOf(playerIdx); + const partnerHandSize = state.players[partner].hand.length; + const lastPlay = isLastPlay(state, playerIdx); + + let bestMove: AIMove | null = null; + let bestScore = -Infinity; + + for (const card of player.hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + for (const captureSet of captures) { + const score = scoreCaptureAdv( + card, captureSet, table, state, playerIdx, race, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, + ); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } + } + } else { + const score = scoreDumpAdv( + card, table, state, playerIdx, race, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, + ); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } + } + } + + return bestMove!; +} + +function scoreCaptureAdv( + played: Card, captured: Card[], table: Card[], state: GameState, + playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, + myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, + lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, +): number { + const allCaptured = [played, ...captured]; + const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); + const projectedHand = myHand.filter(card => card.id !== played.id); + const isScopa = afterTable.length === 0; + const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); + const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); + const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats); + const afterTableSum = sumCardValues(afterTable); + const exposedDenariCount = afterTable.filter(card => card.suit === 'denara').length; + const exposedSevenCount = afterTable.filter(card => card.value === 7).length; + const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length; + const capturedSevenCount = allCaptured.filter(card => card.value === 7).length; + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const directSevenPrimieraSwing = scoreDirectSevenPrimieraSwing( + played, + captured, + afterTable, + myHand, + table, + liveSevenPressure, + ); + const liveCardsMajorityRace = race.myCards < 21 + && race.oppCards < 21 + && Math.abs(race.myCards - race.oppCards) <= 5; + const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; + const cardsMajorityDelta = scoreCardsMajorityPosition(race.myCards + allCaptured.length, race.oppCards, phase) + - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase); + let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; + + material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? (protectingCardsLead ? 12 : 16) : protectingCardsLead ? 7 : 10); + material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7); + for (const card of allCaptured) material += primieraVal(card) * 2; + material += Math.round((afterPairInventory - beforePairInventory) * 1.8); + material += directSevenPrimieraSwing; + material += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 1.2 : 0.6)); + if (protectingCardsLead && captured.length > 1) material += 96 + captured.length * 24; + + if (capturesSettebello) material += 72; + if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84; + if ( + protectingCardsLead + && !race.behindInDenari + && captured.length === 1 + && captured[0].suit === 'denara' + && !capturesSettebello + ) { + material -= 84; + } + if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14; + if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16; + if ( + nextIsOpp + && !isScopa + && afterTable.length <= 2 + && afterTableSum <= 12 + ) { + material -= 34; + material -= exposedDenariCount * (liveDenariPressure ? 18 : 10); + material -= exposedSevenCount * (liveSevenPressure ? 20 : 12); + } + + const teamPile = getTeamPile(state, playerIdx); + for (const card of allCaptured) { + if (card.value === 7 && !teamPile.some(teamCard => teamCard.suit === card.suit && teamCard.value === 7)) { + material += 10; + } + } + + material += Math.round(scoreCaptureRankResiduePlan(played, captured, afterTable, rankResidue, roleContext, nextIsOpp) / 6); + material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); + + if (!isScopa) { + for (const tableCard of afterTable) { + const dupes = countValueInHand(myHand, tableCard.value); + if (dupes >= 1) material += 7; + if (dupes >= 2) material += 5; + + const partnerProb = partnerLikelyHolds(tableCard.value, playerIdx, state, tracker, myHand, afterTable); + if (partnerProb > 0.4) material += 6; + } + } + + if (nextIsOpp && tracker?.isSettebelloUnseen() && !capturesSettebello && afterTable.some(c => c.suit === 'denara' && c.value === 7)) { + material -= 18; + } + + if (tracker && !isScopa && phase > 0.5 && sumCardValues(afterTable) <= 10) { + const confidence = Math.min(1, tracker.playedCount / 25); + material -= Math.round(confidence * 20); + } + + if (partnerHandSize === 0) material += captured.length * 8; + if (race.aheadOverall && !isScopa && sumCardValues(afterTable) >= 11) material += 10; + if (race.aheadOverall && !isScopa && sumCardValues(afterTable) <= 5 && nextIsOpp) material -= 12; + if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) material += 8; + if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10; + if (countValueInHand(myHand, played.value) >= 2) { + material -= Math.round((played.value >= 8 ? 28 : 14) * roleContext.pairPreservingBias); + if (roleContext.defendingDealerAdvantage && !isScopa) { + material -= Math.round((played.value >= 8 ? 18 : 8) * roleContext.controlBias); + } + } + + return scoreTacticalPriorityLadder({ + scopa: scopaPriority, + settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.need7s), + denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.behindInDenari), + material, + }) + (isScopa && lastPlay ? 40 : 0); +} + +function scoreDumpAdv( + card: Card, table: Card[], state: GameState, + playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, + myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, + lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, +): number { + const afterTable = [...table, card]; + const projectedHand = myHand.filter(held => held.id !== card.id); + + // --- HARD RULES --- + if (card.suit === 'denara' && card.value === 7) return -10000; + const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); + const tableSum = sumCardValues(afterTable); + const exposedDenariCount = afterTable.filter(tableCard => tableCard.suit === 'denara').length; + const exposedSevenCount = afterTable.filter(tableCard => tableCard.value === 7).length; + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const complement = 10 - card.value; + const preservesHighComplementWindow = nextIsOpp + && card.value >= 1 + && card.value <= 3 + && afterTable.length >= 4 + && afterTable.some(tableCard => tableCard.value === complement); + const openingReleasePriority = evaluateFirstHandOpeningReleasePriority( + card, + myHand, + projectedHand, + afterTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ); + const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const openingDuplicateReleaseBias = scoreOpeningDuplicateReleaseBias( + card, + myHand, + state, + playerIdx, + nextIsOpp, + roleContext, + ); + let material = -20 + phase * 6; + + if (card.suit === 'denara') material -= race.behindInDenari ? 28 : race.denariRaceLive ? 24 : 16; + if (card.value === 7) material -= race.need7s ? 26 : race.sevenRaceLive ? 22 : 14; + if (card.value === 6) material -= 12; + if (card.value === 1) material -= 10; + if (card.value >= 8) material += 14 + card.value * 2; + + const dupes = countValueInHand(myHand, card.value); + if (dupes >= 2) material += 24; + if (dupes >= 3) material += 10; + material += Math.round((afterPairInventory - beforePairInventory) * 1.9); + material += Math.round(openingDuplicateReleaseBias * 0.35); + + const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); + if (partnerProb > 0.4) material += 14; + + material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6); + material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); + + if (afterTable.length >= 4 && tableSum >= 15) material += 10; + if (nextIsOpp && afterTable.length >= 4 && tableSum >= 24) material += 22; + if (!nextIsOpp && card.value >= 8) material += 8; + if (nextIsOpp && afterTable.length <= 2 && tableSum <= 12) { + material -= 28; + material -= exposedDenariCount * (liveDenariPressure ? 14 : 8); + material -= exposedSevenCount * (liveSevenPressure ? 16 : 10); + } + if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { + material += afterTable.length >= 4 ? 34 : 22; + if (tableSum >= 10 && tableSum <= 12) material += 26; + } + if (preservesHighComplementWindow) { + material += tableSum >= 24 ? 56 : 32; + if (card.suit !== 'denara') material += 12; + } + if ( + roleContext.defendingDealerAdvantage + && beforePairInventory > 0 + && afterPairInventory === beforePairInventory + && card.suit !== 'denara' + && card.value <= 4 + ) { + material += 42; + } + + if (tracker) { + const unseen = tracker.getUnseenCards(myHand, afterTable); + let directThreats = 0; + for (const unseenCard of unseen) { + const caps = findCaptures(unseenCard, afterTable); + for (const cap of caps) { + if (cap.length === afterTable.length) { + directThreats++; + break; + } + } + } + material -= directThreats * 8; + + for (const suit of SUITS) { + if (!tracker.hasBeenPlayed(`${suit}_7`) && nextIsOpp && afterTable.some(c => c.suit === suit && c.value === 7)) { + material -= 10; + } + } + + if (phase > 0.5) { + const confidence = Math.min(1, tracker.playedCount / 25); + material += Math.round(material * confidence * 0.15); + } + } + + if (table.length === 0 && nextIsOpp) material -= 18; + if (race.aheadOverall && sumCardValues(afterTable) >= 11) material += 8; + if (race.aheadOverall && card.value >= 8) material += 6; + if (roleContext.role === 'first-hand' && afterTable.length >= 2 && sumCardValues(afterTable) >= 8) material += 6; + if (roleContext.role === 'dealer' && nextIsOpp && sumCardValues(afterTable) <= 10) material -= 8; + + return scoreTacticalPriorityLadder({ + scopa: 0, + settebello: 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority, + partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s), + denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari), + material, + }) + (lastPlay ? 0 : 0); +} + +// =========================================================================== +// MASTER — deep minimax, alpha-beta, determinization, endgame solver +// improved evaluation, team-aware search, last-play awareness +// =========================================================================== + +function tableControlPressure( + afterTable: Card[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + myHand: Card[], + race: RaceState, + roleContext: DealerRoleContext, + rankResidue: RankResidueSnapshot | null, +): number { + if (afterTable.length === 0) return 0; + + let score = 0; + const next = nextPlayer(playerIdx); + const partner = partnerOf(playerIdx); + const nextHandSize = state.players[next].hand.length; + const partnerHandSize = state.players[partner].hand.length; + const nextIsOpp = isOpponent(playerIdx, next); + const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); + + if (tableSum >= 11) score += 70; + if (tableSum <= 10 && nextIsOpp) score -= 110; + if (afterTable.some(card => card.suit === 'denara')) { + score += nextIsOpp + ? (race.behindInDenari ? -110 : -45) + : (race.behindInDenari ? 35 : 15); + } + if (afterTable.some(card => card.value === 7)) { + score += nextIsOpp + ? (race.need7s ? -150 : -55) + : (race.need7s ? 45 : 15); + } + + for (const tableCard of afterTable) { + const myAnchors = countValueInHand(myHand, tableCard.value); + if (myAnchors > 0) score += myAnchors * 18; + + const partnerProb = handLikelyHasValue( + tableCard.value, + partnerHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score += partnerProb * (nextIsOpp ? 20 : 55); + if (nextHandSize > 0 && nextIsOpp) { + const nextProb = handLikelyHasValue( + tableCard.value, + nextHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score -= nextProb * 80; + } + } + + if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; + score += scoreExposedTableCards(afterTable, state, playerIdx, tracker, myHand, race); + score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); + score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); + return score; +} + +interface MoveTacticalSummary { + projectedTable: Card[]; + tableSum: number; + clearsTable: boolean; + capturedDenariCount: number; + capturedSevenCount: number; + capturesSettebello: boolean; + exposedDenariCount: number; + exposedSevenCount: number; + highQuietRelease: boolean; + sameValueAnchorsRemaining: number; +} + +function summarizeMoveTactics( + move: AIMove, + hand: Card[], + table: Card[], +): MoveTacticalSummary { + const projectedTable = move.capture.length > 0 + ? table.filter(card => !move.capture.some(captured => captured.id === card.id)) + : [...table, move.card]; + const tableSum = projectedTable.reduce((sum, card) => sum + card.value, 0); + const capturedCards = getMoveCollectedCards(move); + const exposedDenariCount = projectedTable.filter(card => card.suit === 'denara').length; + const exposedSevenCount = projectedTable.filter(card => card.value === 7).length; + + return { + projectedTable, + tableSum, + clearsTable: move.capture.length > 0 && projectedTable.length === 0, + capturedDenariCount: capturedCards.filter(card => card.suit === 'denara').length, + capturedSevenCount: capturedCards.filter(card => card.value === 7).length, + capturesSettebello: capturedCards.some(card => card.suit === 'denara' && card.value === 7), + exposedDenariCount, + exposedSevenCount, + highQuietRelease: move.capture.length === 0 && move.card.value >= 8 && move.card.suit !== 'denara', + sameValueAnchorsRemaining: Math.max(0, countValueInHand(hand, move.card.value) - 1), + }; +} + +function scoreQuietControlWindow( + move: AIMove, + summary: MoveTacticalSummary, + nextIsOpp: boolean, +): number { + if (move.capture.length > 0 || !nextIsOpp || summary.projectedTable.length < 4) { + return 0; + } + + let score = 0; + const complement = 10 - move.card.value; + const preservesTenLine = move.card.value >= 1 + && move.card.value <= 3 + && summary.projectedTable.some(card => card.value === complement); + + if (preservesTenLine) { + score += 44; + if (summary.tableSum >= 24) score += 32; + } + + if (summary.projectedTable.length >= 5) score += 18; + if (summary.tableSum >= 24) score += 18; + if (move.card.suit !== 'denara' && move.card.value <= 2 && summary.tableSum >= 20) score += 20; + if (summary.exposedDenariCount <= 1) score += 8; + if (summary.exposedSevenCount <= 1) score += 8; + + return score; +} + +function scoreOpeningDuplicateReleaseBias( + card: Card, + hand: Card[], + state: GameState, + playerIdx: PlayerIndex, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): number { + if ( + state.table.length > 0 + || !nextIsOpp + || roleContext.role !== 'first-hand' + ) { + return 0; + } + + const sameValueCount = countValueInHand(hand, card.value); + if (sameValueCount >= 2 && card.value >= 8) { + let score = card.suit === 'denara' ? -180 : 280; + + if ( + card.suit !== 'denara' + && hand.some(held => held.id !== card.id && held.value === card.value && held.suit === 'denara') + ) { + score += 220; + } + + if (sameValueCount >= 3) score += 56; + return score; + } + + if (sameValueCount === 1 && card.value <= 3) return -180; + if (card.suit === 'denara' && card.value <= 8) return -60; + + return 0; +} + +function scoreDirectSevenPrimieraSwing( + played: Card, + captured: Card[], + afterTable: Card[], + hand: Card[], + table: Card[], + liveSevenPressure: boolean, +): number { + if (!table.some(card => card.value === 7) || !captured.some(card => card.value === 7)) { + return 0; + } + + const directSevenCapture = played.value === 7 && captured.length === 1 && captured[0].value === 7; + const alternateDirectSevenAvailable = played.value !== 7 + && hand.some(card => card.id !== played.id && card.value === 7); + let score = 0; + + if (directSevenCapture) { + score += liveSevenPressure ? 320 : 220; + if (afterTable.length <= 3) score += 72; + if (!afterTable.some(card => card.value === 7)) score += liveSevenPressure ? 120 : 60; + } + + if (alternateDirectSevenAvailable) { + score -= liveSevenPressure ? 260 : 160; + } + + return score; +} + +function isForcingSearchMove(summary: MoveTacticalSummary, race: RaceState): boolean { + return summary.clearsTable + || summary.capturesSettebello + || summary.capturedSevenCount > 0 + || summary.capturedDenariCount >= 2 + || (race.behindInDenari && summary.capturedDenariCount > 0); +} + +function isPriorityControlQuietMove( + move: AIMove, + summary: MoveTacticalSummary, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): boolean { + if (move.capture.length > 0) return false; + + if ( + roleContext.defendingDealerAdvantage + && move.card.suit !== 'denara' + && move.card.value <= 4 + && summary.tableSum >= 18 + ) { + return true; + } + + if (!summary.highQuietRelease && summary.sameValueAnchorsRemaining === 0) return false; + if (nextIsOpp && summary.tableSum < 11) return false; + if (nextIsOpp && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0)) return false; + + return roleContext.defendingDealerAdvantage + || summary.sameValueAnchorsRemaining > 0 + || summary.tableSum >= 15 + || summary.projectedTable.length >= 5; +} + +function scoreHandStructure( + hand: Card[], + table: Card[], + roleContext: DealerRoleContext, +): number { + if (hand.length === 0) return 0; + + const counts = Array.from({ length: 11 }, () => 0); + let score = 0; + + for (const card of hand) { + counts[card.value]++; + } + + for (let value = 1; value <= 10; value++) { + if (counts[value] >= 2) { + score += Math.round((value >= 8 ? 32 : 18) * roleContext.pairPreservingBias); + } + if (counts[value] >= 3) { + score += 14; + } + } + + for (const card of hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + let bestCaptureScore = 0; + for (const capture of captures) { + let captureScore = capture.length * 14; + if (capture.some(captured => captured.suit === 'denara')) captureScore += 16; + if (capture.some(captured => captured.value === 7)) captureScore += 20; + if (capture.length === table.length) captureScore += 90; + if (captureScore > bestCaptureScore) bestCaptureScore = captureScore; + } + score += bestCaptureScore; + } else { + if (card.value >= 8) score += 12; + if (card.suit !== 'denara' && card.value >= 8) score += 8; + if (card.value <= 3 && roleContext.defendingDealerAdvantage) score += 10; + } + + if (card.suit === 'denara') score += 10; + if (card.value === 7) score += 16; + } + + return score; +} + +function scoreProtectedPairInventory( + hand: Card[], + roleContext: DealerRoleContext, +): number { + if (hand.length < 2) return 0; + + const counts = Array.from({ length: 11 }, () => 0); + let score = 0; + + for (const card of hand) { + counts[card.value]++; + } + + for (let value = 1; value <= 10; value++) { + if (counts[value] < 2) continue; + + score += value >= 8 ? 18 : 10; + if (value === 7) score += 6; + if (counts[value] >= 3) score += 6; + } + + return Math.round(score * roleContext.pairPreservingBias); +} + +function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): number { + const hand = state.players[playerIdx].hand; + if (hand.length === 0) return 0; + + const roleContext = getDealerRoleContext(state, playerIdx); + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + let bestMoveScore = -Infinity; + let safeReleaseCount = 0; + let forcingCount = 0; + + for (const move of getLegalMoves(state, playerIdx)) { + const summary = summarizeMoveTactics(move, hand, state.table); + let moveScore = 0; + + if (summary.clearsTable) moveScore += 160; + moveScore += summary.capturedDenariCount * 26; + moveScore += summary.capturedSevenCount * 28; + if (summary.tableSum >= 11) moveScore += 24; + if (summary.tableSum <= 10 && nextIsOpp) moveScore -= 36; + if (summary.highQuietRelease && summary.tableSum >= 11) moveScore += 38; + if (summary.sameValueAnchorsRemaining > 0) moveScore += summary.sameValueAnchorsRemaining * 12; + moveScore += scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp); + + if (moveScore > bestMoveScore) bestMoveScore = moveScore; + if (isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext)) safeReleaseCount++; + if (summary.clearsTable || summary.capturedDenariCount > 0 || summary.capturedSevenCount > 0) forcingCount++; + } + + if (!Number.isFinite(bestMoveScore)) bestMoveScore = 0; + + return Math.round( + bestMoveScore + + safeReleaseCount * 18 + + forcingCount * 10 + + scoreHandStructure(hand, state.table, roleContext) * 0.4, + ); +} + +function scoreCurrentPlayerVisibleTempo( + state: GameState, + perspectiveTeam: 0 | 1, +): number { + const currentPlayer = state.currentPlayer; + if (state.players[currentPlayer].hand.length === 0) return 0; + + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + const urgency = cardsRemaining <= 8 ? 0.92 : cardsRemaining <= 16 ? 0.82 : 0.72; + const sign = teamOf(currentPlayer) === perspectiveTeam ? 1 : -1; + + return Math.round(scorePlayerVisibleTempo(state, currentPlayer) * urgency * sign); +} + +function scoreMoveObjectiveBias( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + const hand = state.players[playerIdx].hand; + const phase = gamePhase(state); + const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const rankResidue = getRankResidueSnapshot(tracker, hand, state.table); + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + const lastPlay = isLastPlay(state, playerIdx); + const summary = summarizeMoveTactics(move, hand, state.table); + const projectedHand = hand.filter(card => card.id !== move.card.id); + const capturedCards = getMoveCollectedCards(move); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); + const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); + const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); + const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); + const openingReleasePriority = move.capture.length === 0 + ? evaluateFirstHandOpeningReleasePriority( + move.card, + hand, + projectedHand, + summary.projectedTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ) + : 0; + const beforeHandStructure = scoreHandStructure(hand, state.table, roleContext); + const afterHandStructure = scoreHandStructure(projectedHand, summary.projectedTable, roleContext); + const beforePairInventory = scoreProtectedPairInventory(hand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const handStructureDelta = afterHandStructure - beforeHandStructure; + const pairInventoryDelta = afterPairInventory - beforePairInventory; + const rankResiduePlanScore = move.capture.length > 0 + ? scoreCaptureRankResiduePlan(move.card, move.capture, summary.projectedTable, rankResidue, roleContext, nextIsOpp) + : scoreDumpRankResiduePlan(move.card, summary.projectedTable, rankResidue, roleContext, nextIsOpp); + const quietControlWindow = scoreQuietControlWindow(move, summary, nextIsOpp); + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const openingDuplicateReleaseBias = move.capture.length === 0 + ? scoreOpeningDuplicateReleaseBias(move.card, hand, state, playerIdx, nextIsOpp, roleContext) + : 0; + const directSevenPrimieraSwing = move.capture.length > 0 + ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure) + : 0; + const liveCardsMajorityRace = race.myCards < 21 + && race.oppCards < 21 + && Math.abs(race.myCards - race.oppCards) <= 5; + const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; + const cardsMajorityDelta = move.capture.length > 0 + ? scoreCardsMajorityPosition(race.myCards + capturedCards.length, race.oppCards, phase) + - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase) + : 0; + const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value; + const directSettebelloCapture = directRankCapture + && move.capture[0].suit === 'denara' + && move.capture[0].value === 7; + const exactPartnerWindow = move.capture.length === 0 + && partnerHandSize > 0 + && summary.projectedTable.length >= 4 + && summary.tableSum >= 10 + && summary.tableSum <= 12 + && summary.exposedDenariCount <= 1 + && summary.exposedSevenCount <= 1; + const safePartnerWindow = move.capture.length === 0 + && nextIsOpp + && threats?.partnerCanScopa + && !threats.nextOppCanScopa; + + let bias = 0; + + bias += scopaPriority * 380; + if (summary.clearsTable && !lastPlay) bias += 220; + if (summary.capturesSettebello) bias += 460; + if (directRankCapture) bias += move.card.value === 7 ? 90 : 34; + if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70; + if (directRankCapture && move.capture[0].suit === 'denara') { + bias += liveDenariPressure ? (protectingCardsLead ? 88 : 150) : protectingCardsLead ? 36 : 72; + } + if ( + protectingCardsLead + && !race.behindInDenari + && move.capture.length === 1 + && move.capture[0].suit === 'denara' + && !summary.capturesSettebello + ) { + bias -= 180; + } + if (directSettebelloCapture) bias += 180; + if (directSettebelloCapture && nextIsOpp) bias += 220; + if ( + !summary.capturesSettebello + && state.table.some(card => card.suit === 'denara' && card.value === 7) + && nextIsOpp + ) { + bias -= 460; + } + + bias += antiScopaPriority * 48; + bias += partnerSetupPriority * 8; + bias += evaluateSevenDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.need7s) * (race.need7s ? 42 : race.sevenRaceLive ? 40 : 36); + bias += evaluateDenariDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.behindInDenari) * (race.behindInDenari ? 38 : race.denariRaceLive ? 36 : 32); + bias += openingReleasePriority * 52; + bias += openingDuplicateReleaseBias; + bias += quietControlWindow; + bias += directSevenPrimieraSwing; + bias += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 3.6 : 1.8)); + if (protectingCardsLead && move.capture.length > 1) { + bias += 220 + move.capture.length * 36; + } + bias += Math.round(handStructureDelta * 1.35); + bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5)); + bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85); + bias += Math.round(rankResiduePlanScore * 0.9); + bias += Math.round(scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker) * 0.55); + bias += Math.round(capturedCards.reduce((sum, card) => sum + primieraVal(card), 0) * 3.2); + + if (nextIsOpp) { + bias -= Math.round(summary.projectedTable.reduce((sum, card) => sum + primieraVal(card), 0) * 1.25); + } + + if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { + bias -= 720; + } + + if (move.capture.length === 0) { + if (summary.highQuietRelease) bias += 72; + bias += summary.sameValueAnchorsRemaining * 44; + if ( + nextIsOpp + && summary.projectedTable.length >= 5 + && summary.tableSum >= 24 + && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) + ) { + bias -= 96 + summary.exposedDenariCount * 54 + summary.exposedSevenCount * 68; + } + if (exactPartnerWindow) bias += 96; + if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76; + if ( + roleContext.defendingDealerAdvantage + && move.card.suit !== 'denara' + && move.card.value <= 4 + && summary.sameValueAnchorsRemaining > 0 + ) { + bias += 90; + } + if ( + roleContext.defendingDealerAdvantage + && beforePairInventory > 0 + && afterPairInventory === beforePairInventory + && move.card.suit !== 'denara' + && move.card.value <= 4 + ) { + bias += 152; + } + } else if ( + nextIsOpp + && !summary.clearsTable + && !summary.capturesSettebello + && summary.capturedSevenCount === 0 + && summary.projectedTable.length <= 2 + && summary.tableSum <= 12 + ) { + bias -= 120; + } + + if ( + move.capture.length > 0 + && roleContext.defendingDealerAdvantage + && countValueInHand(hand, move.card.value) >= 2 + && !summary.clearsTable + ) { + bias -= Math.round((move.card.value >= 8 ? 180 : 80) * roleContext.pairPreservingBias); + } + + return teamOf(playerIdx) === teamOf(rootPlayer) ? bias : -bias; +} + +interface RankedRootMove { + index: number; + move: AIMove; + key: string; + quick: number; + isCapture: boolean; + forcing: boolean; + priorityControlQuiet: boolean; +} + +interface MasterSearchProgressState { + evaluationsCompleted: number; + totalEvaluations: number; + batchesCompleted: number; + completedDepth: number; + aspirationExpansions: number; + timedOut: boolean; +} + +interface MasterDepthResult { + completed: boolean; + bestMove: AIMove; + bestKey: string; + bestScore: number; +} + +type TranspositionBound = 'exact' | 'lower' | 'upper'; + +interface TranspositionEntry { + key: string; + bestMove: AIMove | null; + bestMoveKey: string | null; + depth: number; + score: number; + bound: TranspositionBound; +} + +interface MasterRootWorkspace { + moveScores: number[]; + orderedMoves: RankedRootMove[]; + pvMoves: RankedRootMove[]; + hashMoves: RankedRootMove[]; + forcingMoves: RankedRootMove[]; + controlQuietMoves: RankedRootMove[]; + killerHistoryQuietMoves: RankedRootMove[]; + remainingMoves: RankedRootMove[]; +} + +interface SampleHandAssignment { + playerIdx: PlayerIndex; + handSize: number; +} + +interface SampleHandBucket { + assignment: SampleHandAssignment; + cards: Card[]; +} + +interface SearchHeuristics { + killerMoves: Map; + historyScores: Map; +} + +interface AspirationWindow { + alpha: number; + beta: number; +} + +type AspirationFailure = 'lower' | 'upper'; + +const ASPIRATION_BASE_WINDOW = 120; +const EARLY_TURN_ASPIRATION_BASE_WINDOW = 180; +const ASPIRATION_MAX_EXPANSIONS = 5; +const EARLY_TURN_MIN_REMAINING_BUDGET_MS = 420; +const EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION = 0.72; +const KILLER_MOVE_SLOTS = 2; +const MAX_EXACT_SAMPLE_ASSIGNMENTS = 48; +const MAX_FOCUSED_ASSIGNMENT_CARDS = 8; +const ROOT_QUICK_PRIOR_FACTOR = 0.2; + +function isQuietMove(move: AIMove): boolean { + return move.capture.length === 0; +} + +function getQuietHistoryScore( + heuristics: SearchHeuristics, + move: AIMove, +): number { + return heuristics.historyScores.get(moveKey(move)) ?? 0; +} + +function getKillerMoveRank( + heuristics: SearchHeuristics, + ply: number, + move: AIMove, +): number { + const killers = heuristics.killerMoves.get(ply); + if (!killers || killers.length === 0) return -1; + return killers.indexOf(moveKey(move)); +} + +function compareQuietMovePriority( + left: { move: AIMove; quick: number }, + right: { move: AIMove; quick: number }, + heuristics: SearchHeuristics, + ply: number, +): number { + const leftKillerRank = getKillerMoveRank(heuristics, ply, left.move); + const rightKillerRank = getKillerMoveRank(heuristics, ply, right.move); + const leftKillerOrder = leftKillerRank === -1 ? Number.POSITIVE_INFINITY : leftKillerRank; + const rightKillerOrder = rightKillerRank === -1 ? Number.POSITIVE_INFINITY : rightKillerRank; + + if (leftKillerOrder !== rightKillerOrder) { + return leftKillerOrder - rightKillerOrder; + } + + const historyDelta = getQuietHistoryScore(heuristics, right.move) - getQuietHistoryScore(heuristics, left.move); + if (historyDelta !== 0) return historyDelta; + + return right.quick - left.quick; +} + +function recordQuietCutoff( + heuristics: SearchHeuristics, + move: AIMove, + ply: number, + depth: number, +): void { + if (!isQuietMove(move)) return; + + const key = moveKey(move); + const killers = heuristics.killerMoves.get(ply) ?? []; + const updatedKillers = [key, ...killers.filter(existingKey => existingKey !== key)].slice(0, KILLER_MOVE_SLOTS); + heuristics.killerMoves.set(ply, updatedKillers); + + const historyBonus = Math.max(1, depth) * Math.max(1, depth); + heuristics.historyScores.set(key, (heuristics.historyScores.get(key) ?? 0) + historyBonus); +} + +function createAspirationWindow( + previousScore: number | undefined, + depth: number, + sampleCount: number, + minimumHalfWindow: number, +): AspirationWindow { + if (previousScore === undefined) { + return { alpha: -Infinity, beta: Infinity }; + } + + const halfWindow = Math.max( + minimumHalfWindow, + Math.round(sampleCount * 45 + depth * 24), + ); + + return { + alpha: previousScore - halfWindow, + beta: previousScore + halfWindow, + }; +} + +function widenAspirationWindow( + window: AspirationWindow, + failingBound: AspirationFailure, + expansion: number, +): AspirationWindow { + if (failingBound === 'lower') { + return { + alpha: window.alpha - expansion, + beta: window.beta, + }; + } + + return { + alpha: window.alpha, + beta: window.beta + expansion, + }; +} + +function classifyAspirationFailure( + score: number, + window: AspirationWindow, +): AspirationFailure | undefined { + if (score <= window.alpha) return 'lower'; + if (score >= window.beta) return 'upper'; + return undefined; +} + +function searchPrincipalVariationChild( + state: GameState, + depth: number, + alpha: number, + beta: number, + myTeam: 0 | 1, + rootPlayer: PlayerIndex, + phase: number, + deadline: number, + timing: SearchTimingContext, + tracker: CardTracker | undefined, + transpositionTable: Map, + heuristics: SearchHeuristics, + ply: number, + isFirstMove: boolean, + maximizing: boolean, +): number { + if (isFirstMove) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + if (maximizing) { + const scoutBeta = Number.isFinite(alpha) ? Math.min(beta, alpha + 1) : beta; + if (!(scoutBeta > alpha)) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + const scoutScore = alphaBeta( + state, + depth, + alpha, + scoutBeta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + if (scoutScore > alpha && scoutScore < beta && timing.now() <= deadline) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + return scoutScore; + } + + const scoutAlpha = Number.isFinite(beta) ? Math.max(alpha, beta - 1) : alpha; + if (!(scoutAlpha < beta)) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + const scoutScore = alphaBeta( + state, + depth, + scoutAlpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + if (scoutScore < beta && scoutScore > alpha && timing.now() <= deadline) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + return scoutScore; +} + +function rankRootMoves( + legalMoves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + race: RaceState, + roleContext: DealerRoleContext, + timing: SearchTimingContext, +): RankedRootMove[] { + const hand = state.players[playerIdx].hand; + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const rankedMoves = legalMoves.map(move => { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); + const quick = quickEval( + move, + state, + playerIdx, + playerIdx, + tracker, + false, + ); + const summary = summarizeMoveTactics(move, hand, state.table); + return { + move, + key: moveKey(move), + quick, + forcing: isForcingSearchMove(summary, race), + priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext), + }; + }); + + return rankedMoves + .sort((a, b) => b.quick - a.quick) + .map((rankedMove, index) => ({ + ...rankedMove, + index, + isCapture: rankedMove.move.capture.length > 0, + })); +} + +function createMasterRootWorkspace(rootMoveCount: number): MasterRootWorkspace { + return { + moveScores: new Array(rootMoveCount).fill(0), + orderedMoves: [], + pvMoves: [], + hashMoves: [], + forcingMoves: [], + controlQuietMoves: [], + killerHistoryQuietMoves: [], + remainingMoves: [], + }; +} + +function resetMasterRootWorkspace(workspace: MasterRootWorkspace): void { + workspace.orderedMoves.length = 0; + workspace.pvMoves.length = 0; + workspace.hashMoves.length = 0; + workspace.forcingMoves.length = 0; + workspace.controlQuietMoves.length = 0; + workspace.killerHistoryQuietMoves.length = 0; + workspace.remainingMoves.length = 0; +} + +function appendRankedRootMoves(target: RankedRootMove[], source: RankedRootMove[]): void { + for (const rankedMove of source) { + target.push(rankedMove); + } +} + +function orderRootMovesForDepth( + rankedMoves: RankedRootMove[], + previousBestKey: string | undefined, + ttEntry: TranspositionEntry | undefined, + heuristics: SearchHeuristics, + workspace: MasterRootWorkspace, + timing: SearchTimingContext, +): RankedRootMove[] { + if (rankedMoves.length <= 1) return rankedMoves; + + resetMasterRootWorkspace(workspace); + const hashMoveKey = ttEntry?.bestMoveKey ?? undefined; + + for (const rankedMove of rankedMoves) { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); + const quietMoveBoost = !rankedMove.isCapture + && ( + getKillerMoveRank(heuristics, 0, rankedMove.move) !== -1 + || getQuietHistoryScore(heuristics, rankedMove.move) > 0 + ); + + if (previousBestKey && rankedMove.key === previousBestKey) { + workspace.pvMoves.push(rankedMove); + continue; + } + + if (hashMoveKey && rankedMove.key === hashMoveKey) { + workspace.hashMoves.push(rankedMove); + continue; + } + + if (rankedMove.forcing) { + workspace.forcingMoves.push(rankedMove); + continue; + } + + if (rankedMove.priorityControlQuiet) { + workspace.controlQuietMoves.push(rankedMove); + continue; + } + + if (quietMoveBoost) { + workspace.killerHistoryQuietMoves.push(rankedMove); + continue; + } + + workspace.remainingMoves.push(rankedMove); + } + + workspace.killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, 0)); + + appendRankedRootMoves(workspace.orderedMoves, workspace.pvMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.hashMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.forcingMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.controlQuietMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.killerHistoryQuietMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.remainingMoves); + + return workspace.orderedMoves; +} + +function selectBestRootMove( + rankedMoves: RankedRootMove[], + moveScores: number[], +): { bestMove: AIMove; bestKey: string; bestScore: number } { + let bestRootMove = rankedMoves[0]; + let bestScore = moveScores[bestRootMove.index] ?? 0; + + for (const rankedMove of rankedMoves) { + const totalScore = moveScores[rankedMove.index] ?? 0; + if (totalScore > bestScore) { + bestScore = totalScore; + bestRootMove = rankedMove; + } + } + + return { + bestMove: bestRootMove.move, + bestKey: bestRootMove.key, + bestScore, + }; +} + +function getMasterProgress( + progressState: MasterSearchProgressState, + startedAt: number, + budgetMs: number, + timing: SearchTimingContext, +): number { + return Math.max( + progressState.evaluationsCompleted / progressState.totalEvaluations, + Math.min(1, (timing.now() - startedAt) / budgetMs), + ); +} + +function buildMasterProgressDetails( + progressState: MasterSearchProgressState, + cardsRemaining: number, + sampleCount: number, + maxDepth: number, + rootMoveCount: number, +): MasterProgressDetails { + return { + cardsRemaining, + sampleCount, + maxDepth, + completedDepth: progressState.completedDepth, + rootMoveCount, + timedOut: progressState.timedOut, + aspirationExpansions: progressState.aspirationExpansions, + }; +} + +function scoreControlOverrideCandidate( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + race: RaceState, + roleContext: DealerRoleContext, + tracker: CardTracker | undefined, +): number { + const hand = state.players[playerIdx].hand; + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const lastPlay = isLastPlay(state, playerIdx); + const summary = summarizeMoveTactics(move, hand, state.table); + const projectedHand = hand.filter(card => card.id !== move.card.id); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); + const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); + const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); + const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); + const openingReleasePriority = move.capture.length === 0 + ? evaluateFirstHandOpeningReleasePriority( + move.card, + hand, + projectedHand, + summary.projectedTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ) + : 0; + let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55); + + score += summary.projectedTable.length * 48; + score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260; + score += scopaPriority * 600; + score += antiScopaPriority * 54; + score += partnerSetupPriority * 8; + + if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { + score -= 720; + } + + if (move.capture.length === 0) { + if (summary.highQuietRelease) score += 220; + score += openingReleasePriority * 180; + if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70; + if (nextIsOpp && summary.projectedTable.length >= 5) score += 110; + if ( + nextIsOpp + && summary.highQuietRelease + && summary.projectedTable.length >= 5 + && summary.tableSum >= 24 + ) { + if (summary.exposedDenariCount === 0 && summary.exposedSevenCount === 0) { + score += 260; + } else { + score -= 80 + summary.exposedDenariCount * 90 + summary.exposedSevenCount * 120; + } + } + } else { + if (!isForcingSearchMove(summary, race)) { + score -= summary.projectedTable.length <= 2 || summary.tableSum <= 12 ? 200 : 80; + } + if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) { + score -= 180; + } + if (nextIsOpp && summary.projectedTable.length <= 2) score -= 150; + else if (nextIsOpp && summary.projectedTable.length === 3 && summary.tableSum <= 12) score -= 90; + if (nextIsOpp) score -= summary.exposedDenariCount * 90; + if (nextIsOpp) score -= summary.exposedSevenCount * 70; + if ( + nextIsOpp + && !summary.clearsTable + && !summary.capturesSettebello + && summary.capturedSevenCount === 0 + && summary.projectedTable.length <= 2 + && summary.tableSum < 18 + ) { + score -= 220; + } + } + + if (roleContext.defendingDealerAdvantage && move.capture.length === 0 && summary.tableSum >= 18) { + score += 180; + } + + if (nextIsOpp && summary.projectedTable.length > 0 && summary.tableSum <= 10) { + score -= 220; + } + + return score; +} + +function findStrategicControlOverride( + legalMoves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + race: RaceState, + roleContext: DealerRoleContext, + tracker: CardTracker | undefined, +): AIMove | undefined { + if (legalMoves.length <= 1) return undefined; + const lastPlay = isLastPlay(state, playerIdx); + if (lastPlay) return undefined; + if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined; + + let bestQuiet: + | { move: AIMove; score: number } + | undefined; + let bestCapture: + | { move: AIMove; score: number } + | undefined; + let bestSafeScopa: + | { move: AIMove; score: number } + | undefined; + + for (const move of legalMoves) { + const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker); + const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table); + const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats); + + if (scopaPriority > 0) { + if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score }; + } + + if (move.capture.length === 0) { + if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score }; + continue; + } + + if (!bestCapture || score > bestCapture.score) bestCapture = { move, score }; + } + + if (bestSafeScopa) return bestSafeScopa.move; + + if (!bestQuiet) return undefined; + + const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table); + const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== bestQuiet.move.card.id); + const duplicateHighValues = new Set( + projectedHand.filter(card => projectedHand.some(other => other.id !== card.id && other.value === card.value && card.value >= 8)) + .map(card => card.value), + ).size; + const dealerControlQuiet = roleContext.role === 'dealer' + && bestCapture !== undefined + && bestQuiet.score >= bestCapture.score + 220 + && bestQuiet.move.card.suit !== 'denara' + && bestQuiet.move.card.value <= 3 + && quietSummary.projectedTable.length >= 5 + && quietSummary.tableSum >= 18 + && duplicateHighValues > 0; + + if (dealerControlQuiet) { + return bestQuiet.move; + } + + if (!bestCapture) return undefined; + + const captureSummary = summarizeMoveTactics(bestCapture.move, state.players[playerIdx].hand, state.table); + const antiScopaControlQuiet = bestQuiet.score >= bestCapture.score + 120 + && bestQuiet.move.card.suit !== 'denara' + && bestQuiet.move.card.value >= 8 + && quietSummary.projectedTable.length >= 5 + && quietSummary.tableSum >= 24 + && state.table.some(card => card.suit === 'denara' || card.value === 7) + && bestCapture.move.card.value <= 5 + && captureSummary.capturedSevenCount === 0 + && !captureSummary.clearsTable + && !captureSummary.capturesSettebello + && captureSummary.projectedTable.length <= 3; + + return antiScopaControlQuiet ? bestQuiet.move : undefined; +} + +async function evaluateMasterDepth( + state: GameState, + samples: GameState[], + orderedMoves: RankedRootMove[], + depth: number, + aspirationWindow: AspirationWindow, + playerIdx: PlayerIndex, + myTeam: 0 | 1, + phase: number, + deadline: number, + tracker: CardTracker | undefined, + onProgress: ((progress: AIDecisionProgress) => void) | undefined, + profile: SearchProfile, + startedAt: number, + timing: SearchTimingContext, + progressState: MasterSearchProgressState, + transpositionTable: Map, + heuristics: SearchHeuristics, + rootWorkspace: MasterRootWorkspace, + cardsRemaining: number, + sampleCount: number, + rootMoveCount: number, +): Promise { + const moveScores = rootWorkspace.moveScores; + moveScores.fill(0); + for (const orderedMove of orderedMoves) { + moveScores[orderedMove.index] = orderedMove.quick * ROOT_QUICK_PRIOR_FACTOR; + } + + for (let start = 0; start < samples.length; start += profile.batchSize) { + if (timing.now() > deadline) { + progressState.timedOut = true; + return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; + } + + const end = Math.min(start + profile.batchSize, samples.length); + for (let sampleIdx = start; sampleIdx < end; sampleIdx++) { + const sample = samples[sampleIdx]; + let sampleAlpha = aspirationWindow.alpha; + const sampleBeta = aspirationWindow.beta; + let isFirstRootMove = true; + + for (const orderedMove of orderedMoves) { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); + if (timing.now() > deadline) { + progressState.timedOut = true; + return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; + } + + const result = applyMove( + sample, + playerIdx, + orderedMove.move.card, + orderedMove.move.capture.length > 0 ? orderedMove.move.capture : undefined, + ); + const score = searchPrincipalVariationChild( + result.nextState, + depth - 1, + sampleAlpha, + sampleBeta, + myTeam, + playerIdx, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + 1, + isFirstRootMove, + true, + ); + moveScores[orderedMove.index] += score; + if (score > sampleAlpha) { + sampleAlpha = score; + } + progressState.evaluationsCompleted++; + isFirstRootMove = false; + } + } + + progressState.batchesCompleted++; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails( + progressState, + cardsRemaining, + sampleCount, + profile.maxDepth, + rootMoveCount, + ), + ); + + if (end < samples.length && timing.now() < deadline) { + await timing.yieldToHost(); + } + } + + return { completed: true, ...selectBestRootMove(orderedMoves, moveScores) }; +} + +async function masterMove( + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + onProgress: ((progress: AIDecisionProgress) => void) | undefined, + profile: SearchProfile, + startedAt: number, + timing: SearchTimingContext, + rng: RandomSource, +): Promise { + const myTeam = teamOf(playerIdx); + const phase = gamePhase(state); + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + const legalMoves = getLegalMoves(state, playerIdx); + const rootMoveCount = legalMoves.length; + if (legalMoves.length === 1) { + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: 1, + maxDepth: 1, + completedDepth: 1, + rootMoveCount, + timedOut: false, + aspirationExpansions: 0, + }); + return legalMoves[0]; + } + + const deadline = startedAt + profile.timeBudgetMs; + const progressState: MasterSearchProgressState = { + evaluationsCompleted: 0, + totalEvaluations: Math.max(1, rootMoveCount * profile.maxDepth), + batchesCompleted: 0, + completedDepth: 0, + aspirationExpansions: 0, + timedOut: false, + }; + + const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const rankedMoves = rankRootMoves( + legalMoves, + state, + playerIdx, + tracker, + race, + roleContext, + timing, + ); + let bestPreSearchMove = rankedMoves[0].move; + if (timing.now() > deadline) { + progressState.timedOut = true; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, 0, profile.maxDepth, rankedMoves.length), + ); + return bestPreSearchMove; + } + + const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng, timing); + const sampleCount = samples.length; + progressState.totalEvaluations = Math.max(1, samples.length * rankedMoves.length * profile.maxDepth); + if (timing.now() > deadline) { + progressState.timedOut = true; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); + return bestPreSearchMove; + } + + const transpositionTable = new Map(); + const heuristics: SearchHeuristics = { + killerMoves: new Map(), + historyScores: new Map(), + }; + const rootWorkspace = createMasterRootWorkspace(rankedMoves.length); + const rootStateKey = buildSearchStateKey(state); + + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + 0, + 0, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); + + let previousBestKey: string | undefined; + let lastCompletedDepth: MasterDepthResult | undefined; + let lastCompletedDepthElapsedMs: number | undefined; + + for (let depth = 1; depth <= profile.maxDepth; depth++) { + const depthStartedAt = timing.now(); + if (depthStartedAt > deadline) { + progressState.timedOut = true; + break; + } + + if (cardsRemaining > 20) { + const remainingBudgetMs = deadline - depthStartedAt; + if (remainingBudgetMs < EARLY_TURN_MIN_REMAINING_BUDGET_MS) { + break; + } + + if ( + lastCompletedDepthElapsedMs !== undefined + && lastCompletedDepthElapsedMs >= profile.timeBudgetMs * EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION + ) { + break; + } + } + + const aspirationHalfWindowFloor = cardsRemaining > 20 || rootMoveCount >= 8 + ? EARLY_TURN_ASPIRATION_BASE_WINDOW + : ASPIRATION_BASE_WINDOW; + let aspirationWindow = createAspirationWindow( + lastCompletedDepth?.bestScore, + depth, + samples.length, + aspirationHalfWindowFloor, + ); + let depthResult: MasterDepthResult | undefined; + + for (let expansion = 0; expansion <= ASPIRATION_MAX_EXPANSIONS; expansion++) { + if (timing.now() > deadline) { + progressState.timedOut = true; + break; + } + + const rootEntry = transpositionTable.get(rootStateKey); + const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace, timing); + bestPreSearchMove = orderedMoves[0]?.move ?? bestPreSearchMove; + if (timing.now() > deadline) { + progressState.timedOut = true; + break; + } + + depthResult = await evaluateMasterDepth( + state, + samples, + orderedMoves, + depth, + aspirationWindow, + playerIdx, + myTeam, + phase, + deadline, + tracker, + onProgress, + profile, + startedAt, + timing, + progressState, + transpositionTable, + heuristics, + rootWorkspace, + cardsRemaining, + sampleCount, + rankedMoves.length, + ); + + if (!depthResult.completed) { + break; + } + + const failingBound = classifyAspirationFailure(depthResult.bestScore, aspirationWindow); + if (!failingBound) { + lastCompletedDepth = depthResult; + lastCompletedDepthElapsedMs = timing.now() - depthStartedAt; + previousBestKey = depthResult.bestKey; + progressState.completedDepth = depth; + break; + } + + progressState.aspirationExpansions++; + const windowWidth = Number.isFinite(aspirationWindow.alpha) && Number.isFinite(aspirationWindow.beta) + ? aspirationWindow.beta - aspirationWindow.alpha + : aspirationHalfWindowFloor; + aspirationWindow = widenAspirationWindow( + aspirationWindow, + failingBound, + Math.max(aspirationHalfWindowFloor, windowWidth * 2), + ); + } + + if (!depthResult?.completed || lastCompletedDepth !== depthResult) { + break; + } + + if (depth < profile.maxDepth && timing.now() < deadline) { + await timing.yieldToHost(); + } + } + + const bestMove = lastCompletedDepth?.bestMove ?? bestPreSearchMove; + + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + 1, + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); + return bestMove; +} + +function quickEval( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, + allowHiddenHands: boolean, +): number { + const result = applyMove(state, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); + const nextState = result.nextState; + + return evaluateTeamPosition( + nextState, + teamOf(rootPlayer), + gamePhase(nextState), + tracker, + rootPlayer, + allowHiddenHands, + ) + scoreMoveObjectiveBias(move, state, playerIdx, rootPlayer, tracker); +} + +function moveKey(move: AIMove): string { + const capIds = move.capture.map(c => c.id).sort().join(','); + return `${move.card.id}|${capIds}`; +} + +function getMoveCollectedCards(move: AIMove): Card[] { + if (move.capture.length === 0) return []; + return [move.card, ...move.capture]; +} + +function stableCardCollectionKey(cards: Card[]): string { + return cards.map(card => card.id).sort().join(','); +} + +function buildSearchStateKey(state: GameState): string { + const playerKeys = state.players.map((player, index) => { + const handKey = stableCardCollectionKey(player.hand); + const pileKey = stableCardCollectionKey(player.pile); + return `p${index}h:${handKey}|p${index}p:${pileKey}|p${index}s:${player.scope}`; + }).join('|'); + + return [ + `cp:${state.currentPlayer}`, + `d:${state.dealer}`, + `l:${state.lastCapturTeam ?? 'null'}`, + `t:${stableCardCollectionKey(state.table)}`, + playerKeys, + ].join('|'); +} + +function getValidHashMove( + moves: AIMove[], + entry: TranspositionEntry | undefined, +): AIMove | undefined { + if (!entry?.bestMoveKey) return undefined; + + return moves.find(move => moveKey(move) === entry.bestMoveKey); +} + +function orderSearchMoves( + moves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, + pvMove: AIMove | undefined, + hashMove: AIMove | undefined, + heuristics: SearchHeuristics, + ply: number, +): AIMove[] { + if (moves.length <= 1) return moves; + + const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const hand = state.players[playerIdx].hand; + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const maximizingNode = teamOf(playerIdx) === teamOf(rootPlayer); + const rankedMoves = moves + .map(move => ({ + move, + key: moveKey(move), + quick: quickEval(move, state, playerIdx, rootPlayer, tracker, true), + })) + .sort((a, b) => maximizingNode ? b.quick - a.quick : a.quick - b.quick); + + const pvMoveKey = pvMove ? moveKey(pvMove) : undefined; + const hashMoveKey = hashMove ? moveKey(hashMove) : undefined; + const pvMoves: typeof rankedMoves = []; + const hashMoves: typeof rankedMoves = []; + const forcingMoves: typeof rankedMoves = []; + const controlQuietMoves: typeof rankedMoves = []; + const killerHistoryQuietMoves: typeof rankedMoves = []; + const remainingMoves: typeof rankedMoves = []; + + for (const rankedMove of rankedMoves) { + const moveSummary = summarizeMoveTactics(rankedMove.move, hand, state.table); + const quietMoveBoost = isQuietMove(rankedMove.move) + && ( + getKillerMoveRank(heuristics, ply, rankedMove.move) !== -1 + || getQuietHistoryScore(heuristics, rankedMove.move) > 0 + ); + + if (pvMoveKey && rankedMove.key === pvMoveKey) { + pvMoves.push(rankedMove); + continue; + } + + if (hashMoveKey && rankedMove.key === hashMoveKey) { + hashMoves.push(rankedMove); + continue; + } + + if (isForcingSearchMove(moveSummary, race)) { + forcingMoves.push(rankedMove); + continue; + } + + if (isPriorityControlQuietMove(rankedMove.move, moveSummary, nextIsOpp, roleContext)) { + controlQuietMoves.push(rankedMove); + continue; + } + + if (quietMoveBoost) { + killerHistoryQuietMoves.push(rankedMove); + continue; + } + + remainingMoves.push(rankedMove); + } + + killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, ply)); + + return [ + ...pvMoves, + ...hashMoves, + ...forcingMoves, + ...controlQuietMoves, + ...killerHistoryQuietMoves, + ...remainingMoves, + ].map(rankedMove => rankedMove.move); +} + +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 createSampleHandAssignments(state: GameState, playerIdx: PlayerIndex): SampleHandAssignment[] { + const assignments: SampleHandAssignment[] = []; + let cur = nextPlayer(playerIdx); + + for (let step = 0; step < 3; step++) { + const handSize = state.players[cur].hand.length; + if (handSize > 0) { + assignments.push({ + playerIdx: cur, + handSize, + }); + } + cur = nextPlayer(cur); + } + + return assignments; +} + +function getUnseenCardPriority(card: Card, table: Card[]): number { + let priority = card.value; + + if (card.suit === 'denara' && card.value === 7) { + priority += 20000; + } else if (card.value === 7) { + priority += 12000; + } else if (card.suit === 'denara') { + priority += 6000; + } + + if (card.value === 6) priority += 900; + if (card.value === 1) priority += 700; + priority += primieraVal(card) * 25; + + if (canCapture(card, table)) { + priority += 800; + const captures = findCaptures(card, table); + let bestCapturePriority = 0; + for (const capture of captures) { + let capturePriority = capture.length * 140; + for (const capturedCard of capture) { + if (capturedCard.suit === 'denara') capturePriority += 90; + if (capturedCard.value === 7) capturePriority += 160; + } + if (capture.length === table.length) capturePriority += 500; + if (capturePriority > bestCapturePriority) bestCapturePriority = capturePriority; + } + priority += bestCapturePriority; + } + + return priority; +} + +function prioritizeUnseenCards(unseen: Card[], table: Card[]): Card[] { + return [...unseen].sort((left, right) => { + const priorityDelta = getUnseenCardPriority(right, table) - getUnseenCardPriority(left, table); + if (priorityDelta !== 0) return priorityDelta; + return left.id.localeCompare(right.id); + }); +} + +function rotateValues(values: T[], offset: number): T[] { + if (values.length <= 1) return values; + + const normalizedOffset = ((offset % values.length) + values.length) % values.length; + if (normalizedOffset === 0) return [...values]; + return [...values.slice(normalizedOffset), ...values.slice(0, normalizedOffset)]; +} + +function getAssignmentOrderVariants(assignments: SampleHandAssignment[]): SampleHandAssignment[][] { + if (assignments.length <= 1) return [assignments]; + + if (assignments.length === 2) { + return [ + assignments, + [assignments[1], assignments[0]], + ]; + } + + return [ + assignments, + [assignments[0], assignments[2], assignments[1]], + [assignments[1], assignments[0], assignments[2]], + [assignments[2], assignments[0], assignments[1]], + [assignments[1], assignments[2], assignments[0]], + [assignments[2], assignments[1], assignments[0]], + ]; +} + +function buildSampleAssignmentKey(assignments: SampleHandBucket[]): string { + return assignments + .map(({ assignment, cards }) => `${assignment.playerIdx}:${stableCardCollectionKey(cards)}`) + .join('|'); +} + +function combinationCount(n: number, k: number): number { + if (k < 0 || k > n) return 0; + if (k === 0 || k === n) return 1; + + let result = 1; + const boundedK = Math.min(k, n - k); + for (let index = 1; index <= boundedK; index++) { + result = (result * (n - boundedK + index)) / index; + if (result > MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return result; + } + } + + return result; +} + +function getHiddenAssignmentCount(unseenCount: number, assignments: SampleHandAssignment[]): number { + let remaining = unseenCount; + let totalAssignments = 1; + + for (const assignment of assignments) { + totalAssignments *= combinationCount(remaining, assignment.handSize); + if (totalAssignments > MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return totalAssignments; + } + remaining -= assignment.handSize; + } + + return totalAssignments; +} + +function assignBucketsToSample( + sample: GameState, + assignments: SampleHandBucket[], +): void { + for (const { assignment, cards } of assignments) { + sample.players[assignment.playerIdx].hand = cards.slice(); + } +} + +function buildExactSampleStates( + state: GameState, + prioritizedUnseen: Card[], + assignments: SampleHandAssignment[], + timing: SearchTimingContext, +): GameState[] { + const samples: GameState[] = []; + const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); + + const visitAssignment = (assignmentIndex: number, remainingCards: Card[]): boolean => { + if (samples.length >= MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return true; + } + + if (assignmentIndex >= buckets.length) { + const sample = cloneState(state); + assignBucketsToSample(sample, buckets); + samples.push(sample); + return false; + } + + const bucket = buckets[assignmentIndex]; + const targetSize = bucket.assignment.handSize; + const chosenIndices: number[] = []; + + const chooseCards = (startIndex: number): boolean => { + if (chosenIndices.length === targetSize) { + bucket.cards = chosenIndices.map(index => remainingCards[index]); + const nextRemaining = remainingCards.filter((_, index) => !chosenIndices.includes(index)); + return visitAssignment(assignmentIndex + 1, nextRemaining); + } + + const needed = targetSize - chosenIndices.length; + const maxStart = remainingCards.length - needed; + for (let index = startIndex; index <= maxStart; index++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + chosenIndices.push(index); + if (chooseCards(index + 1)) return true; + chosenIndices.pop(); + } + + return false; + }; + + return chooseCards(0); + }; + + visitAssignment(0, prioritizedUnseen); + return samples; +} + +function scoreUnseenCardTablePressure(card: Card, table: Card[]): number { + let score = 0; + const captures = findCaptures(card, table); + + for (const capture of captures) { + let captureScore = capture.length * 28; + if (capture.some(captured => captured.suit === 'denara')) captureScore += 22; + if (capture.some(captured => captured.value === 7)) captureScore += 28; + if (capture.length === table.length) captureScore += 140; + if (captureScore > score) score = captureScore; + } + + if (table.some(tableCard => tableCard.value === card.value)) score += 40; + if (card.suit === 'denara') score += 20; + if (card.value === 7) score += 28; + if (card.value >= 8 && captures.length === 0) score += 12; + + return score; +} + +function scoreSampleAssignmentCandidate( + card: Card, + assignment: SampleHandAssignment, + state: GameState, + rootPlayer: PlayerIndex, + rankResidue: RankResidueSnapshot | null, +): number { + const next = nextPlayer(rootPlayer); + const partner = partnerOf(rootPlayer); + const assignmentIsOpp = isOpponent(rootPlayer, assignment.playerIdx); + const playsNext = assignment.playerIdx === next; + const isPartner = assignment.playerIdx === partner; + const assignmentRole = getDealerRoleContext(state, assignment.playerIdx); + const pressureScore = scoreUnseenCardTablePressure(card, state.table); + let score = assignment.handSize * 2; + + if (playsNext) { + score += pressureScore * (assignmentIsOpp ? 2.5 : 1.6); + } else if (assignmentIsOpp) { + score += pressureScore * 1.25; + } else if (isPartner) { + score += pressureScore * 1.1; + } else { + score += pressureScore * 0.85; + } + + if (rankResidue) { + if (rankResidue.hasSingletonResidue[card.value]) { + score += playsNext ? 30 : 12; + } + if (rankResidue.hasPairedResidue[card.value]) { + score += assignmentRole.defendingDealerAdvantage ? 20 : 8; + } + } + + if (card.suit === 'denara') { + score += playsNext && assignmentIsOpp ? 26 : assignmentRole.onDealerSide ? 14 : 8; + } + if (card.value === 7) { + score += assignmentIsOpp ? 24 : 14; + } + if (card.value >= 8 && !canCapture(card, state.table)) { + score += assignmentRole.defendingDealerAdvantage ? 16 : 8; + } + + return score; +} + +function selectSampleBucketForCard( + card: Card, + buckets: SampleHandBucket[], + state: GameState, + rootPlayer: PlayerIndex, + rankResidue: RankResidueSnapshot | null, + sampleIndex: number, +): SampleHandBucket | undefined { + const availableBuckets = buckets.filter(bucket => bucket.cards.length < bucket.assignment.handSize); + if (availableBuckets.length === 0) return undefined; + + const rankedBuckets = availableBuckets + .map(bucket => ({ + bucket, + score: scoreSampleAssignmentCandidate(card, bucket.assignment, state, rootPlayer, rankResidue), + })) + .sort((left, right) => right.score - left.score); + + const topBucketCount = Math.min(2, rankedBuckets.length); + const selectedIndex = topBucketCount === 1 ? 0 : sampleIndex % topBucketCount; + return rankedBuckets[selectedIndex]?.bucket; +} + +function buildStratifiedSampleBuckets( + state: GameState, + playerIdx: PlayerIndex, + prioritizedUnseen: Card[], + assignments: SampleHandAssignment[], + rankResidue: RankResidueSnapshot | null, + sampleIndex: number, + rng: RandomSource, + timing: SearchTimingContext, +): SampleHandBucket[] { + const orderVariants = getAssignmentOrderVariants(assignments); + const assignmentOrder = orderVariants[sampleIndex % orderVariants.length]; + const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); + const bucketByPlayer = new Map(buckets.map(bucket => [bucket.assignment.playerIdx, bucket])); + + const focusedCards = prioritizedUnseen.slice(0, Math.min(MAX_FOCUSED_ASSIGNMENT_CARDS, prioritizedUnseen.length)); + const focusedCardIds = new Set(focusedCards.map(card => card.id)); + const remainingCards = shuffleArray( + prioritizedUnseen.filter(card => !focusedCardIds.has(card.id)), + rng, + ); + + for (let index = 0; index < focusedCards.length; index++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + const preferredBucket = selectSampleBucketForCard( + focusedCards[index], + buckets, + state, + playerIdx, + rankResidue, + sampleIndex + index, + ); + if (preferredBucket) { + preferredBucket.cards.push(focusedCards[index]); + } + } + + let remainingIndex = 0; + for (const assignment of assignmentOrder) { + const bucket = bucketByPlayer.get(assignment.playerIdx); + if (!bucket) continue; + while (bucket.cards.length < assignment.handSize && remainingIndex < remainingCards.length) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + bucket.cards.push(remainingCards[remainingIndex]); + remainingIndex++; + } + } + + return buckets; +} + +function generateSamples( + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + count: number, + rng: RandomSource, + timing: SearchTimingContext, +): GameState[] { + const myHand = state.players[playerIdx].hand; + const unseen = tracker + ? tracker.getUnseenCards(myHand, state.table) + : getUnseenWithoutTracker(state, playerIdx); + const assignments = createSampleHandAssignments(state, playerIdx); + + if (assignments.length === 0 || unseen.length === 0) { + return [cloneState(state)]; + } + + const prioritizedUnseen = prioritizeUnseenCards(unseen, state.table); + const rankResidue = getRankResidueSnapshot(tracker, myHand, state.table); + const hiddenAssignmentCount = getHiddenAssignmentCount(prioritizedUnseen.length, assignments); + if ( + prioritizedUnseen.length <= 8 + && hiddenAssignmentCount <= MAX_EXACT_SAMPLE_ASSIGNMENTS + ) { + return buildExactSampleStates(state, prioritizedUnseen, assignments, timing); + } + + const samples: GameState[] = []; + const seenAssignments = new Set(); + const targetSamples = Math.max(1, count); + const maxAttempts = targetSamples * 4; + + for (let attempt = 0; attempt < maxAttempts && samples.length < targetSamples; attempt++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + const sample = cloneState(state); + const sampleBuckets = buildStratifiedSampleBuckets( + state, + playerIdx, + prioritizedUnseen, + assignments, + rankResidue, + attempt, + rng, + timing, + ); + const sampleKey = buildSampleAssignmentKey(sampleBuckets); + if (seenAssignments.has(sampleKey)) continue; + + seenAssignments.add(sampleKey); + assignBucketsToSample(sample, sampleBuckets); + samples.push(sample); + } + + if (samples.length === 0) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + const fallbackSample = cloneState(state); + assignBucketsToSample( + fallbackSample, + buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng, timing), + ); + return [fallbackSample]; + } + + return samples; +} + +function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] { + return getUnseenCardsForEstimate( + state, + playerIdx, + state.players[playerIdx].hand, + state.table, + undefined, + ); +} + +function getUnseenCardsForEstimate( + state: GameState, + playerIdx: PlayerIndex, + myHand: Card[], + table: Card[], + tracker: CardTracker | undefined, +): Card[] { + if (tracker) { + return tracker.getUnseenCards(myHand, table); + } + + const known = new Set(); + for (const card of myHand) known.add(card.id); + for (const card of table) known.add(card.id); + for (const player of state.players) { + for (const card of player.pile) { + known.add(card.id); + } + } + + const deck = buildDeck(); + return deck.filter(card => !known.has(card.id)); +} + +function shuffleArray(arr: T[], rng: RandomSource = Math.random): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function alphaBeta( + state: GameState, depth: number, alpha: number, beta: number, + myTeam: 0 | 1, rootPlayer: PlayerIndex, + phase: number, deadline: number, + timing: SearchTimingContext, + tracker: CardTracker | undefined, + transpositionTable: Map, + heuristics: SearchHeuristics, + ply: number, +): number { + const stateKey = buildSearchStateKey(state); + + if (depth === 0 || state.roundOver) { + const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); + transpositionTable.set(stateKey, { + key: stateKey, + bestMove: null, + bestMoveKey: null, + depth, + score, + bound: 'exact', + }); + return score; + } + + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + if (timing.now() > deadline) { + return evaluateFast(state, myTeam, phase, tracker, rootPlayer); + } + + const originalAlpha = alpha; + const originalBeta = beta; + const ttEntry = transpositionTable.get(stateKey); + if (ttEntry && ttEntry.depth >= depth) { + if (ttEntry.bound === 'exact') { + return ttEntry.score; + } + if (ttEntry.bound === 'lower') { + alpha = Math.max(alpha, ttEntry.score); + } else { + beta = Math.min(beta, ttEntry.score); + } + if (alpha >= beta) { + return ttEntry.score; + } + } + + const cur = state.currentPlayer; + const isMyTeam = teamOf(cur) === myTeam; + const moves = getLegalMoves(state, cur); + + if (moves.length === 0) { + const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); + transpositionTable.set(stateKey, { + key: stateKey, + bestMove: null, + bestMoveKey: null, + depth, + score, + bound: 'exact', + }); + return score; + } + + const orderedMoves = orderSearchMoves( + moves, + state, + cur, + rootPlayer, + tracker, + ttEntry?.bound === 'exact' ? getValidHashMove(moves, ttEntry) : undefined, + getValidHashMove(moves, ttEntry), + heuristics, + ply, + ); + + if (isMyTeam) { + let value = -Infinity; + let bestMove: AIMove | null = null; + let isFirstMove = true; + for (const move of orderedMoves) { + const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); + const child = searchPrincipalVariationChild( + result.nextState, + depth - 1, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply + 1, + isFirstMove, + true, + ); + if (child > value) { + value = child; + bestMove = move; + } + alpha = Math.max(alpha, value); + if (beta <= alpha) { + recordQuietCutoff(heuristics, move, ply, depth); + break; + } + isFirstMove = false; + } + + transpositionTable.set(stateKey, { + key: stateKey, + bestMove, + bestMoveKey: bestMove ? moveKey(bestMove) : null, + depth, + score: value, + bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', + }); + return value; + } else { + let value = Infinity; + let bestMove: AIMove | null = null; + let isFirstMove = true; + for (const move of orderedMoves) { + const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); + const child = searchPrincipalVariationChild( + result.nextState, + depth - 1, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply + 1, + isFirstMove, + false, + ); + if (child < value) { + value = child; + bestMove = move; + } + beta = Math.min(beta, value); + if (beta <= alpha) { + recordQuietCutoff(heuristics, move, ply, depth); + break; + } + isFirstMove = false; + } + + transpositionTable.set(stateKey, { + key: stateKey, + bestMove, + bestMoveKey: bestMove ? moveKey(bestMove) : null, + depth, + score: value, + bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', + }); + return value; + } +} + +interface TeamEvaluationSnapshot { + cards: number; + denari: number; + settebello: boolean; + primiera: number; + primieraSuits: number; + sevenSuits: number; + sevens: number; + sixes: number; + aces: number; + scope: number; + totalPoints: number; +} + +function buildTeamEvaluationSnapshot(state: GameState, team: 0 | 1): TeamEvaluationSnapshot { + const players = team === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + const bestPrimieraBySuit: Partial> = {}; + const sevenSuits = new Set(); + let cards = 0; + let denari = 0; + let settebello = false; + let sevens = 0; + let sixes = 0; + let aces = 0; + let scope = 0; + + for (const player of players) { + scope += player.scope; + for (const card of player.pile) { + cards++; + if (card.suit === 'denara') { + denari++; + if (card.value === 7) settebello = true; + } + if (card.value === 7) { + sevens++; + sevenSuits.add(card.suit); + } + if (card.value === 6) sixes++; + if (card.value === 1) aces++; + + const primieraScore = PRIMIERA_VALUES[card.value] ?? 0; + if ((bestPrimieraBySuit[card.suit] ?? 0) < primieraScore) { + bestPrimieraBySuit[card.suit] = primieraScore; + } + } + } + + let primiera = 0; + let primieraSuits = 0; + for (const suit of SUITS) { + const suitScore = bestPrimieraBySuit[suit] ?? 0; + if (suitScore > 0) { + primiera += suitScore; + primieraSuits++; + } + } + + return { + cards, + denari, + settebello, + primiera, + primieraSuits, + sevenSuits: sevenSuits.size, + sevens, + sixes, + aces, + scope, + totalPoints: state.teamScores[team].totalPoints, + }; +} + +function scoreMajorityRace( + myValue: number, + oppValue: number, + target: number, + unitWeight: number, + thresholdBonus: number, +): number { + let score = (myValue - oppValue) * unitWeight; + + if (myValue >= target && oppValue < target) { + score += thresholdBonus; + } else if (oppValue >= target && myValue < target) { + score -= thresholdBonus; + } else { + const myDistance = Math.max(0, target - myValue); + const oppDistance = Math.max(0, target - oppValue); + score += (oppDistance - myDistance) * Math.round(unitWeight * 0.75); + } + + return score; +} + +function scoreCardsMajorityPosition( + myCards: number, + oppCards: number, + phase: number, +): number { + return scoreMajorityRace(myCards, oppCards, 21, Math.round(24 + phase * 22), 240); +} + +function getRoundScoringCardWeight(card: Card): number { + let weight = 18 + primieraVal(card) * 3; + + if (card.suit === 'denara') weight += 42; + if (card.value === 7) weight += 44; + if (card.suit === 'denara' && card.value === 7) weight += 120; + + return weight; +} + +function scorePendingTableOwnership(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0 || state.lastCapturTeam === null) return 0; + + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75; + const tableValue = state.table.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + return Math.round((state.lastCapturTeam === perspectiveTeam ? 1 : -1) * tableValue * urgency); +} + +function scoreObjectiveTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0) return 0; + + const nextTeamSign = teamOf(state.currentPlayer) === perspectiveTeam ? 1 : -1; + const tableSum = sumCardValues(state.table); + const exposedDenari = state.table.filter(card => card.suit === 'denara').length; + const exposedSevens = state.table.filter(card => card.value === 7).length; + const exposedSettebello = state.table.some(card => card.suit === 'denara' && card.value === 7); + const shortTable = state.table.length <= 2 || tableSum <= 12; + let pressure = exposedDenari * 34 + exposedSevens * 42; + + if (exposedSettebello) pressure += 120; + if (shortTable) pressure += 36; + + return Math.round(nextTeamSign * pressure * (shortTable ? 1.2 : 0.8)); +} + +function scoreTableControlReserve(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length < 4) return 0; + + const tableSum = sumCardValues(state.table); + if (tableSum < 20) return 0; + + const nextTeam = teamOf(state.currentPlayer); + const exposedDenari = state.table.filter(card => card.suit === 'denara').length; + const exposedSevens = state.table.filter(card => card.value === 7).length; + let reserve = state.table.length * 22 + tableSum * 3; + + reserve -= exposedDenari * 10; + reserve -= exposedSevens * 12; + if (state.table.length >= 5) reserve += 24; + if (tableSum >= 24) reserve += 28; + + return Math.round((nextTeam === perspectiveTeam ? -0.35 : 0.55) * reserve); +} + +function scoreKnownImmediateCapturePressure(state: GameState, playerIdx: PlayerIndex): number { + if (state.table.length === 0 || state.players[playerIdx].hand.length === 0) return 0; + + let bestScore = 0; + for (const move of getLegalMoves(state, playerIdx)) { + if (move.capture.length === 0) continue; + + const captured = [move.card, ...move.capture]; + let moveScore = captured.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + if (move.capture.length === state.table.length) { + const isTerminalClear = state.players.every((player, index) => ( + index === playerIdx ? player.hand.length === 1 : player.hand.length === 0 + )); + moveScore += isTerminalClear ? 90 : 240; + } + + bestScore = Math.max(bestScore, moveScore); + } + + return bestScore; +} + +function getUpcomingTableExposureActors(state: GameState): Array<{ playerIdx: PlayerIndex; weight: number }> { + const actors: Array<{ playerIdx: PlayerIndex; weight: number }> = []; + let playerIdx = state.currentPlayer; + + for (const weight of UPCOMING_TABLE_EXPOSURE_WEIGHTS) { + actors.push({ playerIdx, weight }); + playerIdx = nextPlayer(playerIdx); + } + + return actors; +} + +function scoreCaptureOpportunity( + capturedCards: Card[], + playedValue: number, + tableSize: number, +): number { + let score = capturedCards.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + score += 18 + (PRIMIERA_VALUES[playedValue] ?? 0) * 2; + if (playedValue === 7) score += 48; + if (capturedCards.some(card => card.suit === 'denara')) score += 24; + if (capturedCards.some(card => card.value === 7)) score += 32; + if (capturedCards.some(card => card.suit === 'denara' && card.value === 7)) score += 160; + + if (capturedCards.length === tableSize) { + score += tableSize <= 3 ? 220 : 280; + } + + return score; +} + +function scoreProbableImmediateCapturePressure( + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + if (state.table.length === 0) return 0; + + const handSize = state.players[playerIdx].hand.length; + if (handSize <= 0) return 0; + + const observerHand = state.players[rootPlayer].hand; + let bestScore = 0; + + for (let value = 1; value <= 10; value++) { + const representativeCard = REPRESENTATIVE_CARD_BY_VALUE.get(value); + if (!representativeCard) continue; + + const captures = findCaptures(representativeCard, state.table); + if (captures.length === 0) continue; + + const probability = handLikelyHasValue( + value, + handSize, + state, + rootPlayer, + tracker, + observerHand, + state.table, + ); + if (probability <= 0) continue; + + let bestCaptureScore = 0; + for (const capture of captures) { + bestCaptureScore = Math.max( + bestCaptureScore, + scoreCaptureOpportunity(capture, value, state.table.length), + ); + } + + bestScore = Math.max(bestScore, Math.round(probability * bestCaptureScore)); + } + + return bestScore; +} + +function scoreKnownTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0) return 0; + + let score = 0; + for (const actor of getUpcomingTableExposureActors(state)) { + const capturePressure = scoreKnownImmediateCapturePressure(state, actor.playerIdx); + if (capturePressure === 0) continue; + + score += Math.round( + capturePressure + * actor.weight + * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), + ); + } + + return score; +} + +function scoreProbableTableExposure( + state: GameState, + perspectiveTeam: 0 | 1, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + if (state.table.length === 0) return 0; + + let score = 0; + for (const actor of getUpcomingTableExposureActors(state)) { + const actorScore = scoreProbableImmediateCapturePressure(state, actor.playerIdx, rootPlayer, tracker); + if (actorScore === 0) continue; + + score += Math.round( + actorScore + * actor.weight + * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), + ); + } + + return score; +} + +function scoreRootOpeningAnchorState( + state: GameState, + perspectiveTeam: 0 | 1, + rootPlayer: PlayerIndex, +): number { + if ( + teamOf(rootPlayer) !== perspectiveTeam + || state.table.length !== 1 + || teamOf(state.currentPlayer) === perspectiveTeam + ) { + return 0; + } + + const exposedCard = state.table[0]; + const rootHand = state.players[rootPlayer].hand; + const sameValueAnchors = countValueInHand(rootHand, exposedCard.value); + let score = 0; + + if (sameValueAnchors > 0) { + score += exposedCard.value >= 8 ? 240 : 88; + if (exposedCard.suit !== 'denara') score += 36; + } + + if (sameValueAnchors === 0 && exposedCard.value <= 3) score -= 220; + if (exposedCard.suit === 'denara') score -= 120; + if (exposedCard.value === 7) score -= 140; + + return score; +} + +function evaluateTeamPosition( + state: GameState, + perspectiveTeam: 0 | 1, + _phase: number, + tracker: CardTracker | undefined, + rootPlayer: PlayerIndex, + allowHiddenHands: boolean, +): number { + const opponentTeam = perspectiveTeam === 0 ? 1 : 0; + const mine = buildTeamEvaluationSnapshot(state, perspectiveTeam); + const opp = buildTeamEvaluationSnapshot(state, opponentTeam); + const phase = gamePhase(state); + const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260; + const matchPointCardsPressure = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 3.2 : 1; + + let score = 0; + + score += (mine.totalPoints - opp.totalPoints) * Math.round(matchWeight + phase * 40); + if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260; + if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260; + + score += Math.round(scoreCardsMajorityPosition(mine.cards, opp.cards, phase) * matchPointCardsPressure); + score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220); + + if (mine.settebello) score += 420; + if (opp.settebello) score -= 420; + + score += (mine.scope - opp.scope) * 390; + + score += (mine.primiera - opp.primiera) * Math.round(4.5 + phase * 3); + score += (mine.primieraSuits - opp.primieraSuits) * 124; + if (mine.primieraSuits === 4 && opp.primieraSuits < 4) score += 180; + if (opp.primieraSuits === 4 && mine.primieraSuits < 4) score -= 180; + score += (mine.sevenSuits - opp.sevenSuits) * 92; + score += (mine.sevens - opp.sevens) * 68; + score += (mine.sixes - opp.sixes) * 16; + score += (mine.aces - opp.aces) * 12; + + score += scorePendingTableOwnership(state, perspectiveTeam); + score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer); + score += scoreObjectiveTableExposure(state, perspectiveTeam); + score += scoreTableControlReserve(state, perspectiveTeam); + if ((mine.totalPoints >= 9 || opp.totalPoints >= 9) && state.table.length <= 2) { + score += Math.max(0, mine.cards - opp.cards) * 32; + score -= Math.max(0, opp.cards - mine.cards) * 32; + } + if (allowHiddenHands) { + score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam); + } + score += allowHiddenHands + ? scoreKnownTableExposure(state, perspectiveTeam) + : scoreProbableTableExposure(state, perspectiveTeam, rootPlayer, tracker); + + return score; +} + +/** Fast evaluation: avoids flatMap/filter at every leaf node */ +function evaluateFast( + state: GameState, + myTeam: 0 | 1, + _phase: number, + tracker: CardTracker | undefined, + rootPlayer: PlayerIndex, +): number { + return evaluateTeamPosition(state, myTeam, 0, tracker, rootPlayer, true); +} + +/** + * PIMC evaluation: full-information evaluation for use in determinized PIMC search. + * Enables scoreCurrentPlayerVisibleTempo and scoreKnownTableExposure which require + * all hands to be visible — satisfied in a determinized PIMC state. + * Exported so the new PIMC engine can use the same evaluation quality. + */ +export function evaluateTeamPositionPIMC(state: GameState, perspectiveTeam: 0 | 1, rootPlayer?: PlayerIndex): number { + return evaluateTeamPosition(state, perspectiveTeam, 0, undefined, rootPlayer ?? state.currentPlayer, true); +} + +/** + * Full-quality move score for use in PIMC move ordering (root and interior nodes). + * Equivalent to the legacy's internal quickEval: position eval + scoreMoveObjectiveBias. + * Use this for 1-ply ordering in determinized states where all hands are visible. + * + * @param playerIdx The player whose turn it is at this node. + * @param rootPlayer The player at the root of the search (perspective stays fixed). + */ +export function quickEvalRootMovePIMC( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + tracker: import('./card-tracker').CardTracker | undefined, + rootPlayer?: PlayerIndex, +): number { + return quickEval(move, state, playerIdx, rootPlayer ?? playerIdx, tracker, true); +} + +/** + * Generate hand samples 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 using rank-residue analysis. + * + * @param count Number of samples to generate (legacy target). + */ +export function generateSamplesForPIMC( + state: GameState, + playerIdx: PlayerIndex, + tracker: import('./card-tracker').CardTracker | undefined, + count: number, + rng: RandomSource, + timingSource?: AITimingSource, +): GameState[] { + const timing = createSearchTimingContext(timingSource); + return generateSamples(state, playerIdx, tracker, count, rng, timing); +} diff --git a/src/game/ai-pimc.ts b/src/game/ai-pimc.ts new file mode 100644 index 0000000..0b0b33b --- /dev/null +++ b/src/game/ai-pimc.ts @@ -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 = {}, + 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(); + const winCounts = new Map(); + const trialCounts = new Map(); + 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(); + + 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(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 1–2 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> = {}; + const oppBestBySuit: Partial> = {}; + const mySevenSuitsSet = new Set(); + const oppSevenSuitsSet = new Set(); + + 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}`; +} diff --git a/src/game/ai-strategy.ts b/src/game/ai-strategy.ts new file mode 100644 index 0000000..bafe0b0 --- /dev/null +++ b/src/game/ai-strategy.ts @@ -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; + overallCloseness: number; +} + +export interface CategoryStates { + denari: CategoryEntry; + carte: CategoryEntry; + primiera: PrimieraCategoryEntry; + scope: 'always_contested'; + settebello: 'always_contested'; +} + +export interface PrimieraRaceState { + teamLeadsBySuit: Record; // 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 = {} as Record; + 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(); + 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 = {} as Record; + 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 }; +} diff --git a/src/game/ai-worker-client.ts b/src/game/ai-worker-client.ts index fb8cc4e..ea896fc 100644 --- a/src/game/ai-worker-client.ts +++ b/src/game/ai-worker-client.ts @@ -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; 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 { 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) { diff --git a/src/game/ai-worker-protocol.ts b/src/game/ai-worker-protocol.ts index e8b1d1d..9269f4c 100644 --- a/src/game/ai-worker-protocol.ts +++ b/src/game/ai-worker-protocol.ts @@ -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 { diff --git a/src/game/ai.ts b/src/game/ai.ts index ab40f8e..c4a9e68 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1,11 +1,22 @@ -import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole } from './types'; -import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole, RandomSource } from './engine'; -import { CardTracker } from './card-tracker'; +// --------------------------------------------------------------------------- +// AI entry point — strategy-driven, three difficulty levels +// --------------------------------------------------------------------------- -export interface AIMove { - card: Card; - capture: Card[]; -} +import { Card, GameState, PlayerIndex, Difficulty, AIMove, PRIMIERA_VALUES } from './types'; +import { findCaptures, teamOf, RandomSource } from './engine'; +import { CardTracker } from './card-tracker'; +import { CardInferenceEngine } from './card-inference'; +import { + getCategoryStates, + getPhase, + solveEndgame, + analyzeTableParity, + rankDumpsBySpariglio, +} from './ai-strategy'; +import { pimcSearch } from './ai-pimc'; +import type { PIMCOptions } from './ai-pimc'; + +export type { AIMove }; export interface AIDecisionProgress { difficulty: Difficulty; @@ -56,24 +67,7 @@ export interface AIChooseMoveOptions { rng?: RandomSource; profileOverride?: AISearchProfileOverride; timingSource?: AITimingSource; -} - -interface DealerRoleContext { - role: DealerRelativeRole; - onDealerSide: boolean; - defendingDealerAdvantage: boolean; - attackingDealerAdvantage: boolean; - aggressionBias: number; - controlBias: number; - pairPreservingBias: number; - pairBreakingBias: number; - tablePressureBias: number; -} - -interface RankResidueSnapshot { - unseenSameRankCounts: number[]; - hasSingletonResidue: boolean[]; - hasPairedResidue: boolean[]; + inference?: CardInferenceEngine; } interface SearchTimingContext { @@ -82,37 +76,6 @@ interface SearchTimingContext { yieldToHost(): Promise; } -const DEALER_ROLE_WEIGHTS: Record> = { - 'first-hand': { - aggressionBias: 1.28, - controlBias: 0.9, - pairPreservingBias: 0.88, - pairBreakingBias: 1.26, - tablePressureBias: 1.3, - }, - 'second-hand': { - aggressionBias: 1, - controlBias: 1.08, - pairPreservingBias: 1.12, - pairBreakingBias: 0.96, - tablePressureBias: 1, - }, - 'third-hand': { - aggressionBias: 1.16, - controlBias: 0.94, - pairPreservingBias: 0.94, - pairBreakingBias: 1.16, - tablePressureBias: 1.12, - }, - dealer: { - aggressionBias: 0.84, - controlBias: 1.32, - pairPreservingBias: 1.34, - pairBreakingBias: 0.82, - tablePressureBias: 0.78, - }, -}; - const SEARCH_PROFILES: Record = { beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 }, @@ -123,20 +86,6 @@ const REAL_TIME_SOURCE: AITimingSource = { now: () => Date.now(), }; -const UPCOMING_TABLE_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const; - -const REPRESENTATIVE_CARD_BY_VALUE = (() => { - const cardsByValue = new Map(); - for (const card of buildDeck()) { - if (!cardsByValue.has(card.value)) { - cardsByValue.set(card.value, card); - } - } - return cardsByValue; -})(); - -const SIMULATED_SEARCH_NODE_COST_MS = 48; -const SIMULATED_ROOT_MOVE_COST_MS = 12; const SIMULATED_YIELD_COST_MS = 1; function createSearchTimingContext(timingSource?: AITimingSource): SearchTimingContext { @@ -161,309 +110,9 @@ function createSearchTimingContext(timingSource?: AITimingSource): SearchTimingC } // --------------------------------------------------------------------------- -// Helpers shared across all difficulty levels +// Search profile helpers (preserved) // --------------------------------------------------------------------------- -function nextPlayer(p: PlayerIndex): PlayerIndex { - return ((p + 1) % 4) as PlayerIndex; -} -function partnerOf(p: PlayerIndex): PlayerIndex { - return ((p + 2) % 4) as PlayerIndex; -} -function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { - return teamOf(me) !== teamOf(other); -} -function primieraVal(card: Card): number { - return PRIMIERA_VALUES[card.value] ?? 0; -} -function gamePhase(state: GameState): number { - const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); - return 1 - totalCards / 40; -} - -function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] { - return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile]; -} - -/** Is this the very last play of the round? (all hands have 0 or 1 cards, and it's this player's turn) */ -function isLastPlay(state: GameState, playerIdx: PlayerIndex): boolean { - for (let i = 0; i < 4; i++) { - if (i === playerIdx) { - if (state.players[i].hand.length !== 1) return false; - } else { - if (state.players[i].hand.length !== 0) return false; - } - } - return true; -} - -/** Count how many cards in hand match a given value (anchor candidates) */ -function countValueInHand(hand: Card[], value: number): number { - let n = 0; - for (const c of hand) if (c.value === value) n++; - return n; -} - -function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerRoleContext { - const role = getDealerRelativeRole(state.dealer, playerIdx); - const onDealerSide = role === 'dealer' || role === 'second-hand'; - return { - role, - onDealerSide, - defendingDealerAdvantage: onDealerSide, - attackingDealerAdvantage: !onDealerSide, - ...DEALER_ROLE_WEIGHTS[role], - }; -} - -function getRankResidueSnapshot( - tracker: CardTracker | undefined, - myHand: Card[], - table: Card[], -): RankResidueSnapshot | null { - if (!tracker) return null; - - const unseenSameRankCounts = Array.from({ length: 11 }, () => 0); - const hasSingletonResidue = Array.from({ length: 11 }, () => false); - const hasPairedResidue = Array.from({ length: 11 }, () => false); - const summary = tracker.getValueRankResidueSummary(myHand, table); - - for (const residue of summary) { - unseenSameRankCounts[residue.value] = residue.unseenCount; - hasSingletonResidue[residue.value] = residue.hasSingletonUnseenRankResidue; - hasPairedResidue[residue.value] = residue.hasPairedUnseenRankResidue; - } - - return { unseenSameRankCounts, hasSingletonResidue, hasPairedResidue }; -} - -function countRankResidueValuesOnTable( - afterTable: Card[], - rankResidue: RankResidueSnapshot | null, -): { singletonValues: number; pairedValues: number } { - if (!rankResidue || afterTable.length === 0) { - return { singletonValues: 0, pairedValues: 0 }; - } - - let singletonValues = 0; - let pairedValues = 0; - const seenValues = new Set(); - - for (const card of afterTable) { - if (seenValues.has(card.value)) continue; - seenValues.add(card.value); - if (rankResidue.hasSingletonResidue[card.value]) singletonValues++; - else if (rankResidue.hasPairedResidue[card.value]) pairedValues++; - } - - return { singletonValues, pairedValues }; -} - -function getExposedTableCardWeight( - card: Card, - race: RaceState, - tableSize: number, -): number { - let weight = 52 + primieraVal(card) * 2.5; - - if (card.suit === 'denara') { - weight += race.behindInDenari ? 150 : 95; - } - - if (card.value === 7) { - weight += race.need7s ? 190 : 120; - } - - if (card.suit === 'denara' && card.value === 7) { - weight += 220; - } - - if (tableSize === 1) weight += 150; - else if (tableSize === 2) weight += 70; - - return weight; -} - -function scoreExposedTableCards( - afterTable: Card[], - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - myHand: Card[], - race: RaceState, -): number { - if (afterTable.length === 0) return 0; - - const next = nextPlayer(playerIdx); - const partner = partnerOf(playerIdx); - const nextHandSize = state.players[next].hand.length; - const partnerHandSize = state.players[partner].hand.length; - const nextIsOpp = isOpponent(playerIdx, next); - const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); - const tableHasDenari = afterTable.some(card => card.suit === 'denara'); - const tableHasSeven = afterTable.some(card => card.value === 7); - let score = 0; - - for (const tableCard of afterTable) { - const weight = getExposedTableCardWeight(tableCard, race, afterTable.length); - - if (nextIsOpp && nextHandSize > 0) { - const nextProb = handLikelyHasValue( - tableCard.value, - nextHandSize, - state, - playerIdx, - tracker, - myHand, - afterTable, - ); - score -= Math.round(nextProb * weight); - } - - if (!nextIsOpp && partnerHandSize > 0) { - const partnerProb = handLikelyHasValue( - tableCard.value, - partnerHandSize, - state, - playerIdx, - tracker, - myHand, - afterTable, - ); - score += Math.round(partnerProb * weight * 0.55); - } - } - - if (nextIsOpp && afterTable.length === 1) { - score -= 380; - } else if (nextIsOpp && afterTable.length === 2) { - score -= tableSum <= 10 ? 180 : 110; - if (tableHasDenari) score -= race.behindInDenari ? 130 : 60; - if (tableHasSeven) score -= race.need7s ? 170 : 80; - } else if (nextIsOpp && afterTable.length >= 5 && tableSum >= 24) { - if (tableHasDenari) score += 70; - if (tableHasSeven) score += 55; - } - - return score; -} - -function scoreRoleTablePlan( - afterTable: Card[], - roleContext: DealerRoleContext, - nextIsOpp: boolean, -): number { - if (afterTable.length === 0) return 0; - - const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); - let score = 0; - - if (roleContext.role === 'first-hand') { - if (afterTable.length >= 2) score += 22 * roleContext.tablePressureBias; - if (tableSum >= 8 && tableSum <= 15) score += 18 * roleContext.aggressionBias; - } - - if (roleContext.role === 'third-hand') { - if (afterTable.length >= 2) score += 14 * roleContext.tablePressureBias; - if (tableSum >= 10) score += 10 * roleContext.aggressionBias; - } - - if (roleContext.role === 'second-hand') { - if (nextIsOpp && tableSum >= 11) score += 16 * roleContext.controlBias; - if (!nextIsOpp && tableSum <= 10) score += 10 * roleContext.tablePressureBias; - } - - if (roleContext.role === 'dealer') { - if (tableSum >= 11) score += 28 * roleContext.controlBias; - if (tableSum <= 10 && nextIsOpp) score -= 24 * roleContext.controlBias; - if (afterTable.length === 1 && nextIsOpp) score -= 16 * roleContext.controlBias; - } - - return Math.round(score); -} - -function scoreRankResidueTableState( - afterTable: Card[], - rankResidue: RankResidueSnapshot | null, - roleContext: DealerRoleContext, - nextIsOpp: boolean, -): number { - const { singletonValues, pairedValues } = countRankResidueValuesOnTable(afterTable, rankResidue); - if (singletonValues === 0 && pairedValues === 0) return 0; - - let score = 0; - if (roleContext.defendingDealerAdvantage) { - score += pairedValues * 18 * roleContext.controlBias; - score -= singletonValues * 22 * roleContext.controlBias; - if (nextIsOpp) score += pairedValues * 8 - singletonValues * 10; - } else { - score += singletonValues * 20 * roleContext.tablePressureBias; - score -= pairedValues * 10; - if (nextIsOpp) score += singletonValues * 12; - } - - return Math.round(score); -} - -function scoreCaptureRankResiduePlan( - played: Card, - captured: Card[], - afterTable: Card[], - rankResidue: RankResidueSnapshot | null, - roleContext: DealerRoleContext, - nextIsOpp: boolean, -): number { - if (!rankResidue || captured.length === 0) return 0; - - let score = 0; - const directCapture = captured.length === 1 && captured[0].value === played.value; - - if (directCapture) { - const unseenCount = rankResidue.unseenSameRankCounts[played.value] ?? 0; - const base = rankResidue.hasPairedResidue[played.value] ? 58 : 30; - score += base * roleContext.pairPreservingBias; - if (roleContext.defendingDealerAdvantage && unseenCount > 0) score += 18 * roleContext.controlBias; - } else { - let pairBreaks = 0; - let singletonTargets = 0; - const seenValues = new Set(); - - for (const card of captured) { - if (seenValues.has(card.value)) continue; - seenValues.add(card.value); - if ((rankResidue.unseenSameRankCounts[card.value] ?? 0) > 0) pairBreaks++; - if (rankResidue.hasSingletonResidue[card.value]) singletonTargets++; - } - - const disruption = pairBreaks * 20 + singletonTargets * 18 + Math.max(0, captured.length - 1) * 12; - score += disruption * roleContext.pairBreakingBias; - if (roleContext.defendingDealerAdvantage) score -= 18 * roleContext.controlBias; - } - - score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); - return Math.round(score); -} - -function scoreDumpRankResiduePlan( - card: Card, - afterTable: Card[], - rankResidue: RankResidueSnapshot | null, - roleContext: DealerRoleContext, - nextIsOpp: boolean, -): number { - if (!rankResidue) return 0; - - let score = scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); - if (rankResidue.hasSingletonResidue[card.value]) { - score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias; - } - if (rankResidue.hasPairedResidue[card.value]) { - score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6; - } - - return Math.round(score); -} - function applySearchProfileOverride( profile: SearchProfile, profileOverride?: AISearchProfileOverride, @@ -543,2622 +192,16 @@ function reportDecisionProgress( }); } -function handLikelyHasValue( - value: number, - handSize: number, - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - myHand: Card[], - table: Card[], -): number { - if (handSize <= 0) return 0; - - if (tracker) { - return tracker.probabilityHandHasValue(value, handSize, myHand, table); - } - - const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker); - let unseenWithValue = 0; - for (const card of unseen) { - if (card.value === value) unseenWithValue++; - } - - if (unseenWithValue === 0 || unseen.length === 0) return 0; - const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize); - return 1 - probNone; -} - -/** Check if partner likely holds a card of given value (via tracker inference) */ -function partnerLikelyHolds( - value: number, playerIdx: PlayerIndex, state: GameState, - tracker: CardTracker | undefined, myHand: Card[], table: Card[], -): number { - const partner = partnerOf(playerIdx); - return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table); -} - -/** Race state: who's winning each scoring category */ -interface RaceState { - myCards: number; oppCards: number; - myDenari: number; oppDenari: number; - mySettebello: boolean; oppSettebello: boolean; - my7s: number; opp7s: number; - myScope: number; oppScope: number; - behindInCards: boolean; - behindInDenari: boolean; - denariRaceLive: boolean; - needSettebello: boolean; - need7s: boolean; - sevenRaceLive: boolean; - aheadOverall: boolean; -} - -function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { - const myTeam = teamOf(playerIdx); - const mine = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; - const opps = myTeam === 0 ? [state.players[1], state.players[3]] : [state.players[0], state.players[2]]; - const myPile = mine.flatMap(p => p.pile); - const oppPile = opps.flatMap(p => p.pile); - const myCards = myPile.length, oppCards = oppPile.length; - const myDenari = myPile.filter(c => c.suit === 'denara').length; - const oppDenari = oppPile.filter(c => c.suit === 'denara').length; - const mySettebello = myPile.some(c => c.suit === 'denara' && c.value === 7); - const oppSettebello = oppPile.some(c => c.suit === 'denara' && c.value === 7); - const my7s = myPile.filter(c => c.value === 7).length; - const opp7s = oppPile.filter(c => c.value === 7).length; - const myScope = mine.reduce((s, p) => s + p.scope, 0); - const oppScope = opps.reduce((s, p) => s + p.scope, 0); - const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); - - // Simple overall advantage estimate - let myAdv = 0; - if (myCards > oppCards) myAdv++; else if (oppCards > myCards) myAdv--; - if (myDenari > oppDenari) myAdv++; else if (oppDenari > myDenari) myAdv--; - if (mySettebello) myAdv++; else if (oppSettebello) myAdv--; - myAdv += myScope - oppScope; - - return { - myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello, - my7s, opp7s, myScope, oppScope, - behindInCards: myCards < oppCards, - behindInDenari: myDenari < oppDenari, - denariRaceLive: cardsRemaining > 0 && myDenari < 6 && oppDenari < 6 && Math.abs(myDenari - oppDenari) <= 1, - needSettebello: !mySettebello && !oppSettebello, - need7s: my7s <= opp7s, - sevenRaceLive: cardsRemaining > 0 && my7s < 3 && opp7s < 3 && Math.abs(my7s - opp7s) <= 1, - aheadOverall: myAdv > 0, - }; -} - -/** - * Count scopa threats: how many unseen cards can clear a given table. - * Uses probabilistic assessment per-player based on hand sizes. - */ -function countScopaThreats( - afterTable: Card[], - myHand: Card[], - tracker: CardTracker | undefined, - state: GameState, - playerIdx: PlayerIndex, -): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean; partnerCanScopa: boolean } { - if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false, partnerCanScopa: false }; - - const unseen = tracker - ? tracker.getUnseenCards(myHand, afterTable) - : getUnseenWithoutTracker(state, playerIdx); - - // Count every unseen card that has at least one capture clearing the full table - let totalThreats = 0; - const threatCardIds = new Set(); - for (const uc of unseen) { - const caps = findCaptures(uc, afterTable); - for (const cap of caps) { - if (cap.length === afterTable.length) { - totalThreats++; - threatCardIds.add(uc.id); - break; - } - } - } - - // Probabilistic check for each player - const next = nextPlayer(playerIdx); - const second = nextPlayer(next); - const third = nextPlayer(second); // = partner - const unseenCount = unseen.length; - - let nextOppCanScopa = false; - let secondOppCanScopa = false; - let partnerCanScopa = false; - - if (totalThreats > 0 && unseenCount > 0) { - for (const other of [next, second, third]) { - const hs = state.players[other].hand.length; - if (hs === 0) continue; - const probNone = hypergeometricNone(unseenCount, totalThreats, hs); - const prob = 1 - probNone; - if (isOpponent(playerIdx, other)) { - if (other === next) nextOppCanScopa = prob > 0.20; - else secondOppCanScopa = prob > 0.20; - } else if (other !== playerIdx) { - partnerCanScopa = prob > 0.30; - } - } - } - - return { totalThreats, nextOppCanScopa, secondOppCanScopa, partnerCanScopa }; -} - -interface ScopaThreatSummary { - totalThreats: number; - nextOppCanScopa: boolean; - secondOppCanScopa: boolean; - partnerCanScopa: boolean; -} - -interface TacticalPriorityLadder { - scopa: number; - settebello: number; - antiScopa: number; - partnerSetup: number; - sevenDenial: number; - denariDenial: number; - material: number; -} - -const TACTICAL_PRIORITY_WEIGHTS = { - scopa: 120000000, - settebello: 20000000, - antiScopa: 5000000, - partnerSetup: 45000, - sevenDenial: 2101, - denariDenial: 101, -} as const; - -function clampPriorityBand(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, Math.round(value))); -} - -function sumCardValues(cards: Card[]): number { - return cards.reduce((sum, card) => sum + card.value, 0); -} - -function getPriorityThreatSummary( - afterTable: Card[], - myHand: Card[], - tracker: CardTracker | undefined, - state: GameState, - playerIdx: PlayerIndex, -): ScopaThreatSummary | null { - if (afterTable.length === 0 || sumCardValues(afterTable) > 10) { - return null; - } - - return countScopaThreats(afterTable, myHand, tracker, state, playerIdx); -} - -function isImmediateTacticalConcession( - afterTable: Card[], - nextIsOpp: boolean, - threats: ScopaThreatSummary | null, -): boolean { - if (!nextIsOpp || afterTable.length === 0) return false; - - const tableSum = sumCardValues(afterTable); - if (afterTable.length === 1 && tableSum <= 10) return true; - if (afterTable.length === 2 && tableSum <= 10) return true; - - return Boolean(threats?.nextOppCanScopa); -} - -function evaluateSafeScopaPriority( - clearsTable: boolean, - afterTable: Card[], - lastPlay: boolean, - nextIsOpp: boolean, - threats: ScopaThreatSummary | null, -): number { - if (!clearsTable || lastPlay) return 0; - return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2; -} - -function evaluateFirstHandOpeningReleasePriority( - card: Card, - myHand: Card[], - projectedHand: Card[], - afterTable: Card[], - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - nextIsOpp: boolean, - roleContext: DealerRoleContext, -): number { - if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) { - return 0; - } - - const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length; - if (nextHandSize <= 0) return 0; - - const sameValueCount = countValueInHand(myHand, card.value); - const immediateScopaRisk = handLikelyHasValue( - card.value, - nextHandSize, - state, - playerIdx, - tracker, - projectedHand, - afterTable, - ); - - let score = 0; - score += Math.max(0, sameValueCount - 1) * 2; - if (sameValueCount >= 3) score += 2; - score += Math.round((0.32 - immediateScopaRisk) * 12); - - if (sameValueCount >= 2 && card.value >= 8) score += 2; - if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 3; - if (sameValueCount >= 2 && card.suit === 'denara') score -= 2; - if (card.suit === 'denara') score -= 1; - if (card.value === 7) score -= 1; - if (sameValueCount === 1 && card.value <= 3) score -= 2; - - return clampPriorityBand(score, -8, 8); -} - -function evaluateAntiScopaPriority( - afterTable: Card[], - nextIsOpp: boolean, - threats: ScopaThreatSummary | null, -): number { - if (afterTable.length === 0) return 8; - - const tableSum = sumCardValues(afterTable); - const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; - const exposedSevens = afterTable.filter(card => card.value === 7).length; - let score = tableSum >= 14 ? 7 : tableSum >= 11 ? 6 : 0; - - if (nextIsOpp) { - if (tableSum <= 12) score -= 3; - if (tableSum <= 10) score -= 6; - if (tableSum <= 6) score -= 4; - if (afterTable.length === 1) score -= 9; - else if (afterTable.length === 2 && tableSum <= 12) score -= 6; - else if (afterTable.length === 3 && tableSum <= 12) score -= 3; - if (afterTable.length === 3 && tableSum <= 18) score -= 2; - - score -= exposedDenari * 2; - score -= exposedSevens * 3; - if (afterTable.length <= 2 && (exposedDenari > 0 || exposedSevens > 0)) { - score -= 4 + exposedDenari + exposedSevens; - } - if (afterTable.length === 3 && tableSum <= 18 && (exposedDenari > 0 || exposedSevens > 0)) { - score -= 4 + exposedDenari * 2 + exposedSevens * 2; - } - if (afterTable.length >= 4 && tableSum >= 20) score += 4; - if (afterTable.length >= 5 && tableSum >= 24) score += 2; - } - - if (threats) { - if (threats.nextOppCanScopa) score -= 10; - if (threats.secondOppCanScopa) score -= 5; - score -= Math.min(8, threats.totalThreats); - if (threats.partnerCanScopa) { - score += nextIsOpp && !threats.nextOppCanScopa ? 4 : 2; - } - } - - if (!nextIsOpp && tableSum >= 11) score += 2; - if (!nextIsOpp && afterTable.length >= 4 && tableSum >= 15) score += 2; - - return clampPriorityBand(score, -20, 20); -} - -function evaluatePartnerSetupPriority( - afterTable: Card[], - nextIsOpp: boolean, - partnerHandSize: number, - threats: ScopaThreatSummary | null, -): number { - if (afterTable.length === 0 || partnerHandSize === 0) return 0; - - const tableSum = sumCardValues(afterTable); - const denariOnTable = afterTable.filter(card => card.suit === 'denara').length; - const sevensOnTable = afterTable.filter(card => card.value === 7).length; - let score = 0; - - if (!nextIsOpp) { - score += 4; - if (tableSum >= 1 && tableSum <= 10) score += threats?.partnerCanScopa ? 10 : 5; - if (afterTable.length >= 2) score += 2; - if (denariOnTable > 0) score += Math.min(3, denariOnTable); - if (sevensOnTable > 0) score += 2; - } else { - if (tableSum >= 11) { - score += 2; - if (afterTable.length >= 4) score += 1; - if (denariOnTable > 0) score += 2; - if (sevensOnTable > 0) score += 1; - } - - if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { - score += tableSum <= 12 ? 7 : 4; - if (afterTable.length >= 4) score += 3; - if (denariOnTable === 0) score += 1; - if (sevensOnTable === 0) score += 1; - } - - if (threats?.secondOppCanScopa) score -= 2; - } - - return clampPriorityBand(score, -20, 20); -} - -function evaluateSevenDenialPriority( - afterTable: Card[], - capturedCards: Card[], - releasedCard: Card | null, - nextIsOpp: boolean, - need7s: boolean, -): number { - let score = 0; - const capturedSevens = capturedCards.filter(card => card.value === 7).length; - const exposedSevens = afterTable.filter(card => card.value === 7).length; - const strippedAllSevens = capturedSevens > 0 && exposedSevens === 0; - - score += capturedSevens * (need7s ? 8 : 5); - if (capturedSevens > 0) score += need7s ? 4 : 2; - if (strippedAllSevens) score += need7s ? 5 : 3; - if (nextIsOpp) { - score -= exposedSevens * (need7s ? 10 : 6); - if (exposedSevens > 0 && afterTable.length <= 2) { - score -= need7s ? 6 : 4; - } - } else { - score += exposedSevens; - } - - if (releasedCard?.value === 7) { - score -= need7s ? 12 : 7; - if (nextIsOpp && afterTable.length <= 2) score -= need7s ? 6 : 4; - } - - return clampPriorityBand(score, -20, 20); -} - -function evaluateDenariDenialPriority( - afterTable: Card[], - capturedCards: Card[], - releasedCard: Card | null, - nextIsOpp: boolean, - behindInDenari: boolean, -): number { - let score = 0; - const capturedDenari = capturedCards.filter(card => card.suit === 'denara').length; - const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; - const strippedAllDenari = capturedDenari > 0 && exposedDenari === 0; - - score += capturedDenari * (behindInDenari ? 7 : 4); - if (capturedDenari > 0) score += behindInDenari ? 4 : 2; - if (strippedAllDenari) score += behindInDenari ? 5 : 3; - if (nextIsOpp) { - score -= exposedDenari * (behindInDenari ? 10 : 6); - if (exposedDenari > 0 && afterTable.length <= 2) { - score -= behindInDenari ? 6 : 4; - } - } else { - score += Math.min(2, exposedDenari); - } - - if (releasedCard?.suit === 'denara') { - score -= behindInDenari ? 11 : 6; - if (nextIsOpp && afterTable.length <= 2) score -= behindInDenari ? 6 : 4; - } - - return clampPriorityBand(score, -20, 20); -} - -function scoreTacticalPriorityLadder(priorities: TacticalPriorityLadder): number { - const scopa = clampPriorityBand(priorities.scopa, -2, 2); - const settebello = clampPriorityBand(priorities.settebello, -4, 4); - const antiScopa = clampPriorityBand(priorities.antiScopa, -20, 20); - const partnerSetup = clampPriorityBand(priorities.partnerSetup, -20, 20); - const sevenDenial = clampPriorityBand(priorities.sevenDenial, -20, 20); - const denariDenial = clampPriorityBand(priorities.denariDenial, -20, 20); - const material = clampPriorityBand(priorities.material, -200, 200); - - return ( - scopa * TACTICAL_PRIORITY_WEIGHTS.scopa - + settebello * TACTICAL_PRIORITY_WEIGHTS.settebello - + antiScopa * TACTICAL_PRIORITY_WEIGHTS.antiScopa - + partnerSetup * TACTICAL_PRIORITY_WEIGHTS.partnerSetup - + sevenDenial * TACTICAL_PRIORITY_WEIGHTS.sevenDenial - + denariDenial * TACTICAL_PRIORITY_WEIGHTS.denariDenial - + material - ); -} - -/** P(0 threat cards drawn) using hypergeometric approx */ -function hypergeometricNone(total: number, threats: number, drawn: number): number { - if (drawn >= total) return threats > 0 ? 0 : 1; - let p = 1; - for (let i = 0; i < drawn; i++) { - p *= Math.max(0, (total - threats - i)) / (total - i); - } - return p; -} - // --------------------------------------------------------------------------- -// Main entry point +// Core move helpers // --------------------------------------------------------------------------- -export async function chooseMove( - state: GameState, - playerIdx: PlayerIndex, - difficulty: Difficulty = 'advanced', - tracker?: CardTracker, - onProgress?: (progress: AIDecisionProgress) => void, - options?: AIChooseMoveOptions, -): Promise { - const timing = createSearchTimingContext(options?.timingSource); - const startedAt = timing.now(); - const profile = getSearchProfile(state, difficulty, options?.profileOverride); - reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); - - switch (difficulty) { - case 'beginner': { - const move = beginnerMove(state, playerIdx, tracker); - reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); - return move; - } - case 'advanced': { - const move = advancedMove(state, playerIdx, tracker); - reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); - return move; - } - case 'master': - return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, options?.rng ?? Math.random); - } +function nextPlayer(p: PlayerIndex): PlayerIndex { + return ((p + 1) % 4) as PlayerIndex; } -// =========================================================================== -// BEGINNER — beatable but not stupid, basic strategy awareness -// =========================================================================== - -function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { - const player = state.players[playerIdx]; - const table = state.table; - const phase = gamePhase(state); - const next = nextPlayer(playerIdx); - const nextIsOpp = isOpponent(playerIdx, next); - const lastPlay = isLastPlay(state, playerIdx); - - // 5% pure random (reduced from 8%) - if (Math.random() < 0.05) { - return randomMove(state, playerIdx); - } - - let bestMove: AIMove | null = null; - let bestScore = -Infinity; - - for (const card of player.hand) { - const captures = findCaptures(card, table); - if (captures.length > 0) { - for (const captureSet of captures) { - const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp, lastPlay); - const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.2); - if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } - } - } else { - const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp, player.hand); - const score = base + (Math.random() - 0.5) * Math.max(50, Math.abs(base) * 0.2); - if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } - } - } - - return bestMove!; -} - -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: [] }; -} - -function scoreCaptureBeginner( - played: Card, captured: Card[], table: Card[], - state: GameState, playerIdx: PlayerIndex, phase: number, - nextIsOpp: boolean, lastPlay: boolean, -): number { - const allCaptured = [played, ...captured]; - const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); - const isScopa = afterTable.length === 0; - const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); - const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - const threats = getPriorityThreatSummary(afterTable, state.players[playerIdx].hand, undefined, state, playerIdx); - const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; - let material = 20 + captured.length * 14 + phase * captured.length * 4; - - material += allCaptured.filter(c => c.suit === 'denara').length * 8; - material += allCaptured.filter(c => c.value === 7).length * 6; - for (const card of allCaptured) material += primieraVal(card) * 1.2; - - if (!isScopa) { - for (const tableCard of afterTable) { - const dupes = countValueInHand(state.players[playerIdx].hand, tableCard.value); - if (dupes >= 1) material += 6; - if (dupes >= 2) material += 4; - } - } - - return scoreTacticalPriorityLadder({ - scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0, - settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, - antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), - partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), - sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), - denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), - material, - }) + (isScopa && lastPlay ? 30 : 0); -} - -function scoreDumpBeginner( - card: Card, table: Card[], state: GameState, - playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, - hand: Card[], -): number { - const afterTable = [...table, card]; - - // NEVER dump settebello - if (card.suit === 'denara' && card.value === 7) return -5000; - const threats = getPriorityThreatSummary(afterTable, hand, undefined, state, playerIdx); - const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; - let material = -12 + phase * 4; - - if (card.suit === 'denara') material -= 20; - if (card.value === 7) material -= 22; - if (card.value === 6) material -= 10; - if (card.value === 1) material -= 8; - if (card.value >= 8) material += 12 + card.value; - - const dupes = countValueInHand(hand, card.value); - if (dupes >= 2) material += 18; - if (dupes >= 3) material += 8; - - return scoreTacticalPriorityLadder({ - scopa: 0, - settebello: 0, - antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), - partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), - sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, false), - denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, false), - material, - }); -} - -// =========================================================================== -// ADVANCED — strong heuristic with card counting, race tracking, cooperation -// anchor strategy, whirlwind detection, team signaling -// =========================================================================== - -function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { - const player = state.players[playerIdx]; - const table = state.table; - const phase = gamePhase(state); - const race = getRaceState(state, playerIdx); - const roleContext = getDealerRoleContext(state, playerIdx); - const rankResidue = getRankResidueSnapshot(tracker, player.hand, table); - const next = nextPlayer(playerIdx); - const nextIsOpp = isOpponent(playerIdx, next); - const partner = partnerOf(playerIdx); - const partnerHandSize = state.players[partner].hand.length; - const lastPlay = isLastPlay(state, playerIdx); - - let bestMove: AIMove | null = null; - let bestScore = -Infinity; - - for (const card of player.hand) { - const captures = findCaptures(card, table); - if (captures.length > 0) { - for (const captureSet of captures) { - const score = scoreCaptureAdv( - card, captureSet, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, - ); - if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } - } - } else { - const score = scoreDumpAdv( - card, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, - ); - if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } - } - } - - return bestMove!; -} - -function scoreCaptureAdv( - played: Card, captured: Card[], table: Card[], state: GameState, - playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, - myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, -): number { - const allCaptured = [played, ...captured]; - const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); - const projectedHand = myHand.filter(card => card.id !== played.id); - const isScopa = afterTable.length === 0; - const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); - const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); - const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats); - const afterTableSum = sumCardValues(afterTable); - const exposedDenariCount = afterTable.filter(card => card.suit === 'denara').length; - const exposedSevenCount = afterTable.filter(card => card.value === 7).length; - const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length; - const capturedSevenCount = allCaptured.filter(card => card.value === 7).length; - const liveDenariPressure = race.behindInDenari || race.denariRaceLive; - const liveSevenPressure = race.need7s || race.sevenRaceLive; - const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); - const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); - const directSevenPrimieraSwing = scoreDirectSevenPrimieraSwing( - played, - captured, - afterTable, - myHand, - table, - liveSevenPressure, - ); - const liveCardsMajorityRace = race.myCards < 21 - && race.oppCards < 21 - && Math.abs(race.myCards - race.oppCards) <= 5; - const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; - const cardsMajorityDelta = scoreCardsMajorityPosition(race.myCards + allCaptured.length, race.oppCards, phase) - - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase); - let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; - - material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? (protectingCardsLead ? 12 : 16) : protectingCardsLead ? 7 : 10); - material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7); - for (const card of allCaptured) material += primieraVal(card) * 2; - material += Math.round((afterPairInventory - beforePairInventory) * 1.8); - material += directSevenPrimieraSwing; - material += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 1.2 : 0.6)); - if (protectingCardsLead && captured.length > 1) material += 96 + captured.length * 24; - - if (capturesSettebello) material += 72; - if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84; - if ( - protectingCardsLead - && !race.behindInDenari - && captured.length === 1 - && captured[0].suit === 'denara' - && !capturesSettebello - ) { - material -= 84; - } - if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14; - if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16; - if ( - nextIsOpp - && !isScopa - && afterTable.length <= 2 - && afterTableSum <= 12 - ) { - material -= 34; - material -= exposedDenariCount * (liveDenariPressure ? 18 : 10); - material -= exposedSevenCount * (liveSevenPressure ? 20 : 12); - } - - const teamPile = getTeamPile(state, playerIdx); - for (const card of allCaptured) { - if (card.value === 7 && !teamPile.some(teamCard => teamCard.suit === card.suit && teamCard.value === 7)) { - material += 10; - } - } - - material += Math.round(scoreCaptureRankResiduePlan(played, captured, afterTable, rankResidue, roleContext, nextIsOpp) / 6); - material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); - - if (!isScopa) { - for (const tableCard of afterTable) { - const dupes = countValueInHand(myHand, tableCard.value); - if (dupes >= 1) material += 7; - if (dupes >= 2) material += 5; - - const partnerProb = partnerLikelyHolds(tableCard.value, playerIdx, state, tracker, myHand, afterTable); - if (partnerProb > 0.4) material += 6; - } - } - - if (nextIsOpp && tracker?.isSettebelloUnseen() && !capturesSettebello && afterTable.some(c => c.suit === 'denara' && c.value === 7)) { - material -= 18; - } - - if (tracker && !isScopa && phase > 0.5 && sumCardValues(afterTable) <= 10) { - const confidence = Math.min(1, tracker.playedCount / 25); - material -= Math.round(confidence * 20); - } - - if (partnerHandSize === 0) material += captured.length * 8; - if (race.aheadOverall && !isScopa && sumCardValues(afterTable) >= 11) material += 10; - if (race.aheadOverall && !isScopa && sumCardValues(afterTable) <= 5 && nextIsOpp) material -= 12; - if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) material += 8; - if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10; - if (countValueInHand(myHand, played.value) >= 2) { - material -= Math.round((played.value >= 8 ? 28 : 14) * roleContext.pairPreservingBias); - if (roleContext.defendingDealerAdvantage && !isScopa) { - material -= Math.round((played.value >= 8 ? 18 : 8) * roleContext.controlBias); - } - } - - return scoreTacticalPriorityLadder({ - scopa: scopaPriority, - settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, - antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), - partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), - sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.need7s), - denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.behindInDenari), - material, - }) + (isScopa && lastPlay ? 40 : 0); -} - -function scoreDumpAdv( - card: Card, table: Card[], state: GameState, - playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, - myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, -): number { - const afterTable = [...table, card]; - const projectedHand = myHand.filter(held => held.id !== card.id); - - // --- HARD RULES --- - if (card.suit === 'denara' && card.value === 7) return -10000; - const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); - const tableSum = sumCardValues(afterTable); - const exposedDenariCount = afterTable.filter(tableCard => tableCard.suit === 'denara').length; - const exposedSevenCount = afterTable.filter(tableCard => tableCard.value === 7).length; - const liveDenariPressure = race.behindInDenari || race.denariRaceLive; - const liveSevenPressure = race.need7s || race.sevenRaceLive; - const complement = 10 - card.value; - const preservesHighComplementWindow = nextIsOpp - && card.value >= 1 - && card.value <= 3 - && afterTable.length >= 4 - && afterTable.some(tableCard => tableCard.value === complement); - const openingReleasePriority = evaluateFirstHandOpeningReleasePriority( - card, - myHand, - projectedHand, - afterTable, - state, - playerIdx, - tracker, - nextIsOpp, - roleContext, - ); - const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); - const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); - const openingDuplicateReleaseBias = scoreOpeningDuplicateReleaseBias( - card, - myHand, - state, - playerIdx, - nextIsOpp, - roleContext, - ); - let material = -20 + phase * 6; - - if (card.suit === 'denara') material -= race.behindInDenari ? 28 : race.denariRaceLive ? 24 : 16; - if (card.value === 7) material -= race.need7s ? 26 : race.sevenRaceLive ? 22 : 14; - if (card.value === 6) material -= 12; - if (card.value === 1) material -= 10; - if (card.value >= 8) material += 14 + card.value * 2; - - const dupes = countValueInHand(myHand, card.value); - if (dupes >= 2) material += 24; - if (dupes >= 3) material += 10; - material += Math.round((afterPairInventory - beforePairInventory) * 1.9); - material += Math.round(openingDuplicateReleaseBias * 0.35); - - const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); - if (partnerProb > 0.4) material += 14; - - material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6); - material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); - - if (afterTable.length >= 4 && tableSum >= 15) material += 10; - if (nextIsOpp && afterTable.length >= 4 && tableSum >= 24) material += 22; - if (!nextIsOpp && card.value >= 8) material += 8; - if (nextIsOpp && afterTable.length <= 2 && tableSum <= 12) { - material -= 28; - material -= exposedDenariCount * (liveDenariPressure ? 14 : 8); - material -= exposedSevenCount * (liveSevenPressure ? 16 : 10); - } - if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { - material += afterTable.length >= 4 ? 34 : 22; - if (tableSum >= 10 && tableSum <= 12) material += 26; - } - if (preservesHighComplementWindow) { - material += tableSum >= 24 ? 56 : 32; - if (card.suit !== 'denara') material += 12; - } - if ( - roleContext.defendingDealerAdvantage - && beforePairInventory > 0 - && afterPairInventory === beforePairInventory - && card.suit !== 'denara' - && card.value <= 4 - ) { - material += 42; - } - - if (tracker) { - const unseen = tracker.getUnseenCards(myHand, afterTable); - let directThreats = 0; - for (const unseenCard of unseen) { - const caps = findCaptures(unseenCard, afterTable); - for (const cap of caps) { - if (cap.length === afterTable.length) { - directThreats++; - break; - } - } - } - material -= directThreats * 8; - - for (const suit of SUITS) { - if (!tracker.hasBeenPlayed(`${suit}_7`) && nextIsOpp && afterTable.some(c => c.suit === suit && c.value === 7)) { - material -= 10; - } - } - - if (phase > 0.5) { - const confidence = Math.min(1, tracker.playedCount / 25); - material += Math.round(material * confidence * 0.15); - } - } - - if (table.length === 0 && nextIsOpp) material -= 18; - if (race.aheadOverall && sumCardValues(afterTable) >= 11) material += 8; - if (race.aheadOverall && card.value >= 8) material += 6; - if (roleContext.role === 'first-hand' && afterTable.length >= 2 && sumCardValues(afterTable) >= 8) material += 6; - if (roleContext.role === 'dealer' && nextIsOpp && sumCardValues(afterTable) <= 10) material -= 8; - - return scoreTacticalPriorityLadder({ - scopa: 0, - settebello: 0, - antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority, - partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), - sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s), - denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari), - material, - }) + (lastPlay ? 0 : 0); -} - -// =========================================================================== -// MASTER — deep minimax, alpha-beta, determinization, endgame solver -// improved evaluation, team-aware search, last-play awareness -// =========================================================================== - -function tableControlPressure( - afterTable: Card[], - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - myHand: Card[], - race: RaceState, - roleContext: DealerRoleContext, - rankResidue: RankResidueSnapshot | null, -): number { - if (afterTable.length === 0) return 0; - - let score = 0; - const next = nextPlayer(playerIdx); - const partner = partnerOf(playerIdx); - const nextHandSize = state.players[next].hand.length; - const partnerHandSize = state.players[partner].hand.length; - const nextIsOpp = isOpponent(playerIdx, next); - const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); - - if (tableSum >= 11) score += 70; - if (tableSum <= 10 && nextIsOpp) score -= 110; - if (afterTable.some(card => card.suit === 'denara')) { - score += nextIsOpp - ? (race.behindInDenari ? -110 : -45) - : (race.behindInDenari ? 35 : 15); - } - if (afterTable.some(card => card.value === 7)) { - score += nextIsOpp - ? (race.need7s ? -150 : -55) - : (race.need7s ? 45 : 15); - } - - for (const tableCard of afterTable) { - const myAnchors = countValueInHand(myHand, tableCard.value); - if (myAnchors > 0) score += myAnchors * 18; - - const partnerProb = handLikelyHasValue( - tableCard.value, - partnerHandSize, - state, - playerIdx, - tracker, - myHand, - afterTable, - ); - score += partnerProb * (nextIsOpp ? 20 : 55); - if (nextHandSize > 0 && nextIsOpp) { - const nextProb = handLikelyHasValue( - tableCard.value, - nextHandSize, - state, - playerIdx, - tracker, - myHand, - afterTable, - ); - score -= nextProb * 80; - } - } - - if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; - score += scoreExposedTableCards(afterTable, state, playerIdx, tracker, myHand, race); - score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); - score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); - return score; -} - -interface MoveTacticalSummary { - projectedTable: Card[]; - tableSum: number; - clearsTable: boolean; - capturedDenariCount: number; - capturedSevenCount: number; - capturesSettebello: boolean; - exposedDenariCount: number; - exposedSevenCount: number; - highQuietRelease: boolean; - sameValueAnchorsRemaining: number; -} - -function summarizeMoveTactics( - move: AIMove, - hand: Card[], - table: Card[], -): MoveTacticalSummary { - const projectedTable = move.capture.length > 0 - ? table.filter(card => !move.capture.some(captured => captured.id === card.id)) - : [...table, move.card]; - const tableSum = projectedTable.reduce((sum, card) => sum + card.value, 0); - const capturedCards = getMoveCollectedCards(move); - const exposedDenariCount = projectedTable.filter(card => card.suit === 'denara').length; - const exposedSevenCount = projectedTable.filter(card => card.value === 7).length; - - return { - projectedTable, - tableSum, - clearsTable: move.capture.length > 0 && projectedTable.length === 0, - capturedDenariCount: capturedCards.filter(card => card.suit === 'denara').length, - capturedSevenCount: capturedCards.filter(card => card.value === 7).length, - capturesSettebello: capturedCards.some(card => card.suit === 'denara' && card.value === 7), - exposedDenariCount, - exposedSevenCount, - highQuietRelease: move.capture.length === 0 && move.card.value >= 8 && move.card.suit !== 'denara', - sameValueAnchorsRemaining: Math.max(0, countValueInHand(hand, move.card.value) - 1), - }; -} - -function scoreQuietControlWindow( - move: AIMove, - summary: MoveTacticalSummary, - nextIsOpp: boolean, -): number { - if (move.capture.length > 0 || !nextIsOpp || summary.projectedTable.length < 4) { - return 0; - } - - let score = 0; - const complement = 10 - move.card.value; - const preservesTenLine = move.card.value >= 1 - && move.card.value <= 3 - && summary.projectedTable.some(card => card.value === complement); - - if (preservesTenLine) { - score += 44; - if (summary.tableSum >= 24) score += 32; - } - - if (summary.projectedTable.length >= 5) score += 18; - if (summary.tableSum >= 24) score += 18; - if (move.card.suit !== 'denara' && move.card.value <= 2 && summary.tableSum >= 20) score += 20; - if (summary.exposedDenariCount <= 1) score += 8; - if (summary.exposedSevenCount <= 1) score += 8; - - return score; -} - -function scoreOpeningDuplicateReleaseBias( - card: Card, - hand: Card[], - state: GameState, - playerIdx: PlayerIndex, - nextIsOpp: boolean, - roleContext: DealerRoleContext, -): number { - if ( - state.table.length > 0 - || !nextIsOpp - || roleContext.role !== 'first-hand' - ) { - return 0; - } - - const sameValueCount = countValueInHand(hand, card.value); - if (sameValueCount >= 2 && card.value >= 8) { - let score = card.suit === 'denara' ? -180 : 280; - - if ( - card.suit !== 'denara' - && hand.some(held => held.id !== card.id && held.value === card.value && held.suit === 'denara') - ) { - score += 220; - } - - if (sameValueCount >= 3) score += 56; - return score; - } - - if (sameValueCount === 1 && card.value <= 3) return -180; - if (card.suit === 'denara' && card.value <= 8) return -60; - - return 0; -} - -function scoreDirectSevenPrimieraSwing( - played: Card, - captured: Card[], - afterTable: Card[], - hand: Card[], - table: Card[], - liveSevenPressure: boolean, -): number { - if (!table.some(card => card.value === 7) || !captured.some(card => card.value === 7)) { - return 0; - } - - const directSevenCapture = played.value === 7 && captured.length === 1 && captured[0].value === 7; - const alternateDirectSevenAvailable = played.value !== 7 - && hand.some(card => card.id !== played.id && card.value === 7); - let score = 0; - - if (directSevenCapture) { - score += liveSevenPressure ? 320 : 220; - if (afterTable.length <= 3) score += 72; - if (!afterTable.some(card => card.value === 7)) score += liveSevenPressure ? 120 : 60; - } - - if (alternateDirectSevenAvailable) { - score -= liveSevenPressure ? 260 : 160; - } - - return score; -} - -function isForcingSearchMove(summary: MoveTacticalSummary, race: RaceState): boolean { - return summary.clearsTable - || summary.capturesSettebello - || summary.capturedSevenCount > 0 - || summary.capturedDenariCount >= 2 - || (race.behindInDenari && summary.capturedDenariCount > 0); -} - -function isPriorityControlQuietMove( - move: AIMove, - summary: MoveTacticalSummary, - nextIsOpp: boolean, - roleContext: DealerRoleContext, -): boolean { - if (move.capture.length > 0) return false; - - if ( - roleContext.defendingDealerAdvantage - && move.card.suit !== 'denara' - && move.card.value <= 4 - && summary.tableSum >= 18 - ) { - return true; - } - - if (!summary.highQuietRelease && summary.sameValueAnchorsRemaining === 0) return false; - if (nextIsOpp && summary.tableSum < 11) return false; - if (nextIsOpp && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0)) return false; - - return roleContext.defendingDealerAdvantage - || summary.sameValueAnchorsRemaining > 0 - || summary.tableSum >= 15 - || summary.projectedTable.length >= 5; -} - -function scoreHandStructure( - hand: Card[], - table: Card[], - roleContext: DealerRoleContext, -): number { - if (hand.length === 0) return 0; - - const counts = Array.from({ length: 11 }, () => 0); - let score = 0; - - for (const card of hand) { - counts[card.value]++; - } - - for (let value = 1; value <= 10; value++) { - if (counts[value] >= 2) { - score += Math.round((value >= 8 ? 32 : 18) * roleContext.pairPreservingBias); - } - if (counts[value] >= 3) { - score += 14; - } - } - - for (const card of hand) { - const captures = findCaptures(card, table); - if (captures.length > 0) { - let bestCaptureScore = 0; - for (const capture of captures) { - let captureScore = capture.length * 14; - if (capture.some(captured => captured.suit === 'denara')) captureScore += 16; - if (capture.some(captured => captured.value === 7)) captureScore += 20; - if (capture.length === table.length) captureScore += 90; - if (captureScore > bestCaptureScore) bestCaptureScore = captureScore; - } - score += bestCaptureScore; - } else { - if (card.value >= 8) score += 12; - if (card.suit !== 'denara' && card.value >= 8) score += 8; - if (card.value <= 3 && roleContext.defendingDealerAdvantage) score += 10; - } - - if (card.suit === 'denara') score += 10; - if (card.value === 7) score += 16; - } - - return score; -} - -function scoreProtectedPairInventory( - hand: Card[], - roleContext: DealerRoleContext, -): number { - if (hand.length < 2) return 0; - - const counts = Array.from({ length: 11 }, () => 0); - let score = 0; - - for (const card of hand) { - counts[card.value]++; - } - - for (let value = 1; value <= 10; value++) { - if (counts[value] < 2) continue; - - score += value >= 8 ? 18 : 10; - if (value === 7) score += 6; - if (counts[value] >= 3) score += 6; - } - - return Math.round(score * roleContext.pairPreservingBias); -} - -function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): number { - const hand = state.players[playerIdx].hand; - if (hand.length === 0) return 0; - - const roleContext = getDealerRoleContext(state, playerIdx); - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - let bestMoveScore = -Infinity; - let safeReleaseCount = 0; - let forcingCount = 0; - - for (const move of getLegalMoves(state, playerIdx)) { - const summary = summarizeMoveTactics(move, hand, state.table); - let moveScore = 0; - - if (summary.clearsTable) moveScore += 160; - moveScore += summary.capturedDenariCount * 26; - moveScore += summary.capturedSevenCount * 28; - if (summary.tableSum >= 11) moveScore += 24; - if (summary.tableSum <= 10 && nextIsOpp) moveScore -= 36; - if (summary.highQuietRelease && summary.tableSum >= 11) moveScore += 38; - if (summary.sameValueAnchorsRemaining > 0) moveScore += summary.sameValueAnchorsRemaining * 12; - moveScore += scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp); - - if (moveScore > bestMoveScore) bestMoveScore = moveScore; - if (isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext)) safeReleaseCount++; - if (summary.clearsTable || summary.capturedDenariCount > 0 || summary.capturedSevenCount > 0) forcingCount++; - } - - if (!Number.isFinite(bestMoveScore)) bestMoveScore = 0; - - return Math.round( - bestMoveScore - + safeReleaseCount * 18 - + forcingCount * 10 - + scoreHandStructure(hand, state.table, roleContext) * 0.4, - ); -} - -function scoreCurrentPlayerVisibleTempo( - state: GameState, - perspectiveTeam: 0 | 1, -): number { - const currentPlayer = state.currentPlayer; - if (state.players[currentPlayer].hand.length === 0) return 0; - - const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); - const urgency = cardsRemaining <= 8 ? 0.92 : cardsRemaining <= 16 ? 0.82 : 0.72; - const sign = teamOf(currentPlayer) === perspectiveTeam ? 1 : -1; - - return Math.round(scorePlayerVisibleTempo(state, currentPlayer) * urgency * sign); -} - -function scoreMoveObjectiveBias( - move: AIMove, - state: GameState, - playerIdx: PlayerIndex, - rootPlayer: PlayerIndex, - tracker: CardTracker | undefined, -): number { - const hand = state.players[playerIdx].hand; - const phase = gamePhase(state); - const race = getRaceState(state, playerIdx); - const roleContext = getDealerRoleContext(state, playerIdx); - const rankResidue = getRankResidueSnapshot(tracker, hand, state.table); - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; - const lastPlay = isLastPlay(state, playerIdx); - const summary = summarizeMoveTactics(move, hand, state.table); - const projectedHand = hand.filter(card => card.id !== move.card.id); - const capturedCards = getMoveCollectedCards(move); - const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); - const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); - const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); - const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); - const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); - const openingReleasePriority = move.capture.length === 0 - ? evaluateFirstHandOpeningReleasePriority( - move.card, - hand, - projectedHand, - summary.projectedTable, - state, - playerIdx, - tracker, - nextIsOpp, - roleContext, - ) - : 0; - const beforeHandStructure = scoreHandStructure(hand, state.table, roleContext); - const afterHandStructure = scoreHandStructure(projectedHand, summary.projectedTable, roleContext); - const beforePairInventory = scoreProtectedPairInventory(hand, roleContext); - const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); - const handStructureDelta = afterHandStructure - beforeHandStructure; - const pairInventoryDelta = afterPairInventory - beforePairInventory; - const rankResiduePlanScore = move.capture.length > 0 - ? scoreCaptureRankResiduePlan(move.card, move.capture, summary.projectedTable, rankResidue, roleContext, nextIsOpp) - : scoreDumpRankResiduePlan(move.card, summary.projectedTable, rankResidue, roleContext, nextIsOpp); - const quietControlWindow = scoreQuietControlWindow(move, summary, nextIsOpp); - const liveDenariPressure = race.behindInDenari || race.denariRaceLive; - const liveSevenPressure = race.need7s || race.sevenRaceLive; - const openingDuplicateReleaseBias = move.capture.length === 0 - ? scoreOpeningDuplicateReleaseBias(move.card, hand, state, playerIdx, nextIsOpp, roleContext) - : 0; - const directSevenPrimieraSwing = move.capture.length > 0 - ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure) - : 0; - const liveCardsMajorityRace = race.myCards < 21 - && race.oppCards < 21 - && Math.abs(race.myCards - race.oppCards) <= 5; - const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; - const cardsMajorityDelta = move.capture.length > 0 - ? scoreCardsMajorityPosition(race.myCards + capturedCards.length, race.oppCards, phase) - - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase) - : 0; - const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value; - const directSettebelloCapture = directRankCapture - && move.capture[0].suit === 'denara' - && move.capture[0].value === 7; - const exactPartnerWindow = move.capture.length === 0 - && partnerHandSize > 0 - && summary.projectedTable.length >= 4 - && summary.tableSum >= 10 - && summary.tableSum <= 12 - && summary.exposedDenariCount <= 1 - && summary.exposedSevenCount <= 1; - const safePartnerWindow = move.capture.length === 0 - && nextIsOpp - && threats?.partnerCanScopa - && !threats.nextOppCanScopa; - - let bias = 0; - - bias += scopaPriority * 380; - if (summary.clearsTable && !lastPlay) bias += 220; - if (summary.capturesSettebello) bias += 460; - if (directRankCapture) bias += move.card.value === 7 ? 90 : 34; - if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70; - if (directRankCapture && move.capture[0].suit === 'denara') { - bias += liveDenariPressure ? (protectingCardsLead ? 88 : 150) : protectingCardsLead ? 36 : 72; - } - if ( - protectingCardsLead - && !race.behindInDenari - && move.capture.length === 1 - && move.capture[0].suit === 'denara' - && !summary.capturesSettebello - ) { - bias -= 180; - } - if (directSettebelloCapture) bias += 180; - if (directSettebelloCapture && nextIsOpp) bias += 220; - if ( - !summary.capturesSettebello - && state.table.some(card => card.suit === 'denara' && card.value === 7) - && nextIsOpp - ) { - bias -= 460; - } - - bias += antiScopaPriority * 48; - bias += partnerSetupPriority * 8; - bias += evaluateSevenDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.need7s) * (race.need7s ? 42 : race.sevenRaceLive ? 40 : 36); - bias += evaluateDenariDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.behindInDenari) * (race.behindInDenari ? 38 : race.denariRaceLive ? 36 : 32); - bias += openingReleasePriority * 52; - bias += openingDuplicateReleaseBias; - bias += quietControlWindow; - bias += directSevenPrimieraSwing; - bias += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 3.6 : 1.8)); - if (protectingCardsLead && move.capture.length > 1) { - bias += 220 + move.capture.length * 36; - } - bias += Math.round(handStructureDelta * 1.35); - bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5)); - bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85); - bias += Math.round(rankResiduePlanScore * 0.9); - bias += Math.round(scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker) * 0.55); - bias += Math.round(capturedCards.reduce((sum, card) => sum + primieraVal(card), 0) * 3.2); - - if (nextIsOpp) { - bias -= Math.round(summary.projectedTable.reduce((sum, card) => sum + primieraVal(card), 0) * 1.25); - } - - if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { - bias -= 720; - } - - if (move.capture.length === 0) { - if (summary.highQuietRelease) bias += 72; - bias += summary.sameValueAnchorsRemaining * 44; - if ( - nextIsOpp - && summary.projectedTable.length >= 5 - && summary.tableSum >= 24 - && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) - ) { - bias -= 96 + summary.exposedDenariCount * 54 + summary.exposedSevenCount * 68; - } - if (exactPartnerWindow) bias += 96; - if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76; - if ( - roleContext.defendingDealerAdvantage - && move.card.suit !== 'denara' - && move.card.value <= 4 - && summary.sameValueAnchorsRemaining > 0 - ) { - bias += 90; - } - if ( - roleContext.defendingDealerAdvantage - && beforePairInventory > 0 - && afterPairInventory === beforePairInventory - && move.card.suit !== 'denara' - && move.card.value <= 4 - ) { - bias += 152; - } - } else if ( - nextIsOpp - && !summary.clearsTable - && !summary.capturesSettebello - && summary.capturedSevenCount === 0 - && summary.projectedTable.length <= 2 - && summary.tableSum <= 12 - ) { - bias -= 120; - } - - if ( - move.capture.length > 0 - && roleContext.defendingDealerAdvantage - && countValueInHand(hand, move.card.value) >= 2 - && !summary.clearsTable - ) { - bias -= Math.round((move.card.value >= 8 ? 180 : 80) * roleContext.pairPreservingBias); - } - - return teamOf(playerIdx) === teamOf(rootPlayer) ? bias : -bias; -} - -interface RankedRootMove { - index: number; - move: AIMove; - key: string; - quick: number; - isCapture: boolean; - forcing: boolean; - priorityControlQuiet: boolean; -} - -interface MasterSearchProgressState { - evaluationsCompleted: number; - totalEvaluations: number; - batchesCompleted: number; - completedDepth: number; - aspirationExpansions: number; - timedOut: boolean; -} - -interface MasterDepthResult { - completed: boolean; - bestMove: AIMove; - bestKey: string; - bestScore: number; -} - -type TranspositionBound = 'exact' | 'lower' | 'upper'; - -interface TranspositionEntry { - key: string; - bestMove: AIMove | null; - bestMoveKey: string | null; - depth: number; - score: number; - bound: TranspositionBound; -} - -interface MasterRootWorkspace { - moveScores: number[]; - orderedMoves: RankedRootMove[]; - pvMoves: RankedRootMove[]; - hashMoves: RankedRootMove[]; - forcingMoves: RankedRootMove[]; - controlQuietMoves: RankedRootMove[]; - killerHistoryQuietMoves: RankedRootMove[]; - remainingMoves: RankedRootMove[]; -} - -interface SampleHandAssignment { - playerIdx: PlayerIndex; - handSize: number; -} - -interface SampleHandBucket { - assignment: SampleHandAssignment; - cards: Card[]; -} - -interface SearchHeuristics { - killerMoves: Map; - historyScores: Map; -} - -interface AspirationWindow { - alpha: number; - beta: number; -} - -type AspirationFailure = 'lower' | 'upper'; - -const ASPIRATION_BASE_WINDOW = 120; -const EARLY_TURN_ASPIRATION_BASE_WINDOW = 180; -const ASPIRATION_MAX_EXPANSIONS = 5; -const EARLY_TURN_MIN_REMAINING_BUDGET_MS = 420; -const EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION = 0.72; -const KILLER_MOVE_SLOTS = 2; -const MAX_EXACT_SAMPLE_ASSIGNMENTS = 48; -const MAX_FOCUSED_ASSIGNMENT_CARDS = 8; -const ROOT_QUICK_PRIOR_FACTOR = 0.2; - -function isQuietMove(move: AIMove): boolean { - return move.capture.length === 0; -} - -function getQuietHistoryScore( - heuristics: SearchHeuristics, - move: AIMove, -): number { - return heuristics.historyScores.get(moveKey(move)) ?? 0; -} - -function getKillerMoveRank( - heuristics: SearchHeuristics, - ply: number, - move: AIMove, -): number { - const killers = heuristics.killerMoves.get(ply); - if (!killers || killers.length === 0) return -1; - return killers.indexOf(moveKey(move)); -} - -function compareQuietMovePriority( - left: { move: AIMove; quick: number }, - right: { move: AIMove; quick: number }, - heuristics: SearchHeuristics, - ply: number, -): number { - const leftKillerRank = getKillerMoveRank(heuristics, ply, left.move); - const rightKillerRank = getKillerMoveRank(heuristics, ply, right.move); - const leftKillerOrder = leftKillerRank === -1 ? Number.POSITIVE_INFINITY : leftKillerRank; - const rightKillerOrder = rightKillerRank === -1 ? Number.POSITIVE_INFINITY : rightKillerRank; - - if (leftKillerOrder !== rightKillerOrder) { - return leftKillerOrder - rightKillerOrder; - } - - const historyDelta = getQuietHistoryScore(heuristics, right.move) - getQuietHistoryScore(heuristics, left.move); - if (historyDelta !== 0) return historyDelta; - - return right.quick - left.quick; -} - -function recordQuietCutoff( - heuristics: SearchHeuristics, - move: AIMove, - ply: number, - depth: number, -): void { - if (!isQuietMove(move)) return; - - const key = moveKey(move); - const killers = heuristics.killerMoves.get(ply) ?? []; - const updatedKillers = [key, ...killers.filter(existingKey => existingKey !== key)].slice(0, KILLER_MOVE_SLOTS); - heuristics.killerMoves.set(ply, updatedKillers); - - const historyBonus = Math.max(1, depth) * Math.max(1, depth); - heuristics.historyScores.set(key, (heuristics.historyScores.get(key) ?? 0) + historyBonus); -} - -function createAspirationWindow( - previousScore: number | undefined, - depth: number, - sampleCount: number, - minimumHalfWindow: number, -): AspirationWindow { - if (previousScore === undefined) { - return { alpha: -Infinity, beta: Infinity }; - } - - const halfWindow = Math.max( - minimumHalfWindow, - Math.round(sampleCount * 45 + depth * 24), - ); - - return { - alpha: previousScore - halfWindow, - beta: previousScore + halfWindow, - }; -} - -function widenAspirationWindow( - window: AspirationWindow, - failingBound: AspirationFailure, - expansion: number, -): AspirationWindow { - if (failingBound === 'lower') { - return { - alpha: window.alpha - expansion, - beta: window.beta, - }; - } - - return { - alpha: window.alpha, - beta: window.beta + expansion, - }; -} - -function classifyAspirationFailure( - score: number, - window: AspirationWindow, -): AspirationFailure | undefined { - if (score <= window.alpha) return 'lower'; - if (score >= window.beta) return 'upper'; - return undefined; -} - -function searchPrincipalVariationChild( - state: GameState, - depth: number, - alpha: number, - beta: number, - myTeam: 0 | 1, - rootPlayer: PlayerIndex, - phase: number, - deadline: number, - timing: SearchTimingContext, - tracker: CardTracker | undefined, - transpositionTable: Map, - heuristics: SearchHeuristics, - ply: number, - isFirstMove: boolean, - maximizing: boolean, -): number { - if (isFirstMove) { - return alphaBeta( - state, - depth, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - } - - if (maximizing) { - const scoutBeta = Number.isFinite(alpha) ? Math.min(beta, alpha + 1) : beta; - if (!(scoutBeta > alpha)) { - return alphaBeta( - state, - depth, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - } - - const scoutScore = alphaBeta( - state, - depth, - alpha, - scoutBeta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - if (scoutScore > alpha && scoutScore < beta && timing.now() <= deadline) { - return alphaBeta( - state, - depth, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - } - return scoutScore; - } - - const scoutAlpha = Number.isFinite(beta) ? Math.max(alpha, beta - 1) : alpha; - if (!(scoutAlpha < beta)) { - return alphaBeta( - state, - depth, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - } - - const scoutScore = alphaBeta( - state, - depth, - scoutAlpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - if (scoutScore < beta && scoutScore > alpha && timing.now() <= deadline) { - return alphaBeta( - state, - depth, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply, - ); - } - return scoutScore; -} - -function rankRootMoves( - legalMoves: AIMove[], - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - race: RaceState, - roleContext: DealerRoleContext, - timing: SearchTimingContext, -): RankedRootMove[] { - const hand = state.players[playerIdx].hand; - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - const rankedMoves = legalMoves.map(move => { - timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); - const quick = quickEval( - move, - state, - playerIdx, - playerIdx, - tracker, - false, - ); - const summary = summarizeMoveTactics(move, hand, state.table); - return { - move, - key: moveKey(move), - quick, - forcing: isForcingSearchMove(summary, race), - priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext), - }; - }); - - return rankedMoves - .sort((a, b) => b.quick - a.quick) - .map((rankedMove, index) => ({ - ...rankedMove, - index, - isCapture: rankedMove.move.capture.length > 0, - })); -} - -function createMasterRootWorkspace(rootMoveCount: number): MasterRootWorkspace { - return { - moveScores: new Array(rootMoveCount).fill(0), - orderedMoves: [], - pvMoves: [], - hashMoves: [], - forcingMoves: [], - controlQuietMoves: [], - killerHistoryQuietMoves: [], - remainingMoves: [], - }; -} - -function resetMasterRootWorkspace(workspace: MasterRootWorkspace): void { - workspace.orderedMoves.length = 0; - workspace.pvMoves.length = 0; - workspace.hashMoves.length = 0; - workspace.forcingMoves.length = 0; - workspace.controlQuietMoves.length = 0; - workspace.killerHistoryQuietMoves.length = 0; - workspace.remainingMoves.length = 0; -} - -function appendRankedRootMoves(target: RankedRootMove[], source: RankedRootMove[]): void { - for (const rankedMove of source) { - target.push(rankedMove); - } -} - -function orderRootMovesForDepth( - rankedMoves: RankedRootMove[], - previousBestKey: string | undefined, - ttEntry: TranspositionEntry | undefined, - heuristics: SearchHeuristics, - workspace: MasterRootWorkspace, - timing: SearchTimingContext, -): RankedRootMove[] { - if (rankedMoves.length <= 1) return rankedMoves; - - resetMasterRootWorkspace(workspace); - const hashMoveKey = ttEntry?.bestMoveKey ?? undefined; - - for (const rankedMove of rankedMoves) { - timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); - const quietMoveBoost = !rankedMove.isCapture - && ( - getKillerMoveRank(heuristics, 0, rankedMove.move) !== -1 - || getQuietHistoryScore(heuristics, rankedMove.move) > 0 - ); - - if (previousBestKey && rankedMove.key === previousBestKey) { - workspace.pvMoves.push(rankedMove); - continue; - } - - if (hashMoveKey && rankedMove.key === hashMoveKey) { - workspace.hashMoves.push(rankedMove); - continue; - } - - if (rankedMove.forcing) { - workspace.forcingMoves.push(rankedMove); - continue; - } - - if (rankedMove.priorityControlQuiet) { - workspace.controlQuietMoves.push(rankedMove); - continue; - } - - if (quietMoveBoost) { - workspace.killerHistoryQuietMoves.push(rankedMove); - continue; - } - - workspace.remainingMoves.push(rankedMove); - } - - workspace.killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, 0)); - - appendRankedRootMoves(workspace.orderedMoves, workspace.pvMoves); - appendRankedRootMoves(workspace.orderedMoves, workspace.hashMoves); - appendRankedRootMoves(workspace.orderedMoves, workspace.forcingMoves); - appendRankedRootMoves(workspace.orderedMoves, workspace.controlQuietMoves); - appendRankedRootMoves(workspace.orderedMoves, workspace.killerHistoryQuietMoves); - appendRankedRootMoves(workspace.orderedMoves, workspace.remainingMoves); - - return workspace.orderedMoves; -} - -function selectBestRootMove( - rankedMoves: RankedRootMove[], - moveScores: number[], -): { bestMove: AIMove; bestKey: string; bestScore: number } { - let bestRootMove = rankedMoves[0]; - let bestScore = moveScores[bestRootMove.index] ?? 0; - - for (const rankedMove of rankedMoves) { - const totalScore = moveScores[rankedMove.index] ?? 0; - if (totalScore > bestScore) { - bestScore = totalScore; - bestRootMove = rankedMove; - } - } - - return { - bestMove: bestRootMove.move, - bestKey: bestRootMove.key, - bestScore, - }; -} - -function getMasterProgress( - progressState: MasterSearchProgressState, - startedAt: number, - budgetMs: number, - timing: SearchTimingContext, -): number { - return Math.max( - progressState.evaluationsCompleted / progressState.totalEvaluations, - Math.min(1, (timing.now() - startedAt) / budgetMs), - ); -} - -function buildMasterProgressDetails( - progressState: MasterSearchProgressState, - cardsRemaining: number, - sampleCount: number, - maxDepth: number, - rootMoveCount: number, -): MasterProgressDetails { - return { - cardsRemaining, - sampleCount, - maxDepth, - completedDepth: progressState.completedDepth, - rootMoveCount, - timedOut: progressState.timedOut, - aspirationExpansions: progressState.aspirationExpansions, - }; -} - -function scoreControlOverrideCandidate( - move: AIMove, - state: GameState, - playerIdx: PlayerIndex, - race: RaceState, - roleContext: DealerRoleContext, - tracker: CardTracker | undefined, -): number { - const hand = state.players[playerIdx].hand; - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - const lastPlay = isLastPlay(state, playerIdx); - const summary = summarizeMoveTactics(move, hand, state.table); - const projectedHand = hand.filter(card => card.id !== move.card.id); - const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); - const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; - const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); - const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); - const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); - const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); - const openingReleasePriority = move.capture.length === 0 - ? evaluateFirstHandOpeningReleasePriority( - move.card, - hand, - projectedHand, - summary.projectedTable, - state, - playerIdx, - tracker, - nextIsOpp, - roleContext, - ) - : 0; - let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55); - - score += summary.projectedTable.length * 48; - score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260; - score += scopaPriority * 600; - score += antiScopaPriority * 54; - score += partnerSetupPriority * 8; - - if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { - score -= 720; - } - - if (move.capture.length === 0) { - if (summary.highQuietRelease) score += 220; - score += openingReleasePriority * 180; - if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70; - if (nextIsOpp && summary.projectedTable.length >= 5) score += 110; - if ( - nextIsOpp - && summary.highQuietRelease - && summary.projectedTable.length >= 5 - && summary.tableSum >= 24 - ) { - if (summary.exposedDenariCount === 0 && summary.exposedSevenCount === 0) { - score += 260; - } else { - score -= 80 + summary.exposedDenariCount * 90 + summary.exposedSevenCount * 120; - } - } - } else { - if (!isForcingSearchMove(summary, race)) { - score -= summary.projectedTable.length <= 2 || summary.tableSum <= 12 ? 200 : 80; - } - if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) { - score -= 180; - } - if (nextIsOpp && summary.projectedTable.length <= 2) score -= 150; - else if (nextIsOpp && summary.projectedTable.length === 3 && summary.tableSum <= 12) score -= 90; - if (nextIsOpp) score -= summary.exposedDenariCount * 90; - if (nextIsOpp) score -= summary.exposedSevenCount * 70; - if ( - nextIsOpp - && !summary.clearsTable - && !summary.capturesSettebello - && summary.capturedSevenCount === 0 - && summary.projectedTable.length <= 2 - && summary.tableSum < 18 - ) { - score -= 220; - } - } - - if (roleContext.defendingDealerAdvantage && move.capture.length === 0 && summary.tableSum >= 18) { - score += 180; - } - - if (nextIsOpp && summary.projectedTable.length > 0 && summary.tableSum <= 10) { - score -= 220; - } - - return score; -} - -function findStrategicControlOverride( - legalMoves: AIMove[], - state: GameState, - playerIdx: PlayerIndex, - race: RaceState, - roleContext: DealerRoleContext, - tracker: CardTracker | undefined, -): AIMove | undefined { - if (legalMoves.length <= 1) return undefined; - const lastPlay = isLastPlay(state, playerIdx); - if (lastPlay) return undefined; - if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined; - - let bestQuiet: - | { move: AIMove; score: number } - | undefined; - let bestCapture: - | { move: AIMove; score: number } - | undefined; - let bestSafeScopa: - | { move: AIMove; score: number } - | undefined; - - for (const move of legalMoves) { - const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker); - const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table); - const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); - const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); - const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats); - - if (scopaPriority > 0) { - if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score }; - } - - if (move.capture.length === 0) { - if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score }; - continue; - } - - if (!bestCapture || score > bestCapture.score) bestCapture = { move, score }; - } - - if (bestSafeScopa) return bestSafeScopa.move; - - if (!bestQuiet) return undefined; - - const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table); - const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== bestQuiet.move.card.id); - const duplicateHighValues = new Set( - projectedHand.filter(card => projectedHand.some(other => other.id !== card.id && other.value === card.value && card.value >= 8)) - .map(card => card.value), - ).size; - const dealerControlQuiet = roleContext.role === 'dealer' - && bestCapture !== undefined - && bestQuiet.score >= bestCapture.score + 220 - && bestQuiet.move.card.suit !== 'denara' - && bestQuiet.move.card.value <= 3 - && quietSummary.projectedTable.length >= 5 - && quietSummary.tableSum >= 18 - && duplicateHighValues > 0; - - if (dealerControlQuiet) { - return bestQuiet.move; - } - - if (!bestCapture) return undefined; - - const captureSummary = summarizeMoveTactics(bestCapture.move, state.players[playerIdx].hand, state.table); - const antiScopaControlQuiet = bestQuiet.score >= bestCapture.score + 120 - && bestQuiet.move.card.suit !== 'denara' - && bestQuiet.move.card.value >= 8 - && quietSummary.projectedTable.length >= 5 - && quietSummary.tableSum >= 24 - && state.table.some(card => card.suit === 'denara' || card.value === 7) - && bestCapture.move.card.value <= 5 - && captureSummary.capturedSevenCount === 0 - && !captureSummary.clearsTable - && !captureSummary.capturesSettebello - && captureSummary.projectedTable.length <= 3; - - return antiScopaControlQuiet ? bestQuiet.move : undefined; -} - -async function evaluateMasterDepth( - state: GameState, - samples: GameState[], - orderedMoves: RankedRootMove[], - depth: number, - aspirationWindow: AspirationWindow, - playerIdx: PlayerIndex, - myTeam: 0 | 1, - phase: number, - deadline: number, - tracker: CardTracker | undefined, - onProgress: ((progress: AIDecisionProgress) => void) | undefined, - profile: SearchProfile, - startedAt: number, - timing: SearchTimingContext, - progressState: MasterSearchProgressState, - transpositionTable: Map, - heuristics: SearchHeuristics, - rootWorkspace: MasterRootWorkspace, - cardsRemaining: number, - sampleCount: number, - rootMoveCount: number, -): Promise { - const moveScores = rootWorkspace.moveScores; - moveScores.fill(0); - for (const orderedMove of orderedMoves) { - moveScores[orderedMove.index] = orderedMove.quick * ROOT_QUICK_PRIOR_FACTOR; - } - - for (let start = 0; start < samples.length; start += profile.batchSize) { - if (timing.now() > deadline) { - progressState.timedOut = true; - return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; - } - - const end = Math.min(start + profile.batchSize, samples.length); - for (let sampleIdx = start; sampleIdx < end; sampleIdx++) { - const sample = samples[sampleIdx]; - let sampleAlpha = aspirationWindow.alpha; - const sampleBeta = aspirationWindow.beta; - let isFirstRootMove = true; - - for (const orderedMove of orderedMoves) { - timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); - if (timing.now() > deadline) { - progressState.timedOut = true; - return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; - } - - const result = applyMove( - sample, - playerIdx, - orderedMove.move.card, - orderedMove.move.capture.length > 0 ? orderedMove.move.capture : undefined, - ); - const score = searchPrincipalVariationChild( - result.nextState, - depth - 1, - sampleAlpha, - sampleBeta, - myTeam, - playerIdx, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - 1, - isFirstRootMove, - true, - ); - moveScores[orderedMove.index] += score; - if (score > sampleAlpha) { - sampleAlpha = score; - } - progressState.evaluationsCompleted++; - isFirstRootMove = false; - } - } - - progressState.batchesCompleted++; - reportDecisionProgress( - onProgress, - 'master', - startedAt, - timing, - profile.timeBudgetMs, - getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), - progressState.batchesCompleted, - buildMasterProgressDetails( - progressState, - cardsRemaining, - sampleCount, - profile.maxDepth, - rootMoveCount, - ), - ); - - if (end < samples.length && timing.now() < deadline) { - await timing.yieldToHost(); - } - } - - return { completed: true, ...selectBestRootMove(orderedMoves, moveScores) }; -} - -async function masterMove( - state: GameState, - playerIdx: PlayerIndex, - tracker: CardTracker | undefined, - onProgress: ((progress: AIDecisionProgress) => void) | undefined, - profile: SearchProfile, - startedAt: number, - timing: SearchTimingContext, - rng: RandomSource, -): Promise { - const myTeam = teamOf(playerIdx); - const phase = gamePhase(state); - const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); - const legalMoves = getLegalMoves(state, playerIdx); - const rootMoveCount = legalMoves.length; - if (legalMoves.length === 1) { - reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { - cardsRemaining, - sampleCount: 1, - maxDepth: 1, - completedDepth: 1, - rootMoveCount, - timedOut: false, - aspirationExpansions: 0, - }); - return legalMoves[0]; - } - - const deadline = startedAt + profile.timeBudgetMs; - const progressState: MasterSearchProgressState = { - evaluationsCompleted: 0, - totalEvaluations: Math.max(1, rootMoveCount * profile.maxDepth), - batchesCompleted: 0, - completedDepth: 0, - aspirationExpansions: 0, - timedOut: false, - }; - - const race = getRaceState(state, playerIdx); - const roleContext = getDealerRoleContext(state, playerIdx); - const rankedMoves = rankRootMoves( - legalMoves, - state, - playerIdx, - tracker, - race, - roleContext, - timing, - ); - let bestPreSearchMove = rankedMoves[0].move; - if (timing.now() > deadline) { - progressState.timedOut = true; - reportDecisionProgress( - onProgress, - 'master', - startedAt, - timing, - profile.timeBudgetMs, - getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), - progressState.batchesCompleted, - buildMasterProgressDetails(progressState, cardsRemaining, 0, profile.maxDepth, rankedMoves.length), - ); - return bestPreSearchMove; - } - - const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng, timing); - const sampleCount = samples.length; - progressState.totalEvaluations = Math.max(1, samples.length * rankedMoves.length * profile.maxDepth); - if (timing.now() > deadline) { - progressState.timedOut = true; - reportDecisionProgress( - onProgress, - 'master', - startedAt, - timing, - profile.timeBudgetMs, - getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), - progressState.batchesCompleted, - buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), - ); - return bestPreSearchMove; - } - - const transpositionTable = new Map(); - const heuristics: SearchHeuristics = { - killerMoves: new Map(), - historyScores: new Map(), - }; - const rootWorkspace = createMasterRootWorkspace(rankedMoves.length); - const rootStateKey = buildSearchStateKey(state); - - reportDecisionProgress( - onProgress, - 'master', - startedAt, - timing, - profile.timeBudgetMs, - 0, - 0, - buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), - ); - - let previousBestKey: string | undefined; - let lastCompletedDepth: MasterDepthResult | undefined; - let lastCompletedDepthElapsedMs: number | undefined; - - for (let depth = 1; depth <= profile.maxDepth; depth++) { - const depthStartedAt = timing.now(); - if (depthStartedAt > deadline) { - progressState.timedOut = true; - break; - } - - if (cardsRemaining > 20) { - const remainingBudgetMs = deadline - depthStartedAt; - if (remainingBudgetMs < EARLY_TURN_MIN_REMAINING_BUDGET_MS) { - break; - } - - if ( - lastCompletedDepthElapsedMs !== undefined - && lastCompletedDepthElapsedMs >= profile.timeBudgetMs * EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION - ) { - break; - } - } - - const aspirationHalfWindowFloor = cardsRemaining > 20 || rootMoveCount >= 8 - ? EARLY_TURN_ASPIRATION_BASE_WINDOW - : ASPIRATION_BASE_WINDOW; - let aspirationWindow = createAspirationWindow( - lastCompletedDepth?.bestScore, - depth, - samples.length, - aspirationHalfWindowFloor, - ); - let depthResult: MasterDepthResult | undefined; - - for (let expansion = 0; expansion <= ASPIRATION_MAX_EXPANSIONS; expansion++) { - if (timing.now() > deadline) { - progressState.timedOut = true; - break; - } - - const rootEntry = transpositionTable.get(rootStateKey); - const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace, timing); - bestPreSearchMove = orderedMoves[0]?.move ?? bestPreSearchMove; - if (timing.now() > deadline) { - progressState.timedOut = true; - break; - } - - depthResult = await evaluateMasterDepth( - state, - samples, - orderedMoves, - depth, - aspirationWindow, - playerIdx, - myTeam, - phase, - deadline, - tracker, - onProgress, - profile, - startedAt, - timing, - progressState, - transpositionTable, - heuristics, - rootWorkspace, - cardsRemaining, - sampleCount, - rankedMoves.length, - ); - - if (!depthResult.completed) { - break; - } - - const failingBound = classifyAspirationFailure(depthResult.bestScore, aspirationWindow); - if (!failingBound) { - lastCompletedDepth = depthResult; - lastCompletedDepthElapsedMs = timing.now() - depthStartedAt; - previousBestKey = depthResult.bestKey; - progressState.completedDepth = depth; - break; - } - - progressState.aspirationExpansions++; - const windowWidth = Number.isFinite(aspirationWindow.alpha) && Number.isFinite(aspirationWindow.beta) - ? aspirationWindow.beta - aspirationWindow.alpha - : aspirationHalfWindowFloor; - aspirationWindow = widenAspirationWindow( - aspirationWindow, - failingBound, - Math.max(aspirationHalfWindowFloor, windowWidth * 2), - ); - } - - if (!depthResult?.completed || lastCompletedDepth !== depthResult) { - break; - } - - if (depth < profile.maxDepth && timing.now() < deadline) { - await timing.yieldToHost(); - } - } - - const bestMove = lastCompletedDepth?.bestMove ?? bestPreSearchMove; - - reportDecisionProgress( - onProgress, - 'master', - startedAt, - timing, - profile.timeBudgetMs, - 1, - progressState.batchesCompleted, - buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), - ); - return bestMove; -} - -function quickEval( - move: AIMove, - state: GameState, - playerIdx: PlayerIndex, - rootPlayer: PlayerIndex, - tracker: CardTracker | undefined, - allowHiddenHands: boolean, -): number { - const result = applyMove(state, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); - const nextState = result.nextState; - - return evaluateTeamPosition( - nextState, - teamOf(rootPlayer), - gamePhase(nextState), - tracker, - rootPlayer, - allowHiddenHands, - ) + scoreMoveObjectiveBias(move, state, playerIdx, rootPlayer, tracker); -} - -function moveKey(move: AIMove): string { - const capIds = move.capture.map(c => c.id).sort().join(','); - return `${move.card.id}|${capIds}`; -} - -function getMoveCollectedCards(move: AIMove): Card[] { - if (move.capture.length === 0) return []; - return [move.card, ...move.capture]; -} - -function stableCardCollectionKey(cards: Card[]): string { - return cards.map(card => card.id).sort().join(','); -} - -function buildSearchStateKey(state: GameState): string { - const playerKeys = state.players.map((player, index) => { - const handKey = stableCardCollectionKey(player.hand); - const pileKey = stableCardCollectionKey(player.pile); - return `p${index}h:${handKey}|p${index}p:${pileKey}|p${index}s:${player.scope}`; - }).join('|'); - - return [ - `cp:${state.currentPlayer}`, - `d:${state.dealer}`, - `l:${state.lastCapturTeam ?? 'null'}`, - `t:${stableCardCollectionKey(state.table)}`, - playerKeys, - ].join('|'); -} - -function getValidHashMove( - moves: AIMove[], - entry: TranspositionEntry | undefined, -): AIMove | undefined { - if (!entry?.bestMoveKey) return undefined; - - return moves.find(move => moveKey(move) === entry.bestMoveKey); -} - -function orderSearchMoves( - moves: AIMove[], - state: GameState, - playerIdx: PlayerIndex, - rootPlayer: PlayerIndex, - tracker: CardTracker | undefined, - pvMove: AIMove | undefined, - hashMove: AIMove | undefined, - heuristics: SearchHeuristics, - ply: number, -): AIMove[] { - if (moves.length <= 1) return moves; - - const race = getRaceState(state, playerIdx); - const roleContext = getDealerRoleContext(state, playerIdx); - const hand = state.players[playerIdx].hand; - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - const maximizingNode = teamOf(playerIdx) === teamOf(rootPlayer); - const rankedMoves = moves - .map(move => ({ - move, - key: moveKey(move), - quick: quickEval(move, state, playerIdx, rootPlayer, tracker, true), - })) - .sort((a, b) => maximizingNode ? b.quick - a.quick : a.quick - b.quick); - - const pvMoveKey = pvMove ? moveKey(pvMove) : undefined; - const hashMoveKey = hashMove ? moveKey(hashMove) : undefined; - const pvMoves: typeof rankedMoves = []; - const hashMoves: typeof rankedMoves = []; - const forcingMoves: typeof rankedMoves = []; - const controlQuietMoves: typeof rankedMoves = []; - const killerHistoryQuietMoves: typeof rankedMoves = []; - const remainingMoves: typeof rankedMoves = []; - - for (const rankedMove of rankedMoves) { - const moveSummary = summarizeMoveTactics(rankedMove.move, hand, state.table); - const quietMoveBoost = isQuietMove(rankedMove.move) - && ( - getKillerMoveRank(heuristics, ply, rankedMove.move) !== -1 - || getQuietHistoryScore(heuristics, rankedMove.move) > 0 - ); - - if (pvMoveKey && rankedMove.key === pvMoveKey) { - pvMoves.push(rankedMove); - continue; - } - - if (hashMoveKey && rankedMove.key === hashMoveKey) { - hashMoves.push(rankedMove); - continue; - } - - if (isForcingSearchMove(moveSummary, race)) { - forcingMoves.push(rankedMove); - continue; - } - - if (isPriorityControlQuietMove(rankedMove.move, moveSummary, nextIsOpp, roleContext)) { - controlQuietMoves.push(rankedMove); - continue; - } - - if (quietMoveBoost) { - killerHistoryQuietMoves.push(rankedMove); - continue; - } - - remainingMoves.push(rankedMove); - } - - killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, ply)); - - return [ - ...pvMoves, - ...hashMoves, - ...forcingMoves, - ...controlQuietMoves, - ...killerHistoryQuietMoves, - ...remainingMoves, - ].map(rankedMove => rankedMove.move); +function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { + return teamOf(me) !== teamOf(other); } function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { @@ -3176,1003 +219,359 @@ function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { return moves; } -function createSampleHandAssignments(state: GameState, playerIdx: PlayerIndex): SampleHandAssignment[] { - const assignments: SampleHandAssignment[] = []; - let cur = nextPlayer(playerIdx); - - for (let step = 0; step < 3; step++) { - const handSize = state.players[cur].hand.length; - if (handSize > 0) { - assignments.push({ - playerIdx: cur, - handSize, - }); - } - cur = nextPlayer(cur); - } - - return assignments; -} - -function getUnseenCardPriority(card: Card, table: Card[]): number { - let priority = card.value; - - if (card.suit === 'denara' && card.value === 7) { - priority += 20000; - } else if (card.value === 7) { - priority += 12000; - } else if (card.suit === 'denara') { - priority += 6000; - } - - if (card.value === 6) priority += 900; - if (card.value === 1) priority += 700; - priority += primieraVal(card) * 25; - - if (canCapture(card, table)) { - priority += 800; - const captures = findCaptures(card, table); - let bestCapturePriority = 0; - for (const capture of captures) { - let capturePriority = capture.length * 140; - for (const capturedCard of capture) { - if (capturedCard.suit === 'denara') capturePriority += 90; - if (capturedCard.value === 7) capturePriority += 160; - } - if (capture.length === table.length) capturePriority += 500; - if (capturePriority > bestCapturePriority) bestCapturePriority = capturePriority; - } - priority += bestCapturePriority; - } - - return priority; -} - -function prioritizeUnseenCards(unseen: Card[], table: Card[]): Card[] { - return [...unseen].sort((left, right) => { - const priorityDelta = getUnseenCardPriority(right, table) - getUnseenCardPriority(left, table); - if (priorityDelta !== 0) return priorityDelta; - return left.id.localeCompare(right.id); - }); -} - -function rotateValues(values: T[], offset: number): T[] { - if (values.length <= 1) return values; - - const normalizedOffset = ((offset % values.length) + values.length) % values.length; - if (normalizedOffset === 0) return [...values]; - return [...values.slice(normalizedOffset), ...values.slice(0, normalizedOffset)]; -} - -function getAssignmentOrderVariants(assignments: SampleHandAssignment[]): SampleHandAssignment[][] { - if (assignments.length <= 1) return [assignments]; - - if (assignments.length === 2) { - return [ - assignments, - [assignments[1], assignments[0]], - ]; - } - - return [ - assignments, - [assignments[0], assignments[2], assignments[1]], - [assignments[1], assignments[0], assignments[2]], - [assignments[2], assignments[0], assignments[1]], - [assignments[1], assignments[2], assignments[0]], - [assignments[2], assignments[1], assignments[0]], - ]; -} - -function buildSampleAssignmentKey(assignments: SampleHandBucket[]): string { - return assignments - .map(({ assignment, cards }) => `${assignment.playerIdx}:${stableCardCollectionKey(cards)}`) - .join('|'); -} - -function combinationCount(n: number, k: number): number { - if (k < 0 || k > n) return 0; - if (k === 0 || k === n) return 1; - - let result = 1; - const boundedK = Math.min(k, n - k); - for (let index = 1; index <= boundedK; index++) { - result = (result * (n - boundedK + index)) / index; - if (result > MAX_EXACT_SAMPLE_ASSIGNMENTS) { - return result; +/** + * Returns a forced move (scopa or settebello capture) if one exists. + * Priority 1: any capture that empties the table (scopa). + * Priority 2: any capture that includes the settebello (denara_7). + */ +function checkForcedMove(legalMoves: AIMove[], table: Card[]): AIMove | null { + // 1. Scopa: capture that clears the table + for (const move of legalMoves) { + if (move.capture.length > 0) { + const tableAfter = table.filter(c => !move.capture.some(cap => cap.id === c.id)); + if (tableAfter.length === 0) return move; } } - - return result; + // 2. Settebello capture + for (const move of legalMoves) { + if (move.capture.some(c => c.id === 'denara_7')) return move; + } + return null; } -function getHiddenAssignmentCount(unseenCount: number, assignments: SampleHandAssignment[]): number { - let remaining = unseenCount; - let totalAssignments = 1; - - for (const assignment of assignments) { - totalAssignments *= combinationCount(remaining, assignment.handSize); - if (totalAssignments > MAX_EXACT_SAMPLE_ASSIGNMENTS) { - return totalAssignments; - } - remaining -= assignment.handSize; - } - - return totalAssignments; +function buildFallbackInference(tracker: CardTracker | undefined): CardInferenceEngine { + return new CardInferenceEngine(tracker ?? new CardTracker()); } -function assignBucketsToSample( - sample: GameState, - assignments: SampleHandBucket[], -): void { - for (const { assignment, cards } of assignments) { - sample.players[assignment.playerIdx].hand = cards.slice(); - } -} +// =========================================================================== +// BEGINNER — beatable, static priority + noise +// =========================================================================== -function buildExactSampleStates( - state: GameState, - prioritizedUnseen: Card[], - assignments: SampleHandAssignment[], - timing: SearchTimingContext, -): GameState[] { - const samples: GameState[] = []; - const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); - - const visitAssignment = (assignmentIndex: number, remainingCards: Card[]): boolean => { - if (samples.length >= MAX_EXACT_SAMPLE_ASSIGNMENTS) { - return true; - } - - if (assignmentIndex >= buckets.length) { - const sample = cloneState(state); - assignBucketsToSample(sample, buckets); - samples.push(sample); - return false; - } - - const bucket = buckets[assignmentIndex]; - const targetSize = bucket.assignment.handSize; - const chosenIndices: number[] = []; - - const chooseCards = (startIndex: number): boolean => { - if (chosenIndices.length === targetSize) { - bucket.cards = chosenIndices.map(index => remainingCards[index]); - const nextRemaining = remainingCards.filter((_, index) => !chosenIndices.includes(index)); - return visitAssignment(assignmentIndex + 1, nextRemaining); - } - - const needed = targetSize - chosenIndices.length; - const maxStart = remainingCards.length - needed; - for (let index = startIndex; index <= maxStart; index++) { - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - chosenIndices.push(index); - if (chooseCards(index + 1)) return true; - chosenIndices.pop(); - } - - return false; - }; - - return chooseCards(0); - }; - - visitAssignment(0, prioritizedUnseen); - return samples; -} - -function scoreUnseenCardTablePressure(card: Card, table: Card[]): number { - let score = 0; - const captures = findCaptures(card, table); - - for (const capture of captures) { - let captureScore = capture.length * 28; - if (capture.some(captured => captured.suit === 'denara')) captureScore += 22; - if (capture.some(captured => captured.value === 7)) captureScore += 28; - if (capture.length === table.length) captureScore += 140; - if (captureScore > score) score = captureScore; - } - - if (table.some(tableCard => tableCard.value === card.value)) score += 40; - if (card.suit === 'denara') score += 20; - if (card.value === 7) score += 28; - if (card.value >= 8 && captures.length === 0) score += 12; - - return score; -} - -function scoreSampleAssignmentCandidate( - card: Card, - assignment: SampleHandAssignment, - state: GameState, - rootPlayer: PlayerIndex, - rankResidue: RankResidueSnapshot | null, -): number { - const next = nextPlayer(rootPlayer); - const partner = partnerOf(rootPlayer); - const assignmentIsOpp = isOpponent(rootPlayer, assignment.playerIdx); - const playsNext = assignment.playerIdx === next; - const isPartner = assignment.playerIdx === partner; - const assignmentRole = getDealerRoleContext(state, assignment.playerIdx); - const pressureScore = scoreUnseenCardTablePressure(card, state.table); - let score = assignment.handSize * 2; - - if (playsNext) { - score += pressureScore * (assignmentIsOpp ? 2.5 : 1.6); - } else if (assignmentIsOpp) { - score += pressureScore * 1.25; - } else if (isPartner) { - score += pressureScore * 1.1; - } else { - score += pressureScore * 0.85; - } - - if (rankResidue) { - if (rankResidue.hasSingletonResidue[card.value]) { - score += playsNext ? 30 : 12; - } - if (rankResidue.hasPairedResidue[card.value]) { - score += assignmentRole.defendingDealerAdvantage ? 20 : 8; - } - } - - if (card.suit === 'denara') { - score += playsNext && assignmentIsOpp ? 26 : assignmentRole.onDealerSide ? 14 : 8; - } - if (card.value === 7) { - score += assignmentIsOpp ? 24 : 14; - } - if (card.value >= 8 && !canCapture(card, state.table)) { - score += assignmentRole.defendingDealerAdvantage ? 16 : 8; - } - - return score; -} - -function selectSampleBucketForCard( - card: Card, - buckets: SampleHandBucket[], - state: GameState, - rootPlayer: PlayerIndex, - rankResidue: RankResidueSnapshot | null, - sampleIndex: number, -): SampleHandBucket | undefined { - const availableBuckets = buckets.filter(bucket => bucket.cards.length < bucket.assignment.handSize); - if (availableBuckets.length === 0) return undefined; - - const rankedBuckets = availableBuckets - .map(bucket => ({ - bucket, - score: scoreSampleAssignmentCandidate(card, bucket.assignment, state, rootPlayer, rankResidue), - })) - .sort((left, right) => right.score - left.score); - - const topBucketCount = Math.min(2, rankedBuckets.length); - const selectedIndex = topBucketCount === 1 ? 0 : sampleIndex % topBucketCount; - return rankedBuckets[selectedIndex]?.bucket; -} - -function buildStratifiedSampleBuckets( +function beginnerMove( state: GameState, playerIdx: PlayerIndex, - prioritizedUnseen: Card[], - assignments: SampleHandAssignment[], - rankResidue: RankResidueSnapshot | null, - sampleIndex: number, + _tracker: CardTracker | undefined, rng: RandomSource, - timing: SearchTimingContext, -): SampleHandBucket[] { - const orderVariants = getAssignmentOrderVariants(assignments); - const assignmentOrder = orderVariants[sampleIndex % orderVariants.length]; - const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); - const bucketByPlayer = new Map(buckets.map(bucket => [bucket.assignment.playerIdx, bucket])); +): AIMove { + const legalMoves = getLegalMoves(state, playerIdx); - const focusedCards = prioritizedUnseen.slice(0, Math.min(MAX_FOCUSED_ASSIGNMENT_CARDS, prioritizedUnseen.length)); - const focusedCardIds = new Set(focusedCards.map(card => card.id)); - const remainingCards = shuffleArray( - prioritizedUnseen.filter(card => !focusedCardIds.has(card.id)), - rng, - ); + // 5% pure random + if (rng() < 0.05) { + return legalMoves[Math.floor(rng() * legalMoves.length)]; + } - for (let index = 0; index < focusedCards.length; index++) { - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - const preferredBucket = selectSampleBucketForCard( - focusedCards[index], - buckets, - state, - playerIdx, - rankResidue, - sampleIndex + index, - ); - if (preferredBucket) { - preferredBucket.cards.push(focusedCards[index]); + // Forced moves (scopa / settebello) — unconditional + const forced = checkForcedMove(legalMoves, state.table); + if (forced) return forced; + + let bestMove = legalMoves[0]; + let bestScore = -Infinity; + + for (const move of legalMoves) { + let score = 0; + const captured = move.capture; + + if (captured.length > 0) { + // 7s (primiera) + score += captured.filter(c => c.value === 7).length * 30; + // Coins + score += captured.filter(c => c.suit === 'denara').length * 20; + // Cards + score += captured.length * 5; + } else { + // Dump: penalise if it makes the table easy to scopa + const tableAfter = [...state.table, move.card]; + const tableSum = tableAfter.reduce((s, c) => s + c.value, 0); + if (tableSum <= 10 && tableAfter.length <= 3) { + score -= 80; + } + } + + // 20% noise band + score *= 0.8 + rng() * 0.4; + + if (score > bestScore) { + bestScore = score; + bestMove = move; } } - let remainingIndex = 0; - for (const assignment of assignmentOrder) { - const bucket = bucketByPlayer.get(assignment.playerIdx); - if (!bucket) continue; - while (bucket.cards.length < assignment.handSize && remainingIndex < remainingCards.length) { - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - bucket.cards.push(remainingCards[remainingIndex]); - remainingIndex++; - } - } - - return buckets; + return bestMove; } -function generateSamples( +// =========================================================================== +// ADVANCED — forced check → category states → phase-aware heuristics + inference +// =========================================================================== + +function advancedMove( + state: GameState, + playerIdx: PlayerIndex, + _tracker: CardTracker | undefined, + inference: CardInferenceEngine | null, +): AIMove { + const legalMoves = getLegalMoves(state, playerIdx); + + // Forced moves first + const forced = checkForcedMove(legalMoves, state.table); + if (forced) return forced; + + const myTeam = teamOf(playerIdx); + const categoryStates = getCategoryStates(state, myTeam); + const phase = getPhase(state); + + // Endgame: attempt deterministic solve + if (phase === 'endgame' && inference) { + const endgameMove = solveEndgame(state, playerIdx, inference, legalMoves); + if (endgameMove) return endgameMove; + } + + const next = nextPlayer(playerIdx); + const nextIsOpp = isOpponent(playerIdx, next); + const nextHandSize = state.players[next].hand.length; + const myHand = state.players[playerIdx].hand; + + // Spariglio analysis for dump moves + const dealerTeam = teamOf(state.dealer); + const isNonDealerTeam = myTeam !== dealerTeam; + const spariglioPotentials = rankDumpsBySpariglio(myHand, state.table, isNonDealerTeam); + const spariglioPotentialMap = new Map(spariglioPotentials.map(sp => [sp.card.id, sp])); + + const denariCloseness = categoryStates.denari.state === 'contested' ? categoryStates.denari.closeness : 0; + const carteCloseness = categoryStates.carte.state === 'contested' ? categoryStates.carte.closeness : 0; + const primieraCloseness = categoryStates.primiera.overallCloseness; + + let bestMove = legalMoves[0]; + let bestScore = -Infinity; + + for (const move of legalMoves) { + let score = 0; + const captured = move.capture; + + if (captured.length > 0) { + // Settebello + if (captured.some(c => c.id === 'denara_7')) score += 50; + // Coins weighted by closeness + score += captured.filter(c => c.suit === 'denara').length * Math.round(15 + denariCloseness * 25); + // 7s weighted by primiera closeness + score += captured.filter(c => c.value === 7).length * Math.round(20 + primieraCloseness * 40); + // Cards: use floor of 6 so captures stay attractive even when carte is secured + score += captured.length * Math.round(6 + carteCloseness * 8); + } else { + // Dump: spariglio-aware scoring + const sp = spariglioPotentialMap.get(move.card.id); + if (sp) { + score += sp.spariglioDelta * (isNonDealerTeam ? 15 : -15); + if (sp.isSpariglio3Card) score += isNonDealerTeam ? 20 : -20; + } + + // Damage minimization: penalise dumping contested-category cards. + // We cannot capture with them, so we're leaving them on the table for + // the opponent to take — the higher the contest, the bigger the risk. + const dumpCard = move.card; + if (dumpCard.suit === 'denara') { + // Giving away a coin card when the denari race is live + score -= Math.round(8 + denariCloseness * 18); + if (dumpCard.value === 7) score -= 18; // settebello is uniquely dangerous + } + if (dumpCard.value === 7) { + // Giving away a 7 hurts primiera regardless of suit + score -= Math.round(10 + primieraCloseness * 24); + } + // High primiera-value cards in contested suits (7=21, 6=18, 1=16, 5=15) + const dumpSuitEntry = categoryStates.primiera.perSuit[dumpCard.suit]; + if (dumpSuitEntry && dumpSuitEntry.state === 'contested') { + const primVal = PRIMIERA_VALUES[dumpCard.value] ?? 0; + if (primVal >= 15) { + score -= Math.round(primVal * 0.25 + dumpSuitEntry.closeness * 14); + } + } + // If the opponent likely holds the same value they can directly capture + // our dump card on the very next turn — compound the damage + if (inference && nextIsOpp && nextHandSize > 0) { + const projectedTable = [...state.table, dumpCard]; + const probCapture = inference.probabilityPlayerHasValue( + next, dumpCard.value, nextHandSize, myHand, projectedTable, + ); + if (probCapture > 0.05) { + score -= Math.round(probCapture * 20); + if (dumpCard.suit === 'denara') + score -= Math.round(probCapture * (14 + denariCloseness * 20)); + if (dumpCard.value === 7) + score -= Math.round(probCapture * (10 + primieraCloseness * 20)); + } + } + } + + // Anti-scopa: penalise leaving a table the opponent can immediately sweep + if (inference && nextIsOpp && nextHandSize > 0) { + const tableAfter = captured.length > 0 + ? state.table.filter(c => !captured.some(cap => cap.id === c.id)) + : [...state.table, move.card]; + if (tableAfter.length > 0) { + const tableSum = tableAfter.reduce((s, c) => s + c.value, 0); + const prob = inference.probabilityPlayerHasValue(next, tableSum, nextHandSize, myHand, tableAfter); + score -= prob * 60; + } + } + + if (score > bestScore) { + bestScore = score; + bestMove = move; + } + } + + return bestMove; +} + +// =========================================================================== +// MASTER — forced check → endgame solve → PIMC search +// =========================================================================== + +async function masterMove( state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, - count: number, - rng: RandomSource, + onProgress: ((progress: AIDecisionProgress) => void) | undefined, + profile: SearchProfile, + startedAt: number, timing: SearchTimingContext, -): GameState[] { - const myHand = state.players[playerIdx].hand; - const unseen = tracker - ? tracker.getUnseenCards(myHand, state.table) - : getUnseenWithoutTracker(state, playerIdx); - const assignments = createSampleHandAssignments(state, playerIdx); + rng: RandomSource, + inference: CardInferenceEngine | null, +): Promise { + const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0); + const legalMoves = getLegalMoves(state, playerIdx); - if (assignments.length === 0 || unseen.length === 0) { - return [cloneState(state)]; + if (legalMoves.length === 1) { + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: 1, + maxDepth: 1, + completedDepth: 1, + rootMoveCount: 1, + timedOut: false, + aspirationExpansions: 0, + }); + return legalMoves[0]; } - const prioritizedUnseen = prioritizeUnseenCards(unseen, state.table); - const rankResidue = getRankResidueSnapshot(tracker, myHand, state.table); - const hiddenAssignmentCount = getHiddenAssignmentCount(prioritizedUnseen.length, assignments); - if ( - prioritizedUnseen.length <= 8 - && hiddenAssignmentCount <= MAX_EXACT_SAMPLE_ASSIGNMENTS - ) { - return buildExactSampleStates(state, prioritizedUnseen, assignments, timing); + // Forced moves + const forced = checkForcedMove(legalMoves, state.table); + if (forced) { + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: 0, + maxDepth: 0, + completedDepth: 0, + rootMoveCount: legalMoves.length, + timedOut: false, + aspirationExpansions: 0, + }); + return forced; } - const samples: GameState[] = []; - const seenAssignments = new Set(); - const targetSamples = Math.max(1, count); - const maxAttempts = targetSamples * 4; - - for (let attempt = 0; attempt < maxAttempts && samples.length < targetSamples; attempt++) { - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - const sample = cloneState(state); - const sampleBuckets = buildStratifiedSampleBuckets( - state, - playerIdx, - prioritizedUnseen, - assignments, - rankResidue, - attempt, - rng, - timing, - ); - const sampleKey = buildSampleAssignmentKey(sampleBuckets); - if (seenAssignments.has(sampleKey)) continue; - - seenAssignments.add(sampleKey); - assignBucketsToSample(sample, sampleBuckets); - samples.push(sample); + // Deterministic endgame solve + if (inference) { + const endgameMove = solveEndgame(state, playerIdx, inference, legalMoves); + if (endgameMove) { + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: 0, + maxDepth: 0, + completedDepth: 0, + rootMoveCount: legalMoves.length, + timedOut: false, + aspirationExpansions: 0, + }); + return endgameMove; + } } - if (samples.length === 0) { - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - const fallbackSample = cloneState(state); - assignBucketsToSample( - fallbackSample, - buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng, timing), - ); - return [fallbackSample]; - } + const myTeam = teamOf(playerIdx); + const categoryStates = getCategoryStates(state, myTeam); + const parityState = analyzeTableParity(state.table); + const effectiveInference = inference ?? buildFallbackInference(tracker); - return samples; -} + await timing.yieldToHost(); -function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] { - return getUnseenCardsForEstimate( + const deadline = startedAt + profile.timeBudgetMs; + const remainingBudget = Math.max(100, deadline - timing.now()); + + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 0, 0, { + cardsRemaining, + sampleCount: profile.sampleCount, + maxDepth: profile.maxDepth, + completedDepth: 0, + rootMoveCount: legalMoves.length, + timedOut: false, + aspirationExpansions: 0, + }); + + const pimcOptions: Partial = { + determinizations: profile.sampleCount ?? 12, + timeBudgetMs: remainingBudget, + maxDepthMidgame: Math.min(profile.maxDepth ?? 5, 5), + maxDepthEndgame: 8, + stabilityWeight: 0.3, + timingSource: timing, + }; + + const results = pimcSearch( state, playerIdx, - state.players[playerIdx].hand, - state.table, - undefined, - ); -} - -function getUnseenCardsForEstimate( - state: GameState, - playerIdx: PlayerIndex, - myHand: Card[], - table: Card[], - tracker: CardTracker | undefined, -): Card[] { - if (tracker) { - return tracker.getUnseenCards(myHand, table); - } - - const known = new Set(); - for (const card of myHand) known.add(card.id); - for (const card of table) known.add(card.id); - for (const player of state.players) { - for (const card of player.pile) { - known.add(card.id); - } - } - - const deck = buildDeck(); - return deck.filter(card => !known.has(card.id)); -} - -function shuffleArray(arr: T[], rng: RandomSource = Math.random): T[] { - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } - return arr; -} - -function alphaBeta( - state: GameState, depth: number, alpha: number, beta: number, - myTeam: 0 | 1, rootPlayer: PlayerIndex, - phase: number, deadline: number, - timing: SearchTimingContext, - tracker: CardTracker | undefined, - transpositionTable: Map, - heuristics: SearchHeuristics, - ply: number, -): number { - const stateKey = buildSearchStateKey(state); - - if (depth === 0 || state.roundOver) { - const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); - transpositionTable.set(stateKey, { - key: stateKey, - bestMove: null, - bestMoveKey: null, - depth, - score, - bound: 'exact', - }); - return score; - } - - timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); - if (timing.now() > deadline) { - return evaluateFast(state, myTeam, phase, tracker, rootPlayer); - } - - const originalAlpha = alpha; - const originalBeta = beta; - const ttEntry = transpositionTable.get(stateKey); - if (ttEntry && ttEntry.depth >= depth) { - if (ttEntry.bound === 'exact') { - return ttEntry.score; - } - if (ttEntry.bound === 'lower') { - alpha = Math.max(alpha, ttEntry.score); - } else { - beta = Math.min(beta, ttEntry.score); - } - if (alpha >= beta) { - return ttEntry.score; - } - } - - const cur = state.currentPlayer; - const isMyTeam = teamOf(cur) === myTeam; - const moves = getLegalMoves(state, cur); - - if (moves.length === 0) { - const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); - transpositionTable.set(stateKey, { - key: stateKey, - bestMove: null, - bestMoveKey: null, - depth, - score, - bound: 'exact', - }); - return score; - } - - const orderedMoves = orderSearchMoves( - moves, - state, - cur, - rootPlayer, + legalMoves, + effectiveInference, + categoryStates, + parityState, + pimcOptions, + rng, tracker, - ttEntry?.bound === 'exact' ? getValidHashMove(moves, ttEntry) : undefined, - getValidHashMove(moves, ttEntry), - heuristics, - ply, ); - if (isMyTeam) { - let value = -Infinity; - let bestMove: AIMove | null = null; - let isFirstMove = true; - for (const move of orderedMoves) { - const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = searchPrincipalVariationChild( - result.nextState, - depth - 1, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply + 1, - isFirstMove, - true, - ); - if (child > value) { - value = child; - bestMove = move; - } - alpha = Math.max(alpha, value); - if (beta <= alpha) { - recordQuietCutoff(heuristics, move, ply, depth); - break; - } - isFirstMove = false; - } + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: profile.sampleCount, + maxDepth: profile.maxDepth, + completedDepth: profile.maxDepth, + rootMoveCount: legalMoves.length, + timedOut: timing.now() >= deadline, + aspirationExpansions: 0, + }); - transpositionTable.set(stateKey, { - key: stateKey, - bestMove, - bestMoveKey: bestMove ? moveKey(bestMove) : null, - depth, - score: value, - bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', - }); - return value; - } else { - let value = Infinity; - let bestMove: AIMove | null = null; - let isFirstMove = true; - for (const move of orderedMoves) { - const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = searchPrincipalVariationChild( - result.nextState, - depth - 1, - alpha, - beta, - myTeam, - rootPlayer, - phase, - deadline, - timing, - tracker, - transpositionTable, - heuristics, - ply + 1, - isFirstMove, - false, - ); - if (child < value) { - value = child; - bestMove = move; - } - beta = Math.min(beta, value); - if (beta <= alpha) { - recordQuietCutoff(heuristics, move, ply, depth); - break; - } - isFirstMove = false; - } - - transpositionTable.set(stateKey, { - key: stateKey, - bestMove, - bestMoveKey: bestMove ? moveKey(bestMove) : null, - depth, - score: value, - bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', - }); - return value; - } + return results[0]?.move ?? legalMoves[0]; } -interface TeamEvaluationSnapshot { - cards: number; - denari: number; - settebello: boolean; - primiera: number; - primieraSuits: number; - sevenSuits: number; - sevens: number; - sixes: number; - aces: number; - scope: number; - totalPoints: number; -} +// =========================================================================== +// Main entry point +// =========================================================================== -function buildTeamEvaluationSnapshot(state: GameState, team: 0 | 1): TeamEvaluationSnapshot { - const players = team === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; - const bestPrimieraBySuit: Partial> = {}; - const sevenSuits = new Set(); - let cards = 0; - let denari = 0; - let settebello = false; - let sevens = 0; - let sixes = 0; - let aces = 0; - let scope = 0; - - for (const player of players) { - scope += player.scope; - for (const card of player.pile) { - cards++; - if (card.suit === 'denara') { - denari++; - if (card.value === 7) settebello = true; - } - if (card.value === 7) { - sevens++; - sevenSuits.add(card.suit); - } - if (card.value === 6) sixes++; - if (card.value === 1) aces++; - - const primieraScore = PRIMIERA_VALUES[card.value] ?? 0; - if ((bestPrimieraBySuit[card.suit] ?? 0) < primieraScore) { - bestPrimieraBySuit[card.suit] = primieraScore; - } - } - } - - let primiera = 0; - let primieraSuits = 0; - for (const suit of SUITS) { - const suitScore = bestPrimieraBySuit[suit] ?? 0; - if (suitScore > 0) { - primiera += suitScore; - primieraSuits++; - } - } - - return { - cards, - denari, - settebello, - primiera, - primieraSuits, - sevenSuits: sevenSuits.size, - sevens, - sixes, - aces, - scope, - totalPoints: state.teamScores[team].totalPoints, - }; -} - -function scoreMajorityRace( - myValue: number, - oppValue: number, - target: number, - unitWeight: number, - thresholdBonus: number, -): number { - let score = (myValue - oppValue) * unitWeight; - - if (myValue >= target && oppValue < target) { - score += thresholdBonus; - } else if (oppValue >= target && myValue < target) { - score -= thresholdBonus; - } else { - const myDistance = Math.max(0, target - myValue); - const oppDistance = Math.max(0, target - oppValue); - score += (oppDistance - myDistance) * Math.round(unitWeight * 0.75); - } - - return score; -} - -function scoreCardsMajorityPosition( - myCards: number, - oppCards: number, - phase: number, -): number { - return scoreMajorityRace(myCards, oppCards, 21, Math.round(24 + phase * 22), 240); -} - -function getRoundScoringCardWeight(card: Card): number { - let weight = 18 + primieraVal(card) * 3; - - if (card.suit === 'denara') weight += 42; - if (card.value === 7) weight += 44; - if (card.suit === 'denara' && card.value === 7) weight += 120; - - return weight; -} - -function scorePendingTableOwnership(state: GameState, perspectiveTeam: 0 | 1): number { - if (state.table.length === 0 || state.lastCapturTeam === null) return 0; - - const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); - const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75; - const tableValue = state.table.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); - - return Math.round((state.lastCapturTeam === perspectiveTeam ? 1 : -1) * tableValue * urgency); -} - -function scoreObjectiveTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { - if (state.table.length === 0) return 0; - - const nextTeamSign = teamOf(state.currentPlayer) === perspectiveTeam ? 1 : -1; - const tableSum = sumCardValues(state.table); - const exposedDenari = state.table.filter(card => card.suit === 'denara').length; - const exposedSevens = state.table.filter(card => card.value === 7).length; - const exposedSettebello = state.table.some(card => card.suit === 'denara' && card.value === 7); - const shortTable = state.table.length <= 2 || tableSum <= 12; - let pressure = exposedDenari * 34 + exposedSevens * 42; - - if (exposedSettebello) pressure += 120; - if (shortTable) pressure += 36; - - return Math.round(nextTeamSign * pressure * (shortTable ? 1.2 : 0.8)); -} - -function scoreTableControlReserve(state: GameState, perspectiveTeam: 0 | 1): number { - if (state.table.length < 4) return 0; - - const tableSum = sumCardValues(state.table); - if (tableSum < 20) return 0; - - const nextTeam = teamOf(state.currentPlayer); - const exposedDenari = state.table.filter(card => card.suit === 'denara').length; - const exposedSevens = state.table.filter(card => card.value === 7).length; - let reserve = state.table.length * 22 + tableSum * 3; - - reserve -= exposedDenari * 10; - reserve -= exposedSevens * 12; - if (state.table.length >= 5) reserve += 24; - if (tableSum >= 24) reserve += 28; - - return Math.round((nextTeam === perspectiveTeam ? -0.35 : 0.55) * reserve); -} - -function scoreKnownImmediateCapturePressure(state: GameState, playerIdx: PlayerIndex): number { - if (state.table.length === 0 || state.players[playerIdx].hand.length === 0) return 0; - - let bestScore = 0; - for (const move of getLegalMoves(state, playerIdx)) { - if (move.capture.length === 0) continue; - - const captured = [move.card, ...move.capture]; - let moveScore = captured.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); - - if (move.capture.length === state.table.length) { - const isTerminalClear = state.players.every((player, index) => ( - index === playerIdx ? player.hand.length === 1 : player.hand.length === 0 - )); - moveScore += isTerminalClear ? 90 : 240; - } - - bestScore = Math.max(bestScore, moveScore); - } - - return bestScore; -} - -function getUpcomingTableExposureActors(state: GameState): Array<{ playerIdx: PlayerIndex; weight: number }> { - const actors: Array<{ playerIdx: PlayerIndex; weight: number }> = []; - let playerIdx = state.currentPlayer; - - for (const weight of UPCOMING_TABLE_EXPOSURE_WEIGHTS) { - actors.push({ playerIdx, weight }); - playerIdx = nextPlayer(playerIdx); - } - - return actors; -} - -function scoreCaptureOpportunity( - capturedCards: Card[], - playedValue: number, - tableSize: number, -): number { - let score = capturedCards.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); - - score += 18 + (PRIMIERA_VALUES[playedValue] ?? 0) * 2; - if (playedValue === 7) score += 48; - if (capturedCards.some(card => card.suit === 'denara')) score += 24; - if (capturedCards.some(card => card.value === 7)) score += 32; - if (capturedCards.some(card => card.suit === 'denara' && card.value === 7)) score += 160; - - if (capturedCards.length === tableSize) { - score += tableSize <= 3 ? 220 : 280; - } - - return score; -} - -function scoreProbableImmediateCapturePressure( +export async function chooseMove( state: GameState, playerIdx: PlayerIndex, - rootPlayer: PlayerIndex, - tracker: CardTracker | undefined, -): number { - if (state.table.length === 0) return 0; + difficulty: Difficulty = 'advanced', + tracker?: CardTracker, + onProgress?: (progress: AIDecisionProgress) => void, + options?: AIChooseMoveOptions, +): Promise { + const timing = createSearchTimingContext(options?.timingSource); + const startedAt = timing.now(); + const profile = getSearchProfile(state, difficulty, options?.profileOverride); + const rng = options?.rng ?? Math.random; + const inference = options?.inference ?? null; - const handSize = state.players[playerIdx].hand.length; - if (handSize <= 0) return 0; + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); - const observerHand = state.players[rootPlayer].hand; - let bestScore = 0; - - for (let value = 1; value <= 10; value++) { - const representativeCard = REPRESENTATIVE_CARD_BY_VALUE.get(value); - if (!representativeCard) continue; - - const captures = findCaptures(representativeCard, state.table); - if (captures.length === 0) continue; - - const probability = handLikelyHasValue( - value, - handSize, - state, - rootPlayer, - tracker, - observerHand, - state.table, - ); - if (probability <= 0) continue; - - let bestCaptureScore = 0; - for (const capture of captures) { - bestCaptureScore = Math.max( - bestCaptureScore, - scoreCaptureOpportunity(capture, value, state.table.length), - ); + switch (difficulty) { + case 'beginner': { + const move = beginnerMove(state, playerIdx, tracker, rng); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); + return move; } - - bestScore = Math.max(bestScore, Math.round(probability * bestCaptureScore)); + case 'advanced': { + const move = advancedMove(state, playerIdx, tracker, inference); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); + return move; + } + case 'master': + return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, rng, inference); } - - return bestScore; -} - -function scoreKnownTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { - if (state.table.length === 0) return 0; - - let score = 0; - for (const actor of getUpcomingTableExposureActors(state)) { - const capturePressure = scoreKnownImmediateCapturePressure(state, actor.playerIdx); - if (capturePressure === 0) continue; - - score += Math.round( - capturePressure - * actor.weight - * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), - ); - } - - return score; -} - -function scoreProbableTableExposure( - state: GameState, - perspectiveTeam: 0 | 1, - rootPlayer: PlayerIndex, - tracker: CardTracker | undefined, -): number { - if (state.table.length === 0) return 0; - - let score = 0; - for (const actor of getUpcomingTableExposureActors(state)) { - const actorScore = scoreProbableImmediateCapturePressure(state, actor.playerIdx, rootPlayer, tracker); - if (actorScore === 0) continue; - - score += Math.round( - actorScore - * actor.weight - * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), - ); - } - - return score; -} - -function scoreRootOpeningAnchorState( - state: GameState, - perspectiveTeam: 0 | 1, - rootPlayer: PlayerIndex, -): number { - if ( - teamOf(rootPlayer) !== perspectiveTeam - || state.table.length !== 1 - || teamOf(state.currentPlayer) === perspectiveTeam - ) { - return 0; - } - - const exposedCard = state.table[0]; - const rootHand = state.players[rootPlayer].hand; - const sameValueAnchors = countValueInHand(rootHand, exposedCard.value); - let score = 0; - - if (sameValueAnchors > 0) { - score += exposedCard.value >= 8 ? 240 : 88; - if (exposedCard.suit !== 'denara') score += 36; - } - - if (sameValueAnchors === 0 && exposedCard.value <= 3) score -= 220; - if (exposedCard.suit === 'denara') score -= 120; - if (exposedCard.value === 7) score -= 140; - - return score; -} - -function evaluateTeamPosition( - state: GameState, - perspectiveTeam: 0 | 1, - _phase: number, - tracker: CardTracker | undefined, - rootPlayer: PlayerIndex, - allowHiddenHands: boolean, -): number { - const opponentTeam = perspectiveTeam === 0 ? 1 : 0; - const mine = buildTeamEvaluationSnapshot(state, perspectiveTeam); - const opp = buildTeamEvaluationSnapshot(state, opponentTeam); - const phase = gamePhase(state); - const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260; - const matchPointCardsPressure = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 3.2 : 1; - - let score = 0; - - score += (mine.totalPoints - opp.totalPoints) * Math.round(matchWeight + phase * 40); - if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260; - if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260; - - score += Math.round(scoreCardsMajorityPosition(mine.cards, opp.cards, phase) * matchPointCardsPressure); - score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220); - - if (mine.settebello) score += 420; - if (opp.settebello) score -= 420; - - score += (mine.scope - opp.scope) * 390; - - score += (mine.primiera - opp.primiera) * Math.round(4.5 + phase * 3); - score += (mine.primieraSuits - opp.primieraSuits) * 124; - if (mine.primieraSuits === 4 && opp.primieraSuits < 4) score += 180; - if (opp.primieraSuits === 4 && mine.primieraSuits < 4) score -= 180; - score += (mine.sevenSuits - opp.sevenSuits) * 92; - score += (mine.sevens - opp.sevens) * 68; - score += (mine.sixes - opp.sixes) * 16; - score += (mine.aces - opp.aces) * 12; - - score += scorePendingTableOwnership(state, perspectiveTeam); - score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer); - score += scoreObjectiveTableExposure(state, perspectiveTeam); - score += scoreTableControlReserve(state, perspectiveTeam); - if ((mine.totalPoints >= 9 || opp.totalPoints >= 9) && state.table.length <= 2) { - score += Math.max(0, mine.cards - opp.cards) * 32; - score -= Math.max(0, opp.cards - mine.cards) * 32; - } - if (allowHiddenHands) { - score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam); - } - score += allowHiddenHands - ? scoreKnownTableExposure(state, perspectiveTeam) - : scoreProbableTableExposure(state, perspectiveTeam, rootPlayer, tracker); - - return score; -} - -/** Fast evaluation: avoids flatMap/filter at every leaf node */ -function evaluateFast( - state: GameState, - myTeam: 0 | 1, - _phase: number, - tracker: CardTracker | undefined, - rootPlayer: PlayerIndex, -): number { - return evaluateTeamPosition(state, myTeam, 0, tracker, rootPlayer, true); } diff --git a/src/game/ai.worker.ts b/src/game/ai.worker.ts index 79c4bd2..3259c8b 100644 --- a/src/game/ai.worker.ts +++ b/src/game/ai.worker.ts @@ -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; + confirmedHeld: Array<[PlayerIndex, string[]]>; +} + +export class CardInferenceEngine { + private cannotHave: Map>; + private confirmedHeld: Map>; + 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; + } +} diff --git a/src/game/types.ts b/src/game/types.ts index d3d6d15..1c6f9c1 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -73,3 +73,8 @@ export const PRIMIERA_VALUES: Record = { 9: 10, 10: 10, }; + +export interface AIMove { + card: Card; + capture: Card[]; +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 9a326cd..e6a0db4 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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]; From bfa5797f2b457a67e0ad418a840b9518b3aba8b1 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:33:22 +0200 Subject: [PATCH 2/7] ci: fix SIGPIPE in sdkmanager --licenses step (exit 141) --- .gitea/workflows/android-build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 0b32f04..ff8cc12 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -52,7 +52,9 @@ jobs: - name: Accept SDK licenses & install platform/build-tools run: | - yes | sdkmanager --licenses > /dev/null + # { yes || true } absorbs the SIGPIPE that 'yes' gets when sdkmanager closes + # stdin after accepting all prompts — avoids exit-code 141 under 'pipefail'. + { yes 2>/dev/null || true; } | sdkmanager --licenses sdkmanager \ "platforms;android-36" \ "build-tools;35.0.0" \ From ca75710285c3cb4f9deff1d48770d1fd83b74112 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:36:22 +0200 Subject: [PATCH 3/7] ci: use Node 22 (capacitor/cli requires >=22), refresh lock file --- .gitea/workflows/android-build.yml | 4 +- package-lock.json | 504 +++++++++++++++++++++++++++++ 2 files changed, 506 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index ff8cc12..06d5d96 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -25,10 +25,10 @@ jobs: java-version: '17' # ── 3. Node.js ─────────────────────────────────────────────────────────── - - name: Set up Node 20 + - name: Set up Node 22 uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm # ── 4. Android SDK ─────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index ea7ee6e..1ab8c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "phaser": "^3.87.0" }, "devDependencies": { + "tsx": "^4.19.2", "typescript": "^5.0.0", "vite": "^5.0.0" } @@ -358,6 +359,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -375,6 +393,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -392,6 +427,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1891,6 +1943,458 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", From e5c85981f84cc131847b326d2d40ae1d7a5ceaf1 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:38:55 +0200 Subject: [PATCH 4/7] ci: upgrade to JDK 21 (project targets JavaVersion.VERSION_21) --- .gitea/workflows/android-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 06d5d96..27df0da 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -18,11 +18,11 @@ jobs: uses: actions/checkout@v4 # ── 2. Java ────────────────────────────────────────────────────────────── - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17' + java-version: '21' # ── 3. Node.js ─────────────────────────────────────────────────────────── - name: Set up Node 22 From 052728c168ae8243c803db57545f9b2d4f601c44 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:44:15 +0200 Subject: [PATCH 5/7] ci: use github.token for package upload (respects permissions:packages:write), fix curl status capture --- .gitea/workflows/android-build.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 27df0da..d302609 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -96,7 +96,7 @@ jobs: # ── 9. Publish to Gitea generic package registry ───────────────────────── - name: Publish APKs to Gitea package registry env: - TOKEN: ${{ secrets.GITEA_TOKEN }} + TOKEN: ${{ github.token }} run: | set -euo pipefail VERSION="${{ github.run_number }}" @@ -105,13 +105,20 @@ jobs: upload() { local src="$1" dst_name="$2" echo "→ Uploading $dst_name (version $VERSION)…" - HTTP=$(curl --silent --show-error --write-out "%{http_code}" \ + # --output /dev/null discards the body; --write-out captures only the 3-digit code + HTTP=$(curl --silent --show-error \ + --output /dev/null --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" + # Retry once with verbose output for diagnostics + curl -v -X PUT \ + -H "Authorization: token $TOKEN" \ + --upload-file "$src" \ + "$BASE/$VERSION/$dst_name" 2>&1 | tail -30 || true exit 1 fi echo "✓ $dst_name → $BASE/$VERSION/$dst_name" From bfb0cc87cabfd58e77e4c6f387f27a4af4049367 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:48:12 +0200 Subject: [PATCH 6/7] ci: save APKs as workflow artifacts; best-effort package registry upload --- .gitea/workflows/android-build.yml | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index d302609..8453a01 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -93,10 +93,21 @@ jobs: working-directory: android run: ./gradlew assembleRelease --no-daemon - # ── 9. Publish to Gitea generic package registry ───────────────────────── + # ── 9. Upload APKs as workflow artifacts ───────────────────────────────── + - name: Upload APKs as artifacts + uses: actions/upload-artifact@v4 + with: + name: scopone-android-${{ github.run_number }} + path: | + android/app/build/outputs/apk/debug/app-debug.apk + android/app/build/outputs/apk/release/app-release-unsigned.apk + retention-days: 30 + + # ── 10. Publish to Gitea generic package registry (best-effort) ────────── - name: Publish APKs to Gitea package registry + continue-on-error: true env: - TOKEN: ${{ github.token }} + GITEA_ACTOR: ${{ gitea.actor }} run: | set -euo pipefail VERSION="${{ github.run_number }}" @@ -105,23 +116,18 @@ jobs: upload() { local src="$1" dst_name="$2" echo "→ Uploading $dst_name (version $VERSION)…" - # --output /dev/null discards the body; --write-out captures only the 3-digit code HTTP=$(curl --silent --show-error \ --output /dev/null --write-out "%{http_code}" \ -X PUT \ - -H "Authorization: token $TOKEN" \ + -u "gitea-actions:${{ secrets.GITEA_TOKEN }}" \ --upload-file "$src" \ "$BASE/$VERSION/$dst_name") - if [[ "$HTTP" != "20"* ]]; then - echo "✗ Upload failed — HTTP $HTTP" - # Retry once with verbose output for diagnostics - curl -v -X PUT \ - -H "Authorization: token $TOKEN" \ - --upload-file "$src" \ - "$BASE/$VERSION/$dst_name" 2>&1 | tail -30 || true - exit 1 + echo " HTTP $HTTP" + if [[ "$HTTP" == "20"* ]]; then + echo "✓ $dst_name → $BASE/$VERSION/$dst_name" + else + echo "⚠ Package registry upload skipped (HTTP $HTTP) — download APKs from the workflow artifacts above" fi - echo "✓ $dst_name → $BASE/$VERSION/$dst_name" } upload android/app/build/outputs/apk/debug/app-debug.apk \ @@ -129,6 +135,3 @@ jobs: upload android/app/build/outputs/apk/release/app-release-unsigned.apk \ app-release-unsigned.apk - - echo "" - echo "📦 Package index: $BASE/$VERSION/" From 641f678ddd2f711f2abf9927c7f3a62dc4d55558 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 24 May 2026 16:51:32 +0200 Subject: [PATCH 7/7] ci: downgrade upload-artifact to v3 (v4 not supported on Gitea/GHES) --- .gitea/workflows/android-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 8453a01..385242b 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -95,7 +95,7 @@ jobs: # ── 9. Upload APKs as workflow artifacts ───────────────────────────────── - name: Upload APKs as artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: scopone-android-${{ github.run_number }} path: |