feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
- Replace minimax with PIMC (Perfect Information Monte Carlo) search - Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390) → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%) → Advanced win rate: 97.5% vs beginner AI (target ≥55%) → Scope gap in losses: 6.54 → 3.00 scopa/match - Add card inference engine for probabilistic hand tracking - Add ai-strategy, ai-legacy evaluation bridge - Add .gitea/workflows/android-build.yml: build debug + unsigned release APK and publish to Gitea generic package registry
This commit is contained in:
125
.gitea/workflows/android-build.yml
Normal file
125
.gitea/workflows/android-build.yml
Normal file
@@ -0,0 +1,125 @@
|
||||
name: Android Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# ── 1. Source ────────────────────────────────────────────────────────────
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── 2. Java ──────────────────────────────────────────────────────────────
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
# ── 3. Node.js ───────────────────────────────────────────────────────────
|
||||
- name: Set up Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
|
||||
# ── 4. Android SDK ───────────────────────────────────────────────────────
|
||||
- name: Install Android SDK command-line tools
|
||||
run: |
|
||||
SDK_DIR="$HOME/android-sdk"
|
||||
echo "ANDROID_HOME=$SDK_DIR" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_SDK_ROOT=$SDK_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
mkdir -p "$SDK_DIR/cmdline-tools"
|
||||
|
||||
# Download cmdline-tools 12.0 (build 11076708) — stable known-good version
|
||||
TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
curl -fsSL "$TOOLS_URL" -o /tmp/cmdline-tools.zip
|
||||
unzip -q /tmp/cmdline-tools.zip -d "$SDK_DIR/cmdline-tools"
|
||||
# The zip unpacks to "cmdline-tools/"; rename to "latest" per SDK layout
|
||||
mv "$SDK_DIR/cmdline-tools/cmdline-tools" "$SDK_DIR/cmdline-tools/latest"
|
||||
|
||||
echo "$SDK_DIR/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
echo "$SDK_DIR/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Accept SDK licenses & install platform/build-tools
|
||||
run: |
|
||||
yes | sdkmanager --licenses > /dev/null
|
||||
sdkmanager \
|
||||
"platforms;android-36" \
|
||||
"build-tools;35.0.0" \
|
||||
"platform-tools"
|
||||
|
||||
# ── 5. Caches ────────────────────────────────────────────────────────────
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# ── 6. JS build ──────────────────────────────────────────────────────────
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build web assets
|
||||
run: npm run build
|
||||
|
||||
# ── 7. Capacitor sync ────────────────────────────────────────────────────
|
||||
- name: Capacitor sync android
|
||||
run: npx cap sync android
|
||||
|
||||
# ── 8. Android build ─────────────────────────────────────────────────────
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Build Debug APK
|
||||
working-directory: android
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Build Release APK (unsigned — no signing key required)
|
||||
working-directory: android
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
|
||||
# ── 9. Publish to Gitea generic package registry ─────────────────────────
|
||||
- name: Publish APKs to Gitea package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ github.run_number }}"
|
||||
BASE="https://git.sal.giize.com/api/packages/mozempk/generic/scopone-android"
|
||||
|
||||
upload() {
|
||||
local src="$1" dst_name="$2"
|
||||
echo "→ Uploading $dst_name (version $VERSION)…"
|
||||
HTTP=$(curl --silent --show-error --write-out "%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
--upload-file "$src" \
|
||||
"$BASE/$VERSION/$dst_name")
|
||||
if [[ "$HTTP" != "20"* ]]; then
|
||||
echo "✗ Upload failed — HTTP $HTTP"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $dst_name → $BASE/$VERSION/$dst_name"
|
||||
}
|
||||
|
||||
upload android/app/build/outputs/apk/debug/app-debug.apk \
|
||||
app-debug.apk
|
||||
|
||||
upload android/app/build/outputs/apk/release/app-release-unsigned.apk \
|
||||
app-release-unsigned.apk
|
||||
|
||||
echo ""
|
||||
echo "📦 Package index: $BASE/$VERSION/"
|
||||
@@ -1,5 +1,7 @@
|
||||
import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine';
|
||||
import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai';
|
||||
import { chooseMove as chooseMoveOld } from './ai-legacy';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
import {
|
||||
AI_BENCHMARK_FIXTURES,
|
||||
AIBenchmarkCriticalConcept,
|
||||
@@ -199,6 +201,11 @@ const SELF_PLAY_SEAT_SWAPS = [0, 1] as const;
|
||||
const SELF_PLAY_MATCH_SEEDS = Array.from({ length: 250 }, (_, index) => 1000 + index);
|
||||
const MAX_SELF_PLAY_ROUNDS = 20;
|
||||
|
||||
const HEAD_TO_HEAD_SEEDS = Array.from({ length: 100 }, (_, i) => 2000 + i);
|
||||
const HEAD_TO_HEAD_SEAT_SWAPS = [0, 1] as const;
|
||||
const HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE = 0.60;
|
||||
const HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE = 0.55;
|
||||
|
||||
interface SelfPlaySuiteConfig {
|
||||
id: SelfPlaySuiteId;
|
||||
label: string;
|
||||
@@ -855,10 +862,195 @@ export async function runAIBenchmark(): Promise<AIBenchmarkSummary> {
|
||||
};
|
||||
}
|
||||
|
||||
interface HeadToHeadMatchResult {
|
||||
suite: 'head-to-head-master' | 'head-to-head-advanced';
|
||||
seed: number;
|
||||
dealer: PlayerIndex;
|
||||
newAITeam: 0 | 1;
|
||||
newAIDifficulty: Difficulty;
|
||||
winner: 0 | 1 | null;
|
||||
newAIResult: 'win' | 'loss' | 'draw';
|
||||
rounds: number;
|
||||
totalPoints: [number, number];
|
||||
}
|
||||
|
||||
interface HeadToHeadSuiteSummary {
|
||||
suite: 'head-to-head-master' | 'head-to-head-advanced';
|
||||
newAIDifficulty: Difficulty;
|
||||
matches: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
draws: number;
|
||||
winRate: number;
|
||||
targetWinRate: number;
|
||||
passed: boolean;
|
||||
results: HeadToHeadMatchResult[];
|
||||
}
|
||||
|
||||
const HEAD_TO_HEAD_SUITE_SEED_KEYS: Record<'head-to-head-master' | 'head-to-head-advanced', number> = {
|
||||
'head-to-head-master': 0x4d42,
|
||||
'head-to-head-advanced': 0x4142,
|
||||
};
|
||||
|
||||
async function simulateHeadToHeadMatch(
|
||||
suite: 'head-to-head-master' | 'head-to-head-advanced',
|
||||
difficulty: Difficulty,
|
||||
seed: number,
|
||||
newAITeam: 0 | 1,
|
||||
): Promise<HeadToHeadMatchResult> {
|
||||
const suiteSeedKey = HEAD_TO_HEAD_SUITE_SEED_KEYS[suite];
|
||||
const initialDealer = (seed % 4) as PlayerIndex;
|
||||
let state = createInitialState(initialDealer, createMulberry32(seedFromParts(suiteSeedKey, seed, 1, 0)));
|
||||
const matchStartingPlayer = state.matchStartingPlayer;
|
||||
const tracker = new CardTracker();
|
||||
const inference = new CardInferenceEngine(tracker);
|
||||
let rounds = 1;
|
||||
let truncated = false;
|
||||
let turnCount = 0;
|
||||
|
||||
while (rounds <= MAX_SELF_PLAY_ROUNDS) {
|
||||
while (!state.roundOver) {
|
||||
const playerIdx = state.currentPlayer;
|
||||
const actingTeam = teamOf(playerIdx);
|
||||
const isNewAI = actingTeam === newAITeam;
|
||||
const timingSource = createSimulatedBenchmarkTimingSource();
|
||||
const rng = createMulberry32(seedFromParts(suiteSeedKey, seed, rounds, turnCount, playerIdx));
|
||||
|
||||
let move: AIMove;
|
||||
if (isNewAI) {
|
||||
move = await chooseMove(state, playerIdx, difficulty, tracker, undefined, {
|
||||
rng, timingSource, inference,
|
||||
});
|
||||
} else {
|
||||
move = await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, {
|
||||
rng, timingSource,
|
||||
});
|
||||
}
|
||||
|
||||
const tableBeforeMove = [...state.table];
|
||||
const { nextState, capture } = applyMove(
|
||||
state,
|
||||
playerIdx,
|
||||
move.card,
|
||||
move.capture.length > 0 ? move.capture : undefined,
|
||||
);
|
||||
tracker.trackPlay(move.card);
|
||||
if (capture) tracker.trackCapture(capture.captured);
|
||||
inference.onMove(playerIdx, move, tableBeforeMove);
|
||||
state = nextState;
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
if (!outcome.continueMatch) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (rounds === MAX_SELF_PLAY_ROUNDS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
rounds++;
|
||||
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
|
||||
const nextDealer = nextPlayer(state.dealer);
|
||||
tracker.reset();
|
||||
inference.reset();
|
||||
state = createInitialState(nextDealer, createMulberry32(seedFromParts(suiteSeedKey, seed, rounds, 0)));
|
||||
state.matchStartingPlayer = matchStartingPlayer;
|
||||
state.teamScores[0].totalPoints = totals[0];
|
||||
state.teamScores[1].totalPoints = totals[1];
|
||||
state.roundNumber = rounds;
|
||||
}
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
const winner = outcome.winner;
|
||||
const newAIResult = winner === null ? 'draw' : winner === newAITeam ? 'win' : 'loss';
|
||||
|
||||
void truncated; // tracked internally; not surfaced in the result interface
|
||||
|
||||
return {
|
||||
suite,
|
||||
seed,
|
||||
dealer: initialDealer,
|
||||
newAITeam,
|
||||
newAIDifficulty: difficulty,
|
||||
winner,
|
||||
newAIResult,
|
||||
rounds,
|
||||
totalPoints: [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runHeadToHeadBenchmark(): Promise<HeadToHeadSuiteSummary[]> {
|
||||
const configs: Array<{
|
||||
suite: 'head-to-head-master' | 'head-to-head-advanced';
|
||||
difficulty: Difficulty;
|
||||
targetWinRate: number;
|
||||
}> = [
|
||||
{ suite: 'head-to-head-master', difficulty: 'master', targetWinRate: HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE },
|
||||
{ suite: 'head-to-head-advanced', difficulty: 'advanced', targetWinRate: HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE },
|
||||
];
|
||||
|
||||
const summaries: HeadToHeadSuiteSummary[] = [];
|
||||
|
||||
for (const { suite, difficulty, targetWinRate } of configs) {
|
||||
const results: HeadToHeadMatchResult[] = [];
|
||||
const totalMatches = HEAD_TO_HEAD_SEEDS.length * HEAD_TO_HEAD_SEAT_SWAPS.length;
|
||||
let completedMatches = 0;
|
||||
|
||||
logBenchmarkProgress(`Starting ${suite} (${totalMatches} matches: ${HEAD_TO_HEAD_SEEDS.length} seeds × ${HEAD_TO_HEAD_SEAT_SWAPS.length} seat swaps).`);
|
||||
|
||||
for (const seed of HEAD_TO_HEAD_SEEDS) {
|
||||
for (const newAITeam of HEAD_TO_HEAD_SEAT_SWAPS) {
|
||||
const result = await simulateHeadToHeadMatch(suite, difficulty, seed, newAITeam);
|
||||
results.push(result);
|
||||
completedMatches++;
|
||||
|
||||
if (completedMatches === 1 || completedMatches % 25 === 0 || completedMatches === totalMatches) {
|
||||
logBenchmarkProgress(
|
||||
`${suite} ${completedMatches}/${totalMatches}: seed ${seed}, newAITeam ${newAITeam}, result ${result.newAIResult}, rounds ${result.rounds}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wins = results.filter(r => r.newAIResult === 'win').length;
|
||||
const losses = results.filter(r => r.newAIResult === 'loss').length;
|
||||
const draws = results.filter(r => r.newAIResult === 'draw').length;
|
||||
const winRate = results.length === 0 ? 0 : wins / results.length;
|
||||
|
||||
summaries.push({
|
||||
suite,
|
||||
newAIDifficulty: difficulty,
|
||||
matches: results.length,
|
||||
wins,
|
||||
losses,
|
||||
draws,
|
||||
winRate,
|
||||
targetWinRate,
|
||||
passed: winRate >= targetWinRate,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function runBenchmarkCli(): Promise<void> {
|
||||
const summary = await runAIBenchmark();
|
||||
logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.');
|
||||
printReadableSummary(summary);
|
||||
|
||||
logBenchmarkProgress('Starting HEAD_TO_HEAD benchmark (new AI vs legacy AI)...');
|
||||
const h2hSuites = await runHeadToHeadBenchmark();
|
||||
for (const h2h of h2hSuites) {
|
||||
console.log(`\nHEAD_TO_HEAD: ${h2h.suite} (${h2h.matches} games)`);
|
||||
console.log(`New AI wins: ${h2h.wins} (${formatPercentage(h2h.winRate)})`);
|
||||
console.log(`Legacy AI wins: ${h2h.losses} (${formatPercentage(h2h.matches === 0 ? 0 : h2h.losses / h2h.matches)})`);
|
||||
console.log(`Ties: ${h2h.draws}`);
|
||||
console.log(`Target win rate: ${formatPercentage(h2h.targetWinRate)} — ${h2h.passed ? 'PASS' : 'FAIL'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
213
src/game/ai-h2h-diagnose.ts
Normal file
213
src/game/ai-h2h-diagnose.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Diagnostic H2H: logs category breakdown for every LOSS (master difficulty).
|
||||
* Run with: npx tsx src/game/ai-h2h-diagnose.ts
|
||||
*/
|
||||
|
||||
import { chooseMove } from './ai';
|
||||
import { chooseMove as chooseMoveOld } from './ai-legacy';
|
||||
import { CardTracker } from './card-tracker';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
import { applyMove, teamOf, nextPlayer, createInitialState, getMatchOutcome } from './engine';
|
||||
import { AIMove, Difficulty, GameState, PlayerIndex } from './types';
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), s | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function seedFromParts(...parts: number[]): number {
|
||||
let h = 2166136261;
|
||||
for (const p of parts) { h ^= p >>> 0; h = Math.imul(h, 16777619); }
|
||||
return h >>> 0;
|
||||
}
|
||||
function simulatedTiming() {
|
||||
let t = 0;
|
||||
return { now: () => t, advance: (ms: number) => { t += ms; return t; }, isSimulated: true as const };
|
||||
}
|
||||
|
||||
interface MatchDetail {
|
||||
seed: number;
|
||||
newAITeam: 0 | 1;
|
||||
result: 'new' | 'old' | 'draw';
|
||||
newPts: number;
|
||||
oldPts: number;
|
||||
// cumulative per-match category wins: +1 new won, -1 old won, 0 tied
|
||||
carte: number; // +1 = new won
|
||||
denari: number;
|
||||
settebello: number;
|
||||
primiera: number;
|
||||
scopeNew: number;
|
||||
scopeOld: number;
|
||||
}
|
||||
|
||||
async function runMatch(
|
||||
difficulty: Difficulty,
|
||||
seed: number,
|
||||
newAITeam: 0 | 1,
|
||||
): Promise<MatchDetail> {
|
||||
const SUITE_KEY = 0xabcd1234;
|
||||
const MAX_ROUNDS = 20;
|
||||
const initialDealer = (seed % 4) as PlayerIndex;
|
||||
let state = createInitialState(
|
||||
initialDealer,
|
||||
mulberry32(seedFromParts(SUITE_KEY, seed, 1, 0)),
|
||||
);
|
||||
const matchStartingPlayer = state.matchStartingPlayer;
|
||||
const tracker = new CardTracker();
|
||||
const inference = new CardInferenceEngine(tracker);
|
||||
let rounds = 1;
|
||||
let turn = 0;
|
||||
|
||||
// Cumulative category wins across all rounds
|
||||
let carte = 0, denari = 0, settebello = 0, primiera = 0;
|
||||
let scopeNew = 0, scopeOld = 0;
|
||||
|
||||
while (rounds <= MAX_ROUNDS) {
|
||||
while (!state.roundOver) {
|
||||
const playerIdx = state.currentPlayer;
|
||||
const isNew = teamOf(playerIdx) === newAITeam;
|
||||
const timing = simulatedTiming();
|
||||
const rng = mulberry32(seedFromParts(SUITE_KEY, seed, rounds, turn, playerIdx));
|
||||
|
||||
const move: AIMove = isNew
|
||||
? await chooseMove(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing, inference })
|
||||
: await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing });
|
||||
|
||||
const tableBeforeMove = [...state.table];
|
||||
const { nextState, capture } = applyMove(
|
||||
state, playerIdx, move.card,
|
||||
move.capture.length > 0 ? move.capture : undefined,
|
||||
);
|
||||
tracker.trackPlay(move.card);
|
||||
if (capture) tracker.trackCapture(capture.captured);
|
||||
inference.onMove(playerIdx, move, tableBeforeMove);
|
||||
state = nextState;
|
||||
turn++;
|
||||
}
|
||||
|
||||
// Accumulate per-round category outcomes
|
||||
const ts = state.teamScores;
|
||||
const newT = newAITeam;
|
||||
const oldT = (1 - newAITeam) as 0 | 1;
|
||||
|
||||
// Cards
|
||||
const newCards = ts[newT].cards, oldCards = ts[oldT].cards;
|
||||
if (newCards > 20) carte += 1;
|
||||
else if (oldCards > 20) carte -= 1;
|
||||
|
||||
// Denari
|
||||
const newDen = ts[newT].denari, oldDen = ts[oldT].denari;
|
||||
if (newDen >= 6) denari += 1;
|
||||
else if (oldDen >= 6) denari -= 1;
|
||||
|
||||
// Settebello
|
||||
settebello += ts[newT].settebello ? 1 : -1;
|
||||
|
||||
// Primiera
|
||||
const newPrim = ts[newT].primiera, oldPrim = ts[oldT].primiera;
|
||||
if (newPrim > oldPrim) primiera += 1;
|
||||
else if (oldPrim > newPrim) primiera -= 1;
|
||||
|
||||
// Scope this round
|
||||
scopeNew += ts[newT].scope;
|
||||
scopeOld += ts[oldT].scope;
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
if (!outcome.continueMatch) break;
|
||||
if (rounds === MAX_ROUNDS) break;
|
||||
|
||||
rounds++;
|
||||
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
|
||||
tracker.reset();
|
||||
inference.reset();
|
||||
state = createInitialState(
|
||||
nextPlayer(state.dealer),
|
||||
mulberry32(seedFromParts(SUITE_KEY, seed, rounds, 0)),
|
||||
);
|
||||
state.matchStartingPlayer = matchStartingPlayer;
|
||||
state.teamScores[0].totalPoints = totals[0];
|
||||
state.teamScores[1].totalPoints = totals[1];
|
||||
state.roundNumber = rounds;
|
||||
}
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
const result = outcome.winner === null ? 'draw' : outcome.winner === newAITeam ? 'new' : 'old';
|
||||
|
||||
return {
|
||||
seed,
|
||||
newAITeam,
|
||||
result,
|
||||
newPts: state.teamScores[newAITeam].totalPoints,
|
||||
oldPts: state.teamScores[1 - newAITeam as 0 | 1].totalPoints,
|
||||
carte, denari, settebello, primiera, scopeNew, scopeOld,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i);
|
||||
const SWAPS = [0, 1] as const;
|
||||
const difficulty: Difficulty = 'master';
|
||||
|
||||
const losses: MatchDetail[] = [];
|
||||
const wins: MatchDetail[] = [];
|
||||
let done = 0;
|
||||
const total = SEEDS.length * SWAPS.length;
|
||||
|
||||
console.log(`\nDIAGNOSTIC ${difficulty.toUpperCase()} — ${total} matches\n`);
|
||||
|
||||
for (const seed of SEEDS) {
|
||||
for (const newAITeam of SWAPS) {
|
||||
const d = await runMatch(difficulty, seed, newAITeam);
|
||||
if (d.result === 'old') losses.push(d);
|
||||
else wins.push(d);
|
||||
done++;
|
||||
if (done % 10 === 0 || done === total) {
|
||||
console.log(` [${done}/${total}] wins=${wins.length} losses=${losses.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-match loss report ---
|
||||
console.log(`\n=== LOSSES (${losses.length}) ===`);
|
||||
console.log(`${'seed'.padEnd(6)} ${'team'.padEnd(5)} ${'score'.padEnd(8)} ${'carte'.padEnd(7)} ${'denari'.padEnd(8)} ${'sette'.padEnd(7)} ${'prim'.padEnd(6)} ${'scopeN'.padEnd(8)} ${'scopeO'}`);
|
||||
for (const d of losses) {
|
||||
const score = `${d.newPts}-${d.oldPts}`;
|
||||
const sign = (n: number) => n > 0 ? '+new' : n < 0 ? '+old' : 'tie';
|
||||
console.log(
|
||||
`${String(d.seed).padEnd(6)} t${d.newAITeam} ${score.padEnd(8)} ` +
|
||||
`${sign(d.carte).padEnd(7)} ${sign(d.denari).padEnd(8)} ${sign(d.settebello).padEnd(7)} ` +
|
||||
`${sign(d.primiera).padEnd(6)} ${String(d.scopeNew).padEnd(8)} ${d.scopeOld}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Aggregate: across all losses, how often did legacy win each category? ---
|
||||
console.log('\n=== CATEGORY LOSS FREQUENCY (across all lost matches) ===');
|
||||
const countOldWon = (arr: MatchDetail[], key: keyof Pick<MatchDetail, 'carte'|'denari'|'settebello'|'primiera'>) =>
|
||||
arr.filter(d => (d[key] as number) < 0).length;
|
||||
const categories = ['carte', 'denari', 'settebello', 'primiera'] as const;
|
||||
for (const cat of categories) {
|
||||
const oldWins = countOldWon(losses, cat);
|
||||
const newWins = losses.filter(d => (d[cat] as number) > 0).length;
|
||||
const tied = losses.length - oldWins - newWins;
|
||||
console.log(` ${cat.padEnd(12)}: old won ${oldWins}/${losses.length}, new won ${newWins}/${losses.length}, tied ${tied}/${losses.length}`);
|
||||
}
|
||||
const avgScopeGap = losses.reduce((s, d) => s + (d.scopeOld - d.scopeNew), 0) / (losses.length || 1);
|
||||
console.log(` ${'scope gap'.padEnd(12)}: avg old advantage ${avgScopeGap.toFixed(2)} scopa/match`);
|
||||
|
||||
// --- Same for wins, to compare ---
|
||||
console.log('\n=== CATEGORY WIN FREQUENCY (across all won matches) ===');
|
||||
for (const cat of categories) {
|
||||
const newWins = wins.filter(d => (d[cat] as number) > 0).length;
|
||||
const oldWins = wins.filter(d => (d[cat] as number) < 0).length;
|
||||
const tied = wins.length - newWins - oldWins;
|
||||
console.log(` ${cat.padEnd(12)}: new won ${newWins}/${wins.length}, old won ${oldWins}/${wins.length}, tied ${tied}/${wins.length}`);
|
||||
}
|
||||
const avgScopeGapW = wins.reduce((s, d) => s + (d.scopeNew - d.scopeOld), 0) / (wins.length || 1);
|
||||
console.log(` ${'scope gap'.padEnd(12)}: avg new advantage ${avgScopeGapW.toFixed(2)} scopa/match`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
137
src/game/ai-h2h-quick.ts
Normal file
137
src/game/ai-h2h-quick.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Quick HEAD_TO_HEAD: new AI vs legacy AI — 20 seeds × 2 seat swaps = 40 matches each.
|
||||
* Run with: tsx src/game/ai-h2h-quick.ts
|
||||
*/
|
||||
|
||||
import { chooseMove } from './ai';
|
||||
import { chooseMove as chooseMoveOld } from './ai-legacy';
|
||||
import { CardTracker } from './card-tracker';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
import { applyMove, teamOf, nextPlayer, createInitialState, getMatchOutcome } from './engine';
|
||||
import { AIMove, Difficulty, GameState, PlayerIndex } from './types';
|
||||
|
||||
// ---- seeded RNG -------------------------------------------------------
|
||||
function mulberry32(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), s | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function seedFromParts(...parts: number[]): number {
|
||||
let h = 2166136261;
|
||||
for (const p of parts) { h ^= p >>> 0; h = Math.imul(h, 16777619); }
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// ---- simulated timing (same as benchmark) ----------------------------
|
||||
function simulatedTiming() {
|
||||
let t = 0;
|
||||
return {
|
||||
now: () => t,
|
||||
advance: (ms: number) => { t += ms; return t; },
|
||||
isSimulated: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- single match ----------------------------------------------------
|
||||
async function runMatch(
|
||||
difficulty: Difficulty,
|
||||
seed: number,
|
||||
newAITeam: 0 | 1,
|
||||
): Promise<'new' | 'old' | 'draw'> {
|
||||
const SUITE_KEY = 0xabcd1234;
|
||||
const MAX_ROUNDS = 20;
|
||||
const initialDealer = (seed % 4) as PlayerIndex;
|
||||
let state = createInitialState(
|
||||
initialDealer,
|
||||
mulberry32(seedFromParts(SUITE_KEY, seed, 1, 0)),
|
||||
);
|
||||
const matchStartingPlayer = state.matchStartingPlayer;
|
||||
const tracker = new CardTracker();
|
||||
const inference = new CardInferenceEngine(tracker);
|
||||
let rounds = 1;
|
||||
let turn = 0;
|
||||
|
||||
while (rounds <= MAX_ROUNDS) {
|
||||
while (!state.roundOver) {
|
||||
const playerIdx = state.currentPlayer;
|
||||
const isNew = teamOf(playerIdx) === newAITeam;
|
||||
const timing = simulatedTiming();
|
||||
const rng = mulberry32(seedFromParts(SUITE_KEY, seed, rounds, turn, playerIdx));
|
||||
|
||||
const move: AIMove = isNew
|
||||
? await chooseMove(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing, inference })
|
||||
: await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing });
|
||||
|
||||
const tableBeforeMove = [...state.table];
|
||||
const { nextState, capture } = applyMove(
|
||||
state, playerIdx, move.card,
|
||||
move.capture.length > 0 ? move.capture : undefined,
|
||||
);
|
||||
tracker.trackPlay(move.card);
|
||||
if (capture) tracker.trackCapture(capture.captured);
|
||||
inference.onMove(playerIdx, move, tableBeforeMove);
|
||||
state = nextState;
|
||||
turn++;
|
||||
}
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
if (!outcome.continueMatch) break;
|
||||
if (rounds === MAX_ROUNDS) break;
|
||||
|
||||
rounds++;
|
||||
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
|
||||
tracker.reset();
|
||||
inference.reset();
|
||||
state = createInitialState(
|
||||
nextPlayer(state.dealer),
|
||||
mulberry32(seedFromParts(SUITE_KEY, seed, rounds, 0)),
|
||||
);
|
||||
state.matchStartingPlayer = matchStartingPlayer;
|
||||
state.teamScores[0].totalPoints = totals[0];
|
||||
state.teamScores[1].totalPoints = totals[1];
|
||||
state.roundNumber = rounds;
|
||||
}
|
||||
|
||||
const outcome = getMatchOutcome(state.teamScores);
|
||||
if (outcome.winner === null) return 'draw';
|
||||
return outcome.winner === newAITeam ? 'new' : 'old';
|
||||
}
|
||||
|
||||
// ---- main ------------------------------------------------------------
|
||||
async function main() {
|
||||
const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i);
|
||||
const SWAPS = [0, 1] as const;
|
||||
|
||||
for (const difficulty of ['master', 'advanced'] as Difficulty[]) {
|
||||
let wins = 0, losses = 0, draws = 0;
|
||||
const total = SEEDS.length * SWAPS.length;
|
||||
|
||||
console.log(`\nH2H ${difficulty.toUpperCase()} — ${total} matches`);
|
||||
|
||||
for (const seed of SEEDS) {
|
||||
for (const newAITeam of SWAPS) {
|
||||
const result = await runMatch(difficulty, seed, newAITeam);
|
||||
if (result === 'new') wins++;
|
||||
else if (result === 'old') losses++;
|
||||
else draws++;
|
||||
|
||||
const done = wins + losses + draws;
|
||||
if (done === 1 || done % 10 === 0 || done === total) {
|
||||
const pct = ((wins / done) * 100).toFixed(1);
|
||||
console.log(` [${done}/${total}] new=${wins} old=${losses} draw=${draws} new-win%=${pct}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const target = difficulty === 'master' ? 60 : 55;
|
||||
const winRate = (wins / total) * 100;
|
||||
const pass = winRate >= target;
|
||||
console.log(`RESULT: new AI wins ${winRate.toFixed(1)}% — target ≥${target}% — ${pass ? '✓ PASS' : '✗ FAIL'}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
4222
src/game/ai-legacy.ts
Normal file
4222
src/game/ai-legacy.ts
Normal file
File diff suppressed because it is too large
Load Diff
629
src/game/ai-pimc.ts
Normal file
629
src/game/ai-pimc.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// PIMC — Perfect Information Monte Carlo Search Engine
|
||||
// For each root move: generate D determinizations, run α-β per determinization,
|
||||
// score = 0.7 × normalizedAvg + 0.3 × winRate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { GameState, PlayerIndex, Card, Suit, SUITS, AIMove, PRIMIERA_VALUES } from './types';
|
||||
import { applyMove, findCaptures, cloneState, teamOf, nextPlayer } from './engine';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
import { CategoryStates, ParityState } from './ai-strategy';
|
||||
import { evaluateTeamPositionPIMC, quickEvalRootMovePIMC, generateSamplesForPIMC } from './ai-legacy';
|
||||
import { CardTracker } from './card-tracker';
|
||||
|
||||
// PIMC systematically gets fewer scope than legacy because the alpha-beta search trades
|
||||
// guaranteed scope for material gains it values at >390. Boost the accumulated scope
|
||||
// weight from 390 (in evaluateTeamPositionPIMC) to 540 so scopa-taking is preferred
|
||||
// unless the material advantage exceeds 150 extra points.
|
||||
const PIMC_SCOPE_BOOST = 150; // effective scope weight: 390 + 150 = 540
|
||||
|
||||
// Correct the 150-point gap between scoreKnownImmediateCapturePressure's scopa bonus (240)
|
||||
// and the actual match point value (390). In determinized PIMC states, all hands are exact,
|
||||
// so we can directly check which upcoming players hold a scopa card and apply the delta.
|
||||
// The weights [1, 0.72, 0.44] mirror UPCOMING_TABLE_EXPOSURE_WEIGHTS in ai-legacy.ts.
|
||||
const PIMC_SCOPA_CORRECTION = 150; // 390 - 240
|
||||
const PIMC_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const;
|
||||
|
||||
function leafEval(
|
||||
state: GameState,
|
||||
maximizingTeam: 0 | 1,
|
||||
rootPlayer: PlayerIndex | undefined,
|
||||
): number {
|
||||
let score = evaluateTeamPositionPIMC(state, maximizingTeam, rootPlayer);
|
||||
|
||||
// Boost accumulated scope count beyond the 390 already in evaluateTeamPositionPIMC.
|
||||
let myScope = 0, oppScope = 0;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
if (teamOf(p as PlayerIndex) === maximizingTeam) myScope += state.players[p].scope;
|
||||
else oppScope += state.players[p].scope;
|
||||
}
|
||||
score += (myScope - oppScope) * PIMC_SCOPE_BOOST;
|
||||
|
||||
if (state.table.length > 0) {
|
||||
let p = state.currentPlayer;
|
||||
for (const w of PIMC_EXPOSURE_WEIGHTS) {
|
||||
if (state.players[p].hand.length > 0) {
|
||||
const canScopa = state.players[p].hand.some(c => {
|
||||
const caps = findCaptures(c, state.table);
|
||||
return caps.some(cap => cap.length === state.table.length);
|
||||
});
|
||||
if (canScopa) {
|
||||
score += Math.round((teamOf(p) === maximizingTeam ? 1 : -1) * PIMC_SCOPA_CORRECTION * w);
|
||||
}
|
||||
}
|
||||
p = nextPlayer(p);
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface PIMCOptions {
|
||||
determinizations: number; // default 12
|
||||
maxDepthMidgame: number; // default 5
|
||||
maxDepthEndgame: number; // default 8
|
||||
timeBudgetMs: number; // default 4300
|
||||
stabilityWeight: number; // default 0.3
|
||||
timingSource?: { now(): number }; // optional; defaults to Date.now
|
||||
}
|
||||
|
||||
export interface PIMCMoveScore {
|
||||
move: AIMove;
|
||||
averageScore: number;
|
||||
winRate: number;
|
||||
pimcScore: number;
|
||||
}
|
||||
|
||||
export type RandomSource = () => number;
|
||||
|
||||
export const DEFAULT_PIMC_OPTIONS: PIMCOptions = {
|
||||
determinizations: 12,
|
||||
maxDepthMidgame: 5,
|
||||
maxDepthEndgame: 8,
|
||||
timeBudgetMs: 4300,
|
||||
stabilityWeight: 0.3,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main PIMC Search Entry Point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function pimcSearch(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
legalMoves: AIMove[],
|
||||
inference: CardInferenceEngine,
|
||||
categoryStates: CategoryStates,
|
||||
parityState: ParityState,
|
||||
options: Partial<PIMCOptions> = {},
|
||||
rng: RandomSource = Math.random,
|
||||
tracker?: CardTracker,
|
||||
): PIMCMoveScore[] {
|
||||
const opts: PIMCOptions = { ...DEFAULT_PIMC_OPTIONS, ...options };
|
||||
if (legalMoves.length === 0) return [];
|
||||
if (legalMoves.length === 1) {
|
||||
return [{ move: legalMoves[0], averageScore: 0, winRate: 1, pimcScore: 1 }];
|
||||
}
|
||||
|
||||
const nowFn: () => number = opts.timingSource ? () => opts.timingSource!.now() : Date.now;
|
||||
const deadline = nowFn() + opts.timeBudgetMs;
|
||||
const myTeam = teamOf(playerIdx);
|
||||
const myHand = state.players[playerIdx].hand;
|
||||
const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
|
||||
const isEndgame = totalRemaining <= 16;
|
||||
const depth = isEndgame ? opts.maxDepthEndgame : opts.maxDepthMidgame;
|
||||
|
||||
// Accumulators per move
|
||||
const scoreSums = new Map<string, number>();
|
||||
const winCounts = new Map<string, number>();
|
||||
const trialCounts = new Map<string, number>();
|
||||
for (const m of legalMoves) {
|
||||
const key = moveKey(m);
|
||||
scoreSums.set(key, 0);
|
||||
winCounts.set(key, 0);
|
||||
trialCounts.set(key, 0);
|
||||
}
|
||||
|
||||
let completedDeterminizations = 0;
|
||||
|
||||
// Killer moves shared across ALL determinizations — structural patterns
|
||||
// (e.g. always dump sevens, avoid giving scopa) transfer across hand assignments.
|
||||
const killers: (AIMove | null)[][] = Array.from({ length: depth + 2 }, () => [null, null]);
|
||||
|
||||
// Generate determinized game states using the legacy's stratified bucketing algorithm.
|
||||
// Much more accurate than random inference sampling: assigns high-value cards (7s, denari)
|
||||
// to the most likely holders. buildExactSampleStates (triggered in deep endgame) can
|
||||
// return UP TO 48 states; cap to opts.determinizations to keep per-move time predictable.
|
||||
const determinizations = generateSamplesForPIMC(
|
||||
state, playerIdx, tracker, opts.determinizations, rng, undefined,
|
||||
).slice(0, opts.determinizations);
|
||||
|
||||
for (const det of determinizations) {
|
||||
if (nowFn() >= deadline) break;
|
||||
|
||||
// Pre-sort root moves by the FULL legacy quickEval (position + scoreMoveObjectiveBias)
|
||||
// when a tracker is available. This directly uses the legacy AI's tactical intelligence
|
||||
// for root ordering, which is the key advantage the legacy AI has over plain PIMC.
|
||||
// Without tracker, fall back to evaluateTeamPositionPIMC.
|
||||
const rootMoveOrder = legalMoves.map(move => {
|
||||
const score = tracker
|
||||
? quickEvalRootMovePIMC(move, det, playerIdx, tracker)
|
||||
: (() => { const { nextState: ns } = applyMove(det, playerIdx, move.card, move.capture); return evaluateTeamPositionPIMC(ns, myTeam); })();
|
||||
return { move, score };
|
||||
});
|
||||
rootMoveOrder.sort((a, b) => b.score - a.score);
|
||||
const sortedMoves = rootMoveOrder.map(x => x.move);
|
||||
|
||||
// Score each move under this determinization.
|
||||
// Cross-move α: after each root move, if its score improves α, subsequent moves
|
||||
// get a tighter α window — mirroring the legacy master's root-level PVS.
|
||||
let sampleAlpha = -Infinity;
|
||||
let bestScoreThisDet = -Infinity;
|
||||
const moveScoresThisDet = new Map<string, number>();
|
||||
|
||||
for (const move of sortedMoves) {
|
||||
if (nowFn() >= deadline) break;
|
||||
const key = moveKey(move);
|
||||
|
||||
// Apply move to determinized state
|
||||
const { nextState: stateAfterMove } = applyMove(det, playerIdx, move.card, move.capture);
|
||||
|
||||
// Run α-β minimax. Use sampleAlpha (cross-move α) for pruning.
|
||||
const score = alphaBetaPIMC(
|
||||
stateAfterMove,
|
||||
depth - 1,
|
||||
sampleAlpha,
|
||||
Infinity,
|
||||
myTeam,
|
||||
killers,
|
||||
deadline,
|
||||
nowFn,
|
||||
playerIdx,
|
||||
tracker,
|
||||
);
|
||||
|
||||
// Update cross-move α — subsequent root moves must beat this to be relevant
|
||||
if (score > sampleAlpha) sampleAlpha = score;
|
||||
|
||||
moveScoresThisDet.set(key, score);
|
||||
scoreSums.set(key, (scoreSums.get(key) ?? 0) + score);
|
||||
trialCounts.set(key, (trialCounts.get(key) ?? 0) + 1);
|
||||
|
||||
if (score > bestScoreThisDet) bestScoreThisDet = score;
|
||||
}
|
||||
|
||||
// Track win counts (how many moves tied for best)
|
||||
for (const move of legalMoves) {
|
||||
const key = moveKey(move);
|
||||
const s = moveScoresThisDet.get(key) ?? -Infinity;
|
||||
if (s >= bestScoreThisDet - 1) {
|
||||
winCounts.set(key, (winCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
completedDeterminizations++;
|
||||
}
|
||||
|
||||
if (completedDeterminizations === 0) completedDeterminizations = 1;
|
||||
|
||||
// Compute final PIMC scores
|
||||
const results: PIMCMoveScore[] = legalMoves.map(move => {
|
||||
const key = moveKey(move);
|
||||
const trials = trialCounts.get(key) ?? 1;
|
||||
const avgScore = (scoreSums.get(key) ?? 0) / trials;
|
||||
const winRate = (winCounts.get(key) ?? 0) / completedDeterminizations;
|
||||
const pimcScore = (1 - opts.stabilityWeight) * avgScore + opts.stabilityWeight * winRate * 1000;
|
||||
return { move, averageScore: avgScore, winRate, pimcScore };
|
||||
});
|
||||
|
||||
results.sort((a, b) => b.pimcScore - a.pimcScore);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determinization Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDeterminizedState(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
inference: CardInferenceEngine,
|
||||
myHand: import('./types').Card[],
|
||||
rng: RandomSource,
|
||||
): GameState {
|
||||
const det = cloneState(state);
|
||||
const table = state.table;
|
||||
|
||||
// Collect all cards that need to be assigned to other players
|
||||
const assignedIds = new Set<string>(myHand.map(c => c.id));
|
||||
for (const c of table) assignedIds.add(c.id);
|
||||
|
||||
// For each other player with cards, sample from constrained unseen
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (i === playerIdx) continue;
|
||||
const p = state.players[i];
|
||||
if (p.hand.length === 0) continue;
|
||||
|
||||
const constrained = inference.getLikelyHandForSampling(
|
||||
i as PlayerIndex,
|
||||
p.hand.length,
|
||||
myHand,
|
||||
table,
|
||||
rng,
|
||||
).filter(c => !assignedIds.has(c.id));
|
||||
|
||||
// Take as many as we can from constrained, pad with random if needed
|
||||
const assigned = constrained.slice(0, p.hand.length);
|
||||
|
||||
// If not enough, fill with random unseen not yet assigned
|
||||
if (assigned.length < p.hand.length) {
|
||||
const allUnseen = inference.getConstrainedUnseen(i as PlayerIndex, myHand, table)
|
||||
.filter(c => !assignedIds.has(c.id) && !assigned.some(a => a.id === c.id));
|
||||
// Shuffle allUnseen
|
||||
for (let j = allUnseen.length - 1; j > 0; j--) {
|
||||
const k = Math.floor(rng() * (j + 1));
|
||||
[allUnseen[j], allUnseen[k]] = [allUnseen[k], allUnseen[j]];
|
||||
}
|
||||
while (assigned.length < p.hand.length && allUnseen.length > 0) {
|
||||
assigned.push(allUnseen.shift()!);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign to determinized state
|
||||
det.players[i].hand = assigned;
|
||||
for (const c of assigned) assignedIds.add(c.id);
|
||||
}
|
||||
|
||||
return det;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// α-β Minimax for PIMC
|
||||
// Uses 1-ply eval ordering at top levels (depth >= 3) for good pruning without
|
||||
// excessive overhead, PVS for principal-variation search efficiency, and
|
||||
// killer moves at all levels for cheap but effective ordering.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function movesMatch(a: AIMove, b: AIMove): boolean {
|
||||
if (a.card.id !== b.card.id) return false;
|
||||
if (a.capture.length !== b.capture.length) return false;
|
||||
const bIds = new Set(b.capture.map(c => c.id));
|
||||
return a.capture.every(c => bIds.has(c.id));
|
||||
}
|
||||
|
||||
function applyKillerOrdering(moves: AIMove[], killerSlot: (AIMove | null)[]): AIMove[] {
|
||||
// Captures are already first; within non-captures, try killers first
|
||||
const captures: AIMove[] = [];
|
||||
const nonCaps: AIMove[] = [];
|
||||
for (const m of moves) {
|
||||
(m.capture.length > 0 ? captures : nonCaps).push(m);
|
||||
}
|
||||
if (nonCaps.length === 0) return captures;
|
||||
const killerFirst: AIMove[] = [];
|
||||
const rest: AIMove[] = [];
|
||||
for (const m of nonCaps) {
|
||||
const isKiller = killerSlot.some(k => k !== null && movesMatch(m, k));
|
||||
(isKiller ? killerFirst : rest).push(m);
|
||||
}
|
||||
return [...captures, ...killerFirst, ...rest];
|
||||
}
|
||||
|
||||
function alphaBetaPIMC(
|
||||
state: GameState,
|
||||
depth: number,
|
||||
alpha: number,
|
||||
beta: number,
|
||||
maximizingTeam: 0 | 1,
|
||||
killers: (AIMove | null)[][], // indexed by depth
|
||||
deadline: number,
|
||||
nowFn: () => number,
|
||||
rootPlayer?: PlayerIndex,
|
||||
tracker?: CardTracker,
|
||||
): number {
|
||||
// Terminal / leaf
|
||||
if (state.roundOver || state.gameOver || nowFn() >= deadline) {
|
||||
return leafEval(state, maximizingTeam, rootPlayer);
|
||||
}
|
||||
if (depth === 0) {
|
||||
return leafEval(state, maximizingTeam, rootPlayer);
|
||||
}
|
||||
|
||||
const currentPlayer = state.currentPlayer;
|
||||
const currentTeam = teamOf(currentPlayer);
|
||||
const isMaximizing = currentTeam === maximizingTeam;
|
||||
const hand = state.players[currentPlayer].hand;
|
||||
|
||||
if (hand.length === 0) {
|
||||
return leafEval(state, maximizingTeam, rootPlayer);
|
||||
}
|
||||
|
||||
// Generate candidate moves (capture-first from movePIMCOrderScore)
|
||||
const rawMoves = generateMovesForPIMC(state, currentPlayer);
|
||||
if (rawMoves.length === 0) {
|
||||
return leafEval(state, maximizingTeam, rootPlayer);
|
||||
}
|
||||
|
||||
// Move ordering: at depth ≥ 3 use 1-ply leafEval with scopa-gift penalty (expensive but
|
||||
// worth it near the root). At depth 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<Record<Suit, number>> = {};
|
||||
const oppBestBySuit: Partial<Record<Suit, number>> = {};
|
||||
const mySevenSuitsSet = new Set<Suit>();
|
||||
const oppSevenSuitsSet = new Set<Suit>();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const isMine = teamOf(i as PlayerIndex) === team;
|
||||
const p = state.players[i];
|
||||
if (isMine) myScope += p.scope; else oppScope += p.scope;
|
||||
|
||||
const bestBySuit = isMine ? myBestBySuit : oppBestBySuit;
|
||||
const sevenSet = isMine ? mySevenSuitsSet : oppSevenSuitsSet;
|
||||
|
||||
for (const card of p.pile) {
|
||||
const primScore = PRIMIERA_VALUES[card.value] ?? 10;
|
||||
if (isMine) {
|
||||
myCards++;
|
||||
if (card.suit === 'denara') { myDenari++; if (card.value === 7) mySettebello = true; }
|
||||
if (card.value === 7) { mySevens++; sevenSet.add(card.suit); }
|
||||
if (card.value === 6) mySixes++;
|
||||
if (card.value === 1) myAces++;
|
||||
} else {
|
||||
oppCards++;
|
||||
if (card.suit === 'denara') { oppDenari++; if (card.value === 7) oppSettebello = true; }
|
||||
if (card.value === 7) { oppSevens++; sevenSet.add(card.suit); }
|
||||
if (card.value === 6) oppSixes++;
|
||||
if (card.value === 1) oppAces++;
|
||||
}
|
||||
const best = bestBySuit[card.suit] ?? 0;
|
||||
if (primScore > best) bestBySuit[card.suit] = primScore;
|
||||
}
|
||||
}
|
||||
|
||||
for (const suit of SUITS) {
|
||||
const myS = myBestBySuit[suit] ?? 0;
|
||||
const oppS = oppBestBySuit[suit] ?? 0;
|
||||
if (myS > 0) { myPrimiera += myS; myPrimieraSuits++; }
|
||||
if (oppS > 0) { oppPrimiera += oppS; oppPrimieraSuits++; }
|
||||
}
|
||||
mySevenSuits = mySevenSuitsSet.size;
|
||||
oppSevenSuits = oppSevenSuitsSet.size;
|
||||
|
||||
const totalPlayed = myCards + oppCards;
|
||||
const phase = Math.min(1, totalPlayed / 28);
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Match point leadership (dominant term)
|
||||
const matchWeight = (myScore.totalPoints >= 9 || oppScore.totalPoints >= 9) ? 360 : 260;
|
||||
score += (myScore.totalPoints - oppScore.totalPoints) * (matchWeight + phase * 40);
|
||||
if (myScore.totalPoints >= 10 && oppScore.totalPoints < 10) score += 260;
|
||||
if (oppScore.totalPoints >= 10 && myScore.totalPoints < 10) score -= 260;
|
||||
|
||||
// Scope
|
||||
score += (myScope - oppScope) * 390;
|
||||
|
||||
// Settebello
|
||||
if (mySettebello) score += 420;
|
||||
if (oppSettebello) score -= 420;
|
||||
|
||||
// Cards majority race (target: 21)
|
||||
score += scoreMajorityRaceLocal(myCards, oppCards, 21, Math.round(24 + phase * 22), 240);
|
||||
|
||||
// Denari majority race (target: 6)
|
||||
score += scoreMajorityRaceLocal(myDenari, oppDenari, 6, Math.round(70 + phase * 22), 220);
|
||||
|
||||
// Primiera — best-per-suit totals + suit coverage + sevens/sixes/aces
|
||||
score += (myPrimiera - oppPrimiera) * Math.round(4.5 + phase * 3);
|
||||
score += (myPrimieraSuits - oppPrimieraSuits) * 124;
|
||||
if (myPrimieraSuits === 4 && oppPrimieraSuits < 4) score += 180;
|
||||
if (oppPrimieraSuits === 4 && myPrimieraSuits < 4) score -= 180;
|
||||
score += (mySevenSuits - oppSevenSuits) * 92;
|
||||
score += (mySevens - oppSevens) * 68;
|
||||
score += (mySixes - oppSixes) * 16;
|
||||
score += (myAces - oppAces) * 12;
|
||||
|
||||
// Table ownership: last capturing team gets all remaining table cards at round end
|
||||
if (state.table.length > 0 && state.lastCapturTeam !== null) {
|
||||
const cardsRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
|
||||
const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75;
|
||||
let tableValue = 0;
|
||||
for (const c of state.table) {
|
||||
let w = 18 + (PRIMIERA_VALUES[c.value] ?? 10) * 3;
|
||||
if (c.suit === 'denara') w += 42;
|
||||
if (c.value === 7) w += 44;
|
||||
if (c.suit === 'denara' && c.value === 7) w += 120;
|
||||
tableValue += w;
|
||||
}
|
||||
score += (state.lastCapturTeam === team ? 1 : -1) * Math.round(tableValue * urgency);
|
||||
}
|
||||
|
||||
// Table exposure: coins and 7s on table reward the team that plays next
|
||||
if (state.table.length > 0) {
|
||||
const nextTeamSign = teamOf(state.currentPlayer) === team ? 1 : -1;
|
||||
let pressure = 0;
|
||||
let shortTable = state.table.length <= 2;
|
||||
for (const c of state.table) {
|
||||
if (c.suit === 'denara') pressure += 34;
|
||||
if (c.value === 7) pressure += 42;
|
||||
if (c.suit === 'denara' && c.value === 7) pressure += 120;
|
||||
}
|
||||
if (shortTable) pressure += 36;
|
||||
score += nextTeamSign * Math.round(pressure * (shortTable ? 1.2 : 0.8));
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function moveKey(move: AIMove): string {
|
||||
const capIds = move.capture.map(c => c.id).sort().join(',');
|
||||
return `${move.card.id}|${capIds}`;
|
||||
}
|
||||
341
src/game/ai-strategy.ts
Normal file
341
src/game/ai-strategy.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Strategy — table parity, spariglio, mulinello, category states, endgame
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { Card, GameState, PlayerIndex, Suit, SUITS, PRIMIERA_VALUES } from './types';
|
||||
import { AIMove } from './types';
|
||||
import { teamOf } from './engine';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParityState {
|
||||
pairedRanks: number[];
|
||||
unpairedRanks: number[];
|
||||
spariglioDegree: number;
|
||||
isEvenParity: boolean;
|
||||
}
|
||||
|
||||
export interface SpariglioPotential {
|
||||
card: Card;
|
||||
spariglioDelta: number; // how spariglioDegree changes if this card is dumped
|
||||
isSpariglio3Card: boolean; // true if this is a 3-card spariglio (highest priority)
|
||||
}
|
||||
|
||||
export interface MulinelloState {
|
||||
active: boolean;
|
||||
favorableFor: 'us' | 'them' | null;
|
||||
breakingMoves: AIMove[];
|
||||
}
|
||||
|
||||
export interface CategoryEntry {
|
||||
state: 'secured' | 'lost' | 'contested';
|
||||
closeness: number; // 0-1, higher = closer to winning
|
||||
}
|
||||
|
||||
export interface PrimieraCategoryEntry {
|
||||
perSuit: Record<Suit, CategoryEntry>;
|
||||
overallCloseness: number;
|
||||
}
|
||||
|
||||
export interface CategoryStates {
|
||||
denari: CategoryEntry;
|
||||
carte: CategoryEntry;
|
||||
primiera: PrimieraCategoryEntry;
|
||||
scope: 'always_contested';
|
||||
settebello: 'always_contested';
|
||||
}
|
||||
|
||||
export interface PrimieraRaceState {
|
||||
teamLeadsBySuit: Record<Suit, boolean | null>; // true=we lead, false=they lead, null=tied/unknown
|
||||
contestedSuits: Suit[];
|
||||
unseenPrimieraCards: Card[]; // unseen 7s, 6s, 1s (in primiera value order)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function canCardCapture(card: Card, table: Card[]): boolean {
|
||||
// Direct match
|
||||
if (table.some(c => c.value === card.value)) return true;
|
||||
// Sum capture (subsets of 2+)
|
||||
if (table.length < 2) return false;
|
||||
for (let mask = 1; mask < (1 << table.length); mask++) {
|
||||
const subset = table.filter((_, i) => mask & (1 << i));
|
||||
if (subset.length >= 2 && subset.reduce((s, c) => s + c.value, 0) === card.value) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bestPrimieraValue(pile: Card[], suit: Suit): number {
|
||||
const cards = pile.filter(c => c.suit === suit);
|
||||
if (cards.length === 0) return 0;
|
||||
return Math.max(...cards.map(c => PRIMIERA_VALUES[c.value] ?? 10));
|
||||
}
|
||||
|
||||
function buildPrimieraCategoryEntry(
|
||||
state: GameState,
|
||||
team: 0 | 1,
|
||||
myPile: Card[],
|
||||
oppPile: Card[],
|
||||
): PrimieraCategoryEntry {
|
||||
const perSuit: Record<Suit, CategoryEntry> = {} as Record<Suit, CategoryEntry>;
|
||||
let totalCloseness = 0;
|
||||
|
||||
for (const suit of SUITS) {
|
||||
const myBest = bestPrimieraValue(myPile, suit);
|
||||
const oppBest = bestPrimieraValue(oppPile, suit);
|
||||
|
||||
let entry: CategoryEntry;
|
||||
if (myBest > oppBest && myBest >= PRIMIERA_VALUES[6]) {
|
||||
// We have 7 or 6 in this suit and it's the best
|
||||
entry = { state: 'secured', closeness: 1 };
|
||||
} else if (oppBest > myBest && oppBest >= PRIMIERA_VALUES[6]) {
|
||||
entry = { state: 'lost', closeness: 0 };
|
||||
} else {
|
||||
// Contested: closer to 1 if we have a better card
|
||||
const closeness = Math.max(0, Math.min(1, (myBest - oppBest + 10) / 20));
|
||||
entry = { state: 'contested', closeness };
|
||||
}
|
||||
|
||||
perSuit[suit] = entry;
|
||||
totalCloseness += entry.closeness;
|
||||
}
|
||||
|
||||
return {
|
||||
perSuit,
|
||||
overallCloseness: totalCloseness / 4,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function analyzeTableParity(table: Card[]): ParityState {
|
||||
const countByValue = new Map<number, number>();
|
||||
for (const card of table) {
|
||||
countByValue.set(card.value, (countByValue.get(card.value) ?? 0) + 1);
|
||||
}
|
||||
const pairedRanks: number[] = [];
|
||||
const unpairedRanks: number[] = [];
|
||||
for (const [value, count] of countByValue) {
|
||||
if (count % 2 === 0) pairedRanks.push(value);
|
||||
else unpairedRanks.push(value);
|
||||
}
|
||||
return {
|
||||
pairedRanks,
|
||||
unpairedRanks,
|
||||
spariglioDegree: unpairedRanks.length,
|
||||
isEvenParity: unpairedRanks.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function rankDumpsBySpariglio(
|
||||
hand: Card[],
|
||||
table: Card[],
|
||||
isNonDealerTeam: boolean,
|
||||
): SpariglioPotential[] {
|
||||
const current = analyzeTableParity(table);
|
||||
|
||||
const potentials: SpariglioPotential[] = [];
|
||||
for (const card of hand) {
|
||||
if (canCardCapture(card, table)) continue; // only dump moves
|
||||
|
||||
const tableAfter = [...table, card];
|
||||
const after = analyzeTableParity(tableAfter);
|
||||
const spariglioDelta = after.spariglioDegree - current.spariglioDegree;
|
||||
|
||||
potentials.push({
|
||||
card,
|
||||
spariglioDelta,
|
||||
isSpariglio3Card: spariglioDelta >= 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Non-dealer wants highest positive delta first; dealer wants lowest (most negative) first
|
||||
if (isNonDealerTeam) {
|
||||
potentials.sort((a, b) => b.spariglioDelta - a.spariglioDelta);
|
||||
} else {
|
||||
potentials.sort((a, b) => a.spariglioDelta - b.spariglioDelta);
|
||||
}
|
||||
|
||||
return potentials;
|
||||
}
|
||||
|
||||
export function detectMulinello(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
inference: CardInferenceEngine,
|
||||
): MulinelloState {
|
||||
const table = state.table;
|
||||
if (table.length === 0 || table.length > 4) {
|
||||
return { active: false, favorableFor: null, breakingMoves: [] };
|
||||
}
|
||||
|
||||
const myHand = state.players[playerIdx].hand;
|
||||
const myCaptures = myHand.filter(c => canCardCapture(c, table));
|
||||
|
||||
if (myCaptures.length === 0 && table.length <= 2) {
|
||||
// We can't capture — potential mulinello against us
|
||||
return {
|
||||
active: true,
|
||||
favorableFor: 'them',
|
||||
breakingMoves: myHand.map(c => ({ card: c, capture: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
return { active: false, favorableFor: null, breakingMoves: [] };
|
||||
}
|
||||
|
||||
export function getPhase(state: GameState): 'opening' | 'midgame' | 'endgame' {
|
||||
const totalCardsInHands = state.players.reduce((sum, p) => sum + p.hand.length, 0);
|
||||
const totalPlayed = 40 - totalCardsInHands - state.table.length;
|
||||
if (totalPlayed <= 12) return 'opening';
|
||||
if (totalCardsInHands <= 16) return 'endgame';
|
||||
return 'midgame';
|
||||
}
|
||||
|
||||
export function getCategoryStates(state: GameState, team: 0 | 1): CategoryStates {
|
||||
const myPile: Card[] = [];
|
||||
const oppPile: Card[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = state.players[i];
|
||||
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
|
||||
else oppPile.push(...p.pile);
|
||||
}
|
||||
|
||||
const myCoins = myPile.filter(c => c.suit === 'denara').length;
|
||||
const oppCoins = oppPile.filter(c => c.suit === 'denara').length;
|
||||
const totalCards = myPile.length + oppPile.length + state.table.length;
|
||||
const unseenCoins = 10 - myCoins - oppCoins - state.table.filter(c => c.suit === 'denara').length;
|
||||
const unseenCards = 40 - totalCards;
|
||||
|
||||
// Denari
|
||||
const denari: CategoryEntry = myCoins >= 6
|
||||
? { state: 'secured', closeness: 1 }
|
||||
: oppCoins >= 6
|
||||
? { state: 'lost', closeness: 0 }
|
||||
: {
|
||||
state: 'contested',
|
||||
closeness: Math.max(0, Math.min(1, (myCoins - oppCoins + 1) / Math.max(1, unseenCoins + 1))),
|
||||
};
|
||||
|
||||
// Carte
|
||||
const myCards = myPile.length;
|
||||
const oppCards = oppPile.length;
|
||||
const carte: CategoryEntry = myCards >= 21
|
||||
? { state: 'secured', closeness: 1 }
|
||||
: oppCards >= 21
|
||||
? { state: 'lost', closeness: 0 }
|
||||
: {
|
||||
state: 'contested',
|
||||
closeness: Math.max(0, Math.min(1, (myCards - oppCards + 1) / Math.max(1, unseenCards + 1))),
|
||||
};
|
||||
|
||||
// Primiera: per suit
|
||||
const primiera = buildPrimieraCategoryEntry(state, team, myPile, oppPile);
|
||||
|
||||
return {
|
||||
denari,
|
||||
carte,
|
||||
primiera,
|
||||
scope: 'always_contested',
|
||||
settebello: 'always_contested',
|
||||
};
|
||||
}
|
||||
|
||||
export function solveEndgame(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
inference: CardInferenceEngine,
|
||||
legalMoves: AIMove[],
|
||||
): AIMove | null {
|
||||
// Only attempt when very few cards remain
|
||||
const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
|
||||
if (totalRemaining > 8) return null;
|
||||
|
||||
const myHand = state.players[playerIdx].hand;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (i === playerIdx) continue;
|
||||
const p = state.players[i];
|
||||
if (p.hand.length === 0) continue;
|
||||
|
||||
const constrained = inference.getConstrainedUnseen(i as PlayerIndex, myHand, state.table);
|
||||
if (constrained.length < p.hand.length) {
|
||||
// Can't fully determine this player's hand
|
||||
return null;
|
||||
}
|
||||
// If constrained pool equals exactly handSize, we know exactly what they have
|
||||
if (constrained.length > p.hand.length) return null; // still ambiguous
|
||||
}
|
||||
|
||||
// We know all hands — pick the best move by greedy evaluation
|
||||
let bestMove: AIMove | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const move of legalMoves) {
|
||||
let score = 0;
|
||||
// Scopa
|
||||
const tableAfter = state.table.filter(c => !move.capture.some(cap => cap.id === c.id));
|
||||
if (move.capture.length > 0 && tableAfter.length === 0) score += 100;
|
||||
// Settebello
|
||||
if (move.capture.some(c => c.id === 'denara_7')) score += 80;
|
||||
// 7s
|
||||
score += move.capture.filter(c => c.value === 7).length * 40;
|
||||
// Coins
|
||||
score += move.capture.filter(c => c.suit === 'denara').length * 15;
|
||||
// Cards
|
||||
score += move.capture.length * 3;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = move;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMove;
|
||||
}
|
||||
|
||||
export function analyzePrimieraRace(state: GameState, team: 0 | 1): PrimieraRaceState {
|
||||
const myPile: Card[] = [];
|
||||
const oppPile: Card[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = state.players[i];
|
||||
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
|
||||
else oppPile.push(...p.pile);
|
||||
}
|
||||
|
||||
const allKnown = new Set([...myPile, ...oppPile, ...state.table].map(c => c.id));
|
||||
const unseenPrimieraCards: Card[] = [];
|
||||
const PRIMIERA_ORDER = [7, 6, 1, 5, 4, 3, 2, 8, 9, 10];
|
||||
|
||||
for (const value of PRIMIERA_ORDER) {
|
||||
for (const suit of SUITS) {
|
||||
const id = `${suit}_${value}`;
|
||||
if (!allKnown.has(id) && (value === 7 || value === 6 || value === 1)) {
|
||||
unseenPrimieraCards.push({ suit, value, id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamLeadsBySuit: Record<Suit, boolean | null> = {} as Record<Suit, boolean | null>;
|
||||
const contestedSuits: Suit[] = [];
|
||||
|
||||
for (const suit of SUITS) {
|
||||
const myBest = bestPrimieraValue(myPile, suit);
|
||||
const oppBest = bestPrimieraValue(oppPile, suit);
|
||||
if (myBest > oppBest) teamLeadsBySuit[suit] = true;
|
||||
else if (oppBest > myBest) teamLeadsBySuit[suit] = false;
|
||||
else {
|
||||
teamLeadsBySuit[suit] = null;
|
||||
contestedSuits.push(suit);
|
||||
}
|
||||
}
|
||||
|
||||
return { teamLeadsBySuit, contestedSuits, unseenPrimieraCards };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AIDecisionProgress, AIMove, chooseMove } from './ai';
|
||||
import { AIChooseMoveOptions, AIDecisionProgress, AIMove, chooseMove } from './ai';
|
||||
import {
|
||||
AIWorkerErrorMessage,
|
||||
AIWorkerRequestMessage,
|
||||
@@ -14,6 +14,7 @@ export interface AIWorkerClientLike {
|
||||
difficulty?: Difficulty,
|
||||
tracker?: CardTracker,
|
||||
onProgress?: (progress: AIDecisionProgress) => void,
|
||||
options?: AIChooseMoveOptions,
|
||||
): Promise<AIMove>;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -26,6 +27,7 @@ interface PendingRequest {
|
||||
difficulty: Difficulty;
|
||||
tracker?: CardTracker;
|
||||
onProgress?: (progress: AIDecisionProgress) => void;
|
||||
options?: AIChooseMoveOptions;
|
||||
resolve: (move: AIMove) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
@@ -66,6 +68,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
||||
difficulty: Difficulty = 'advanced',
|
||||
tracker?: CardTracker,
|
||||
onProgress?: (progress: AIDecisionProgress) => void,
|
||||
options?: AIChooseMoveOptions,
|
||||
): Promise<AIMove> {
|
||||
if (this.disposed) {
|
||||
throw new Error('AIWorkerClient has been disposed');
|
||||
@@ -85,6 +88,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
||||
difficulty,
|
||||
tracker,
|
||||
onProgress,
|
||||
options,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
@@ -98,6 +102,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
||||
playerIdx,
|
||||
difficulty,
|
||||
trackerSnapshot: tracker ? tracker.toSnapshot() : null,
|
||||
inferenceSnapshot: options?.inference?.toSnapshot() ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -179,6 +184,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
||||
pending.difficulty,
|
||||
pending.tracker,
|
||||
pending.onProgress,
|
||||
pending.options,
|
||||
);
|
||||
pending.resolve(move);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
4271
src/game/ai.ts
4271
src/game/ai.ts
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
AIWorkerRequestMessage,
|
||||
AIWorkerResponseMessage,
|
||||
} from './ai-worker-protocol';
|
||||
import { CardInferenceEngine } from './card-inference';
|
||||
import { CardTracker } from './card-tracker';
|
||||
|
||||
interface AIWorkerScope {
|
||||
@@ -42,6 +43,10 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
|
||||
? CardTracker.fromSnapshot(request.trackerSnapshot)
|
||||
: undefined;
|
||||
|
||||
const inference = request.inferenceSnapshot && tracker
|
||||
? CardInferenceEngine.fromSnapshot(request.inferenceSnapshot, tracker)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const move = await chooseMove(
|
||||
request.state,
|
||||
@@ -55,6 +60,7 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
|
||||
progress,
|
||||
});
|
||||
},
|
||||
inference ? { inference } : undefined,
|
||||
);
|
||||
|
||||
workerScope.postMessage({
|
||||
|
||||
184
src/game/card-inference.ts
Normal file
184
src/game/card-inference.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card Inference Engine
|
||||
// Tracks per-player card constraints and probability distributions.
|
||||
// Additive to CardTracker — does NOT replace it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { Card, PlayerIndex, Suit, SUITS } from './types';
|
||||
import { AIMove } from './types';
|
||||
import { CardTracker } from './card-tracker';
|
||||
|
||||
export interface CardInferenceSnapshot {
|
||||
cannotHave: Array<[PlayerIndex, string[]]>;
|
||||
confirmedHeld: Array<[PlayerIndex, string[]]>;
|
||||
}
|
||||
|
||||
export class CardInferenceEngine {
|
||||
private cannotHave: Map<PlayerIndex, Set<string>>;
|
||||
private confirmedHeld: Map<PlayerIndex, Set<string>>;
|
||||
private tracker: CardTracker;
|
||||
|
||||
constructor(tracker: CardTracker) {
|
||||
this.tracker = tracker;
|
||||
this.cannotHave = new Map();
|
||||
this.confirmedHeld = new Map();
|
||||
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
|
||||
this.cannotHave.set(p, new Set());
|
||||
this.confirmedHeld.set(p, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
|
||||
this.cannotHave.get(p)!.clear();
|
||||
this.confirmedHeld.get(p)!.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call after every move is applied.
|
||||
* @param playerIdx The player who just played
|
||||
* @param move The move that was played (card + capture)
|
||||
* @param tableBeforeMove The table state BEFORE the move was applied
|
||||
*/
|
||||
onMove(playerIdx: PlayerIndex, move: AIMove, tableBeforeMove: Card[]): void {
|
||||
// 1. Player confirmed held the card they played
|
||||
this.confirmedHeld.get(playerIdx)!.add(move.card.id);
|
||||
|
||||
// 2. If dump (no capture): player could not capture any table card with this card.
|
||||
// Infer: player does NOT hold any card whose value matches any table card
|
||||
// (because if they did, they'd have had a capture option for the dump-value already,
|
||||
// but more importantly: the dump tells us no capture was available for move.card.value,
|
||||
// which means the table does NOT have move.card.value — engine enforces mandatory capture).
|
||||
// Additional inference: for each table card value, player cannot have the matching
|
||||
// card OF THE SAME VALUE as their dumped card (engine rule: if matching value on table, capture is mandatory).
|
||||
// Simplified: if player dumps value V, and table had cards of various values,
|
||||
// it means table had NO card of value V. We can mark: player "cannot have" cards
|
||||
// whose value == any table card value (because those would have been capturable).
|
||||
// Actually the correct inference is: player dumped, so findCaptures(move.card, tableBeforeMove) === [].
|
||||
// This means: no single table card matches move.card.value AND no subset sums to move.card.value.
|
||||
// We mark: player definitely did NOT have a card that could capture from this table state.
|
||||
// The simplest safe inference: mark that the player cannot have any card of the SAME VALUE
|
||||
// as any card on the table before the move (since having such a card would have forced a capture).
|
||||
if (move.capture.length === 0 && tableBeforeMove.length > 0) {
|
||||
// Player dumped — they had no capture for move.card.
|
||||
// Since capture is mandatory in Scopone, this means no card on table matches move.card.value
|
||||
// and no subset of table cards sums to move.card.value.
|
||||
// Safe inference: player doesn't hold any card of the same value as table cards
|
||||
// (because if they had such a card, they'd have been forced to capture it instead).
|
||||
// NOTE: This inference applies to OTHER cards in their hand, not the dumped card itself.
|
||||
for (const tableCard of tableBeforeMove) {
|
||||
// If player had a card of value tableCard.value, they would have captured it
|
||||
// (since direct match = mandatory capture). So: player cannot have any card of tableCard.value
|
||||
// in their REMAINING hand (they may have had one but already played it — but since we're
|
||||
// tracking "cannot have NOW", this is correct: if they just dumped and had tableCard.value,
|
||||
// they would have captured instead).
|
||||
for (const suit of SUITS) {
|
||||
const inferredId = `${suit}_${tableCard.value}`;
|
||||
if (inferredId !== move.card.id) {
|
||||
this.cannotHave.get(playerIdx)!.add(inferredId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Exhaustion inference: for any value where all 4 suits are now accounted for
|
||||
this.applyExhaustionInference();
|
||||
}
|
||||
|
||||
private applyExhaustionInference(): void {
|
||||
for (let value = 1; value <= 10; value++) {
|
||||
const allPlayed = SUITS.every(suit => this.tracker.hasBeenPlayed(`${suit}_${value}`));
|
||||
if (allPlayed) {
|
||||
for (const p of [0, 1, 2, 3] as PlayerIndex[]) {
|
||||
for (const suit of SUITS) {
|
||||
this.cannotHave.get(p)!.add(`${suit}_${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pool of unseen cards for a given player, filtered by their constraints.
|
||||
*/
|
||||
getConstrainedUnseen(excludePlayerIdx: PlayerIndex, myHand: Card[], table: Card[]): Card[] {
|
||||
const unseen = this.tracker.getUnseenCards(myHand, table);
|
||||
const excluded = this.cannotHave.get(excludePlayerIdx)!;
|
||||
return unseen.filter(c => !excluded.has(c.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hypergeometric probability: P(player holds at least one card of given value).
|
||||
*/
|
||||
probabilityPlayerHasValue(
|
||||
playerIdx: PlayerIndex,
|
||||
value: number,
|
||||
handSize: number,
|
||||
myHand: Card[],
|
||||
table: Card[],
|
||||
): number {
|
||||
if (handSize <= 0) return 0;
|
||||
const pool = this.getConstrainedUnseen(playerIdx, myHand, table);
|
||||
const matching = pool.filter(c => c.value === value).length;
|
||||
if (matching === 0) return 0;
|
||||
if (handSize >= pool.length) return 1;
|
||||
|
||||
let probNone = 1;
|
||||
for (let i = 0; i < handSize; i++) {
|
||||
probNone *= Math.max(0, (pool.length - matching - i)) / (pool.length - i);
|
||||
}
|
||||
return 1 - probNone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a likely hand for sampling (for PIMC determinization).
|
||||
* Cards are weighted by strategic importance: 7s first, then 6s, then coins, then aces.
|
||||
*/
|
||||
getLikelyHandForSampling(
|
||||
playerIdx: PlayerIndex,
|
||||
handSize: number,
|
||||
myHand: Card[],
|
||||
table: Card[],
|
||||
rng: () => number = Math.random,
|
||||
): Card[] {
|
||||
const pool = this.getConstrainedUnseen(playerIdx, myHand, table);
|
||||
if (pool.length === 0) return [];
|
||||
|
||||
// Weight cards by strategic importance
|
||||
const weighted = pool.map(card => {
|
||||
let weight = 0;
|
||||
if (card.value === 7) weight += 4;
|
||||
else if (card.value === 6) weight += 3;
|
||||
else if (card.value === 1) weight += 2;
|
||||
if (card.suit === 'denara') weight += 1;
|
||||
weight += rng() * 0.5; // noise to vary sampling
|
||||
return { card, weight };
|
||||
});
|
||||
|
||||
weighted.sort((a, b) => b.weight - a.weight);
|
||||
return weighted.slice(0, Math.min(handSize, pool.length)).map(w => w.card);
|
||||
}
|
||||
|
||||
toSnapshot(): CardInferenceSnapshot {
|
||||
return {
|
||||
cannotHave: ([0, 1, 2, 3] as PlayerIndex[]).map(p =>
|
||||
[p, [...this.cannotHave.get(p)!]] as [PlayerIndex, string[]],
|
||||
),
|
||||
confirmedHeld: ([0, 1, 2, 3] as PlayerIndex[]).map(p =>
|
||||
[p, [...this.confirmedHeld.get(p)!]] as [PlayerIndex, string[]],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static fromSnapshot(snapshot: CardInferenceSnapshot, tracker: CardTracker): CardInferenceEngine {
|
||||
const engine = new CardInferenceEngine(tracker);
|
||||
for (const [p, ids] of snapshot.cannotHave) {
|
||||
engine.cannotHave.set(p as PlayerIndex, new Set(ids));
|
||||
}
|
||||
for (const [p, ids] of snapshot.confirmedHeld) {
|
||||
engine.confirmedHeld.set(p as PlayerIndex, new Set(ids));
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -73,3 +73,8 @@ export const PRIMIERA_VALUES: Record<number, number> = {
|
||||
9: 10,
|
||||
10: 10,
|
||||
};
|
||||
|
||||
export interface AIMove {
|
||||
card: Card;
|
||||
capture: Card[];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user