Merge feature/SCOPONE-0013: AI rewrite + Gitea CI pipeline
All checks were successful
Android Build & Publish / android (push) Successful in 2m2s
All checks were successful
Android Build & Publish / android (push) Successful in 2m2s
This commit is contained in:
137
.gitea/workflows/android-build.yml
Normal file
137
.gitea/workflows/android-build.yml
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
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 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '21'
|
||||||
|
|
||||||
|
# ── 3. Node.js ───────────────────────────────────────────────────────────
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
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 || 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" \
|
||||||
|
"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. Upload APKs as workflow artifacts ─────────────────────────────────
|
||||||
|
- name: Upload APKs as artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
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:
|
||||||
|
GITEA_ACTOR: ${{ gitea.actor }}
|
||||||
|
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 \
|
||||||
|
--output /dev/null --write-out "%{http_code}" \
|
||||||
|
-X PUT \
|
||||||
|
-u "gitea-actions:${{ secrets.GITEA_TOKEN }}" \
|
||||||
|
--upload-file "$src" \
|
||||||
|
"$BASE/$VERSION/$dst_name")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
504
package-lock.json
generated
504
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"phaser": "^3.87.0"
|
"phaser": "^3.87.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
@@ -358,6 +359,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -375,6 +393,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -392,6 +427,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"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==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine';
|
import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine';
|
||||||
import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai';
|
import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai';
|
||||||
|
import { chooseMove as chooseMoveOld } from './ai-legacy';
|
||||||
|
import { CardInferenceEngine } from './card-inference';
|
||||||
import {
|
import {
|
||||||
AI_BENCHMARK_FIXTURES,
|
AI_BENCHMARK_FIXTURES,
|
||||||
AIBenchmarkCriticalConcept,
|
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 SELF_PLAY_MATCH_SEEDS = Array.from({ length: 250 }, (_, index) => 1000 + index);
|
||||||
const MAX_SELF_PLAY_ROUNDS = 20;
|
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 {
|
interface SelfPlaySuiteConfig {
|
||||||
id: SelfPlaySuiteId;
|
id: SelfPlaySuiteId;
|
||||||
label: string;
|
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> {
|
async function runBenchmarkCli(): Promise<void> {
|
||||||
const summary = await runAIBenchmark();
|
const summary = await runAIBenchmark();
|
||||||
logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.');
|
logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.');
|
||||||
printReadableSummary(summary);
|
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') {
|
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 {
|
import {
|
||||||
AIWorkerErrorMessage,
|
AIWorkerErrorMessage,
|
||||||
AIWorkerRequestMessage,
|
AIWorkerRequestMessage,
|
||||||
@@ -14,6 +14,7 @@ export interface AIWorkerClientLike {
|
|||||||
difficulty?: Difficulty,
|
difficulty?: Difficulty,
|
||||||
tracker?: CardTracker,
|
tracker?: CardTracker,
|
||||||
onProgress?: (progress: AIDecisionProgress) => void,
|
onProgress?: (progress: AIDecisionProgress) => void,
|
||||||
|
options?: AIChooseMoveOptions,
|
||||||
): Promise<AIMove>;
|
): Promise<AIMove>;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ interface PendingRequest {
|
|||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
tracker?: CardTracker;
|
tracker?: CardTracker;
|
||||||
onProgress?: (progress: AIDecisionProgress) => void;
|
onProgress?: (progress: AIDecisionProgress) => void;
|
||||||
|
options?: AIChooseMoveOptions;
|
||||||
resolve: (move: AIMove) => void;
|
resolve: (move: AIMove) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
|||||||
difficulty: Difficulty = 'advanced',
|
difficulty: Difficulty = 'advanced',
|
||||||
tracker?: CardTracker,
|
tracker?: CardTracker,
|
||||||
onProgress?: (progress: AIDecisionProgress) => void,
|
onProgress?: (progress: AIDecisionProgress) => void,
|
||||||
|
options?: AIChooseMoveOptions,
|
||||||
): Promise<AIMove> {
|
): Promise<AIMove> {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
throw new Error('AIWorkerClient has been disposed');
|
throw new Error('AIWorkerClient has been disposed');
|
||||||
@@ -85,6 +88,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
|||||||
difficulty,
|
difficulty,
|
||||||
tracker,
|
tracker,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
options,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
};
|
};
|
||||||
@@ -98,6 +102,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
|||||||
playerIdx,
|
playerIdx,
|
||||||
difficulty,
|
difficulty,
|
||||||
trackerSnapshot: tracker ? tracker.toSnapshot() : null,
|
trackerSnapshot: tracker ? tracker.toSnapshot() : null,
|
||||||
|
inferenceSnapshot: options?.inference?.toSnapshot() ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +184,7 @@ export class AIWorkerClient implements AIWorkerClientLike {
|
|||||||
pending.difficulty,
|
pending.difficulty,
|
||||||
pending.tracker,
|
pending.tracker,
|
||||||
pending.onProgress,
|
pending.onProgress,
|
||||||
|
pending.options,
|
||||||
);
|
);
|
||||||
pending.resolve(move);
|
pending.resolve(move);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AIDecisionProgress, AIMove } from './ai';
|
import type { AIDecisionProgress, AIMove } from './ai';
|
||||||
|
import type { CardInferenceSnapshot } from './card-inference';
|
||||||
import type { CardTrackerSnapshot } from './card-tracker';
|
import type { CardTrackerSnapshot } from './card-tracker';
|
||||||
import type { Difficulty, GameState, PlayerIndex } from './types';
|
import type { Difficulty, GameState, PlayerIndex } from './types';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export interface AIWorkerChooseMoveRequest {
|
|||||||
playerIdx: PlayerIndex;
|
playerIdx: PlayerIndex;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
trackerSnapshot: CardTrackerSnapshot | null;
|
trackerSnapshot: CardTrackerSnapshot | null;
|
||||||
|
inferenceSnapshot: CardInferenceSnapshot | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIWorkerProgressMessage {
|
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,
|
AIWorkerRequestMessage,
|
||||||
AIWorkerResponseMessage,
|
AIWorkerResponseMessage,
|
||||||
} from './ai-worker-protocol';
|
} from './ai-worker-protocol';
|
||||||
|
import { CardInferenceEngine } from './card-inference';
|
||||||
import { CardTracker } from './card-tracker';
|
import { CardTracker } from './card-tracker';
|
||||||
|
|
||||||
interface AIWorkerScope {
|
interface AIWorkerScope {
|
||||||
@@ -42,6 +43,10 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
|
|||||||
? CardTracker.fromSnapshot(request.trackerSnapshot)
|
? CardTracker.fromSnapshot(request.trackerSnapshot)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const inference = request.inferenceSnapshot && tracker
|
||||||
|
? CardInferenceEngine.fromSnapshot(request.inferenceSnapshot, tracker)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const move = await chooseMove(
|
const move = await chooseMove(
|
||||||
request.state,
|
request.state,
|
||||||
@@ -55,6 +60,7 @@ async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise<voi
|
|||||||
progress,
|
progress,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
inference ? { inference } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
workerScope.postMessage({
|
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,
|
9: 10,
|
||||||
10: 10,
|
10: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AIMove {
|
||||||
|
card: Card;
|
||||||
|
capture: Card[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { AIMove, AIDecisionProgress } from '../game/ai';
|
import { AIMove, AIDecisionProgress } from '../game/ai';
|
||||||
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
|
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
|
||||||
import { CardTracker } from '../game/card-tracker';
|
import { CardTracker } from '../game/card-tracker';
|
||||||
|
import { CardInferenceEngine } from '../game/card-inference';
|
||||||
import {
|
import {
|
||||||
DEFAULT_AUDIO_PREFERENCES,
|
DEFAULT_AUDIO_PREFERENCES,
|
||||||
GameSceneData,
|
GameSceneData,
|
||||||
@@ -71,6 +72,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Difficulty & card tracker
|
// Difficulty & card tracker
|
||||||
private difficulty: Difficulty = 'advanced';
|
private difficulty: Difficulty = 'advanced';
|
||||||
private tracker: CardTracker = new CardTracker();
|
private tracker: CardTracker = new CardTracker();
|
||||||
|
private inference: CardInferenceEngine = new CardInferenceEngine(this.tracker);
|
||||||
private aiClient: AIWorkerClientLike | null = null;
|
private aiClient: AIWorkerClientLike | null = null;
|
||||||
|
|
||||||
// Active player highlight
|
// Active player highlight
|
||||||
@@ -133,6 +135,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
? normalizeAudioPreferences(data.audioPreferences)
|
? normalizeAudioPreferences(data.audioPreferences)
|
||||||
: loadAudioPreferences();
|
: loadAudioPreferences();
|
||||||
this.tracker = new CardTracker();
|
this.tracker = new CardTracker();
|
||||||
|
this.inference = new CardInferenceEngine(this.tracker);
|
||||||
this.aiClient?.dispose();
|
this.aiClient?.dispose();
|
||||||
this.aiClient = new AIWorkerClient();
|
this.aiClient = new AIWorkerClient();
|
||||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.handleSceneShutdown, this);
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.handleSceneShutdown, this);
|
||||||
@@ -793,7 +796,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.updateThinkBar(playerIdx, progress);
|
this.updateThinkBar(playerIdx, progress);
|
||||||
if (progress.difficulty !== 'master') return;
|
if (progress.difficulty !== 'master') return;
|
||||||
finalProgress = progress;
|
finalProgress = progress;
|
||||||
}
|
},
|
||||||
|
{ inference: this.inference },
|
||||||
);
|
);
|
||||||
|
|
||||||
const remainingThinkMs = AI_MIN_THINK_MS - (Date.now() - thinkStartedAt);
|
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 {
|
private executeMove(playerIdx: PlayerIndex, card: Card, capture: Card[]): void {
|
||||||
|
const tableBeforeMove = [...this.state.table];
|
||||||
const { nextState, capture: captureResult, isScopa } = applyMove(
|
const { nextState, capture: captureResult, isScopa } = applyMove(
|
||||||
this.state, playerIdx, card, capture.length > 0 ? capture : undefined
|
this.state, playerIdx, card, capture.length > 0 ? capture : undefined
|
||||||
);
|
);
|
||||||
@@ -1030,6 +1035,13 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.tracker.trackCapture(captureResult.captured);
|
this.tracker.trackCapture(captureResult.captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update inference engine
|
||||||
|
this.inference.onMove(
|
||||||
|
playerIdx,
|
||||||
|
{ card, capture: captureResult?.captured ?? [] },
|
||||||
|
tableBeforeMove,
|
||||||
|
);
|
||||||
|
|
||||||
const cardImg = this.cardImages.get(card.id)!;
|
const cardImg = this.cardImages.get(card.id)!;
|
||||||
cardImg.setDepth(15);
|
cardImg.setDepth(15);
|
||||||
|
|
||||||
@@ -1650,6 +1662,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
for (const img of this.cardImages.values()) img.destroy();
|
for (const img of this.cardImages.values()) img.destroy();
|
||||||
this.cardImages.clear();
|
this.cardImages.clear();
|
||||||
this.tracker.reset();
|
this.tracker.reset();
|
||||||
|
this.inference.reset();
|
||||||
this.state = createInitialState(nextDealer);
|
this.state = createInitialState(nextDealer);
|
||||||
this.state.matchStartingPlayer = matchStartingPlayer;
|
this.state.matchStartingPlayer = matchStartingPlayer;
|
||||||
this.state.teamScores[0].totalPoints = totals[0];
|
this.state.teamScores[0].totalPoints = totals[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user