Compare commits

...

14 Commits

Author SHA1 Message Date
Giancarmine Salucci
416c8b07d2 ci: remove broken concurrency block, make release step idempotent
Some checks failed
Android Build & Publish / android (push) Has been cancelled
- Removed concurrency/cancel-in-progress: on this Gitea runner version
  cancelled runs get re-queued causing an infinite restart loop
- Release creation now checks for existing tag first (GET releases/tags/TAG)
  and reuses the existing release ID if found, preventing the second
  duplicate run from failing with a 409/422 tag-already-exists error
2026-05-25 21:01:09 +02:00
Giancarmine Salucci
32d4178875 ci: add concurrency group to prevent duplicate runner builds
All checks were successful
Android Build & Publish / android (push) Successful in 2m3s
2026-05-25 20:52:40 +02:00
Giancarmine Salucci
697f7de9d1 ci: retry gradlew up to 3x to survive transient CDN/network errors
All checks were successful
Android Build & Publish / android (push) Successful in 2m0s
2026-05-25 16:44:44 +02:00
Giancarmine Salucci
3204a19dc1 ci: retrigger after docker hub flake (run 87)
Some checks failed
Android Build & Publish / android (push) Failing after 32s
2026-05-25 13:39:24 +02:00
Giancarmine Salucci
3088fa36dc fix: avoid SIGPIPE (exit 141) in release changelog step under pipefail
Some checks failed
Android Build & Publish / android (push) Failing after 11s
2026-05-25 09:46:16 +02:00
Giancarmine Salucci
b2a84eb167 feat: CI creates Gitea releases with changelog, app polls for updates on startup
Some checks failed
Android Build & Publish / android (push) Failing after 2m0s
- android-build.yml: fetch full history+tags, embed VITE_APP_BUILD, add step
  to create a tagged Gitea release (build-N) with markdown changelog and APK
  release assets after every push; bump permissions to contents:write
- src/game/update-check.ts: polls Gitea releases/latest, compares build-N tag
  against CURRENT_BUILD (0 in dev), returns UpdateInfo or null; dismissal
  persisted to localStorage
- src/vite-env.d.ts: TypeScript env declarations for VITE_APP_BUILD
- src/scenes/MenuScene.ts: fire-and-forget update check on menu load; renders
  dismissible bottom-bar banner with optional APK download link
- src/game/ai.ts: early-game empty-table dump heuristic (safest card first)
2026-05-25 09:39:08 +02:00
Giancarmine Salucci
49e51748d7 ci: use PACKAGE_TOKEN (bearer) for package registry upload
All checks were successful
Android Build & Publish / android (push) Successful in 2m0s
2026-05-25 09:00:17 +02:00
Giancarmine Salucci
58494f37ab Merge feature/SCOPONE-0013: AI rewrite + Gitea CI pipeline
All checks were successful
Android Build & Publish / android (push) Successful in 2m2s
2026-05-25 08:56:02 +02:00
Giancarmine Salucci
641f678ddd ci: downgrade upload-artifact to v3 (v4 not supported on Gitea/GHES)
All checks were successful
Android Build & Publish / android (push) Successful in 2m1s
2026-05-24 16:51:32 +02:00
Giancarmine Salucci
bfb0cc87ca ci: save APKs as workflow artifacts; best-effort package registry upload
Some checks failed
Android Build & Publish / android (push) Failing after 1m58s
2026-05-24 16:48:12 +02:00
Giancarmine Salucci
052728c168 ci: use github.token for package upload (respects permissions:packages:write), fix curl status capture
Some checks failed
Android Build & Publish / android (push) Failing after 1m56s
2026-05-24 16:44:15 +02:00
Giancarmine Salucci
e5c85981f8 ci: upgrade to JDK 21 (project targets JavaVersion.VERSION_21)
Some checks failed
Android Build & Publish / android (push) Failing after 2m4s
2026-05-24 16:38:55 +02:00
Giancarmine Salucci
ca75710285 ci: use Node 22 (capacitor/cli requires >=22), refresh lock file
Some checks failed
Android Build & Publish / android (push) Failing after 1m8s
2026-05-24 16:36:22 +02:00
Giancarmine Salucci
bfa5797f2b ci: fix SIGPIPE in sdkmanager --licenses step (exit 141)
Some checks failed
Android Build & Publish / android (push) Failing after 20s
2026-05-24 16:33:22 +02:00
6 changed files with 836 additions and 12 deletions

View File

@@ -5,7 +5,7 @@ on:
workflow_dispatch:
permissions:
contents: read
contents: write # required for creating releases
packages: write
jobs:
@@ -16,19 +16,22 @@ jobs:
# ── 1. Source ────────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags required for the changelog step.
fetch-depth: 0
# ── 2. Java ──────────────────────────────────────────────────────────────
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
java-version: '21'
# ── 3. Node.js ───────────────────────────────────────────────────────────
- name: Set up Node 20
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: npm
# ── 4. Android SDK ───────────────────────────────────────────────────────
@@ -52,7 +55,9 @@ jobs:
- name: Accept SDK licenses & install platform/build-tools
run: |
yes | sdkmanager --licenses > /dev/null
# { yes || true } absorbs the SIGPIPE that 'yes' gets when sdkmanager closes
# stdin after accepting all prompts — avoids exit-code 141 under 'pipefail'.
{ yes 2>/dev/null || true; } | sdkmanager --licenses
sdkmanager \
"platforms;android-36" \
"build-tools;35.0.0" \
@@ -73,6 +78,10 @@ jobs:
run: npm ci
- name: Build web assets
env:
# Embed the CI run number as the app's build identifier so the
# in-app update check can compare against Gitea release tags.
VITE_APP_BUILD: ${{ github.run_number }}
run: npm run build
# ── 7. Capacitor sync ────────────────────────────────────────────────────
@@ -85,16 +94,36 @@ jobs:
- name: Build Debug APK
working-directory: android
run: ./gradlew assembleDebug --no-daemon
run: |
# Retry up to 3 times to survive transient network errors when the
# Gradle wrapper downloads its distribution from GitHub CDN.
for attempt in 1 2 3; do
./gradlew assembleDebug --no-daemon && break
[ "$attempt" -lt 3 ] && echo "Attempt $attempt failed — retrying in 30s..." && sleep 30 || exit 1
done
- name: Build Release APK (unsigned — no signing key required)
working-directory: android
run: ./gradlew assembleRelease --no-daemon
run: |
for attempt in 1 2 3; do
./gradlew assembleRelease --no-daemon && break
[ "$attempt" -lt 3 ] && echo "Attempt $attempt failed — retrying in 30s..." && sleep 30 || exit 1
done
# ── 9. Publish to Gitea generic package registry ─────────────────────────
# ── 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 ────────────────────────
- name: Publish APKs to Gitea package registry
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ github.run_number }}"
@@ -103,11 +132,13 @@ jobs:
upload() {
local src="$1" dst_name="$2"
echo "→ Uploading $dst_name (version $VERSION)…"
HTTP=$(curl --silent --show-error --write-out "%{http_code}" \
HTTP=$(curl --silent --show-error \
--output /dev/null --write-out "%{http_code}" \
-X PUT \
-H "Authorization: token $TOKEN" \
--upload-file "$src" \
"$BASE/$VERSION/$dst_name")
echo " HTTP $HTTP"
if [[ "$HTTP" != "20"* ]]; then
echo "✗ Upload failed — HTTP $HTTP"
exit 1
@@ -121,5 +152,60 @@ jobs:
upload android/app/build/outputs/apk/release/app-release-unsigned.apk \
app-release-unsigned.apk
echo ""
echo "📦 Package index: $BASE/$VERSION/"
# ── 11. Create a Gitea release and attach the APKs ───────────────────────
# Requires PACKAGE_TOKEN to have both write:package AND write:repository
# scopes. If the PAT lacks write:repository the curl will return 403 and
# the step will fail — update the PAT scopes on the Gitea web UI.
- name: Create Gitea release
env:
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ github.run_number }}"
TAG="build-${VERSION}"
API="https://git.sal.giize.com/api/v1/repos/mozempk/scopone"
# ── Changelog: commits since last build-* tag (or last 30 commits) ──
# Use grep -m1 instead of | head -1 to avoid SIGPIPE under pipefail.
# Use git log -30 flag instead of | head -30 for the same reason.
LAST_TAG=$(git tag --sort=-creatordate | grep -Em1 '^build-[0-9]+$' || true)
if [[ -n "$LAST_TAG" ]]; then
COMMIT_LOG=$(git log --oneline -30 "${LAST_TAG}..HEAD" 2>/dev/null || git log --oneline -30)
else
COMMIT_LOG=$(git log --oneline -30)
fi
# Format as a markdown list and JSON-encode for the API body.
MD_LIST=$(echo "$COMMIT_LOG" | sed 's/^/- /')
BODY=$(printf '%s' "$MD_LIST" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
# ── Create release (idempotent: reuse existing tag if already present) ───
EXISTING=$(curl -sf "$API/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" || true)
if [[ -n "$EXISTING" ]]; then
RELEASE_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Release $TAG already exists (id=$RELEASE_ID) — reusing."
else
RESP=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Build $VERSION\",\"body\":$BODY,\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created release $TAG (id=$RELEASE_ID)"
fi
# ── Upload APKs as release assets ────────────────────────────────────
upload_asset() {
local file="$1" name="$2"
HTTP=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST "$API/releases/$RELEASE_ID/assets?name=${name}" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file")
echo " $name → HTTP $HTTP"
[[ "$HTTP" == "20"* ]] || { echo "✗ asset upload failed"; exit 1; }
}
upload_asset android/app/build/outputs/apk/release/app-release-unsigned.apk app-release-unsigned.apk
upload_asset android/app/build/outputs/apk/debug/app-debug.apk app-debug.apk
echo "🚀 https://git.sal.giize.com/mozempk/scopone/releases/tag/$TAG"

504
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"phaser": "^3.87.0"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
@@ -358,6 +359,23 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -375,6 +393,23 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -392,6 +427,23 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1891,6 +1943,458 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -239,6 +239,51 @@ function checkForcedMove(legalMoves: AIMove[], table: Card[]): AIMove | null {
return null;
}
/**
* Danger score for dumping a card on an empty table.
* Lower = safer: fewer capture-combo possibilities + less strategic value.
*
* Primary combo-capture proxy: face value (higher → more subsets of future
* table cards can sum to it, letting opponents capture it indirectly).
* Category penalties: settebello, non-coin 7 (primiera), other denara, high
* primiera-value cards (1/5/6 whose primiera contribution ≥ 15 pts).
*/
function emptyTableDumpDanger(card: Card): number {
let danger = card.value; // 1-10: direct proxy for combo-capture breadth
if (card.id === 'denara_7') danger += 50; // settebello — never dump
else if (card.value === 7) danger += 20; // non-coin 7: top primiera
else if (card.suit === 'denara') danger += 15; // coin card
// High-primiera cards (1=16, 5=15, 6=18) get a small extra penalty.
if (card.value !== 7 && (PRIMIERA_VALUES[card.value] ?? 0) >= 15) danger += 5;
return danger;
}
/**
* When the table is empty, choose the least-dangerous dump move.
*
* Rank 1 (primary): highest count of same face-value in our hand
* — opponent holds fewer of that value → harder to
* capture it with a direct same-value play.
* Rank 2 (secondary): lowest emptyTableDumpDanger() score
* — lower face value (fewer capture combos) and
* not a strategically valuable card (7, denara, etc.).
*/
function pickSafestEmptyTableDump(hand: Card[], legalMoves: AIMove[]): AIMove | null {
const dumps = legalMoves.filter(m => m.capture.length === 0);
if (dumps.length === 0) return null;
const countByValue = new Map<number, number>();
for (const c of hand) countByValue.set(c.value, (countByValue.get(c.value) ?? 0) + 1);
const sorted = [...dumps].sort((a, b) => {
const cntA = countByValue.get(a.card.value) ?? 1;
const cntB = countByValue.get(b.card.value) ?? 1;
if (cntB !== cntA) return cntB - cntA; // more in hand → safer (desc)
return emptyTableDumpDanger(a.card) - emptyTableDumpDanger(b.card); // less danger (asc)
});
return sorted[0];
}
function buildFallbackInference(tracker: CardTracker | undefined): CardInferenceEngine {
return new CardInferenceEngine(tracker ?? new CardTracker());
}
@@ -315,6 +360,12 @@ function advancedMove(
const forced = checkForcedMove(legalMoves, state.table);
if (forced) return forced;
// Early-game: empty table → no captures possible, dump the safest card.
if (state.table.length === 0) {
const safe = pickSafestEmptyTableDump(state.players[playerIdx].hand, legalMoves);
if (safe) return safe;
}
const myTeam = teamOf(playerIdx);
const categoryStates = getCategoryStates(state, myTeam);
const phase = getPhase(state);
@@ -486,6 +537,23 @@ async function masterMove(
}
}
// Early-game: empty table → no captures possible, dump the safest card.
if (state.table.length === 0) {
const safe = pickSafestEmptyTableDump(state.players[playerIdx].hand, legalMoves);
if (safe) {
reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, {
cardsRemaining,
sampleCount: 0,
maxDepth: 0,
completedDepth: 0,
rootMoveCount: legalMoves.length,
timedOut: false,
aspirationExpansions: 0,
});
return safe;
}
}
const myTeam = teamOf(playerIdx);
const categoryStates = getCategoryStates(state, myTeam);
const parityState = analyzeTableParity(state.table);

87
src/game/update-check.ts Normal file
View File

@@ -0,0 +1,87 @@
// ---------------------------------------------------------------------------
// Update-check service
// Polls the Gitea releases API on startup and reports if a newer build exists.
// NOTE: requires the Gitea repo (or at least its releases) to be publicly
// accessible without authentication. On private repos the fetch will silently
// fail and no banner is shown.
// ---------------------------------------------------------------------------
const RELEASES_API =
'https://git.sal.giize.com/api/v1/repos/mozempk/scopone/releases/latest';
const DISMISS_KEY = 'scopone_update_dismissed';
// Build number embedded by Vite from the VITE_APP_BUILD env var set in CI.
// Defaults to 0 (local / dev builds), which makes every real CI build newer.
export const CURRENT_BUILD = (() => {
const raw = import.meta.env.VITE_APP_BUILD;
const n = raw ? parseInt(raw, 10) : 0;
return Number.isFinite(n) ? n : 0;
})();
export interface UpdateInfo {
buildNumber: number;
tagName: string;
/** URL to the Gitea release page. */
releaseUrl: string;
/** Direct download URL for the unsigned release APK, if present in assets. */
apkUrl: string | null;
}
/**
* Fetches the latest Gitea release and returns an UpdateInfo if a build
* newer than `currentBuild` is available. Returns null on any error or
* if the app is already up-to-date.
*/
export async function checkForUpdate(currentBuild: number): Promise<UpdateInfo | null> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5_000);
try {
const resp = await fetch(RELEASES_API, { signal: ac.signal });
if (!resp.ok) return null;
const data = await resp.json() as Record<string, unknown>;
const tagName = typeof data.tag_name === 'string' ? data.tag_name : '';
const match = tagName.match(/^build-(\d+)$/);
if (!match) return null;
const remoteBuild = parseInt(match[1], 10);
if (remoteBuild <= currentBuild) return null;
const assets = Array.isArray(data.assets)
? (data.assets as Array<Record<string, unknown>>)
: [];
const releaseAsset = assets.find(
a => typeof a.name === 'string' && a.name === 'app-release-unsigned.apk',
);
return {
buildNumber: remoteBuild,
tagName,
releaseUrl: typeof data.html_url === 'string' ? data.html_url : '',
apkUrl: releaseAsset && typeof releaseAsset.browser_download_url === 'string'
? releaseAsset.browser_download_url
: null,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Returns true if the user already dismissed this build's update notification. */
export function isDismissed(buildNumber: number): boolean {
try {
return localStorage.getItem(DISMISS_KEY) === String(buildNumber);
} catch {
return false;
}
}
/** Persists the dismiss decision so the banner won't reappear for this build. */
export function dismissUpdate(buildNumber: number): void {
try {
localStorage.setItem(DISMISS_KEY, String(buildNumber));
} catch { /* localStorage unavailable — ignore */ }
}

View File

@@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { Difficulty } from '../game/types';
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
import { checkForUpdate, isDismissed, dismissUpdate, CURRENT_BUILD, UpdateInfo } from '../game/update-check';
type MenuButtonPalette = {
base: number;
@@ -133,6 +134,14 @@ export class MenuScene extends Phaser.Scene {
this.createRulesPanel(layout);
this.createControlPanel(layout, audioPreferences);
// Fire-and-forget update check — shows a dismissible banner if a newer
// CI build is available on Gitea.
checkForUpdate(CURRENT_BUILD).then(info => {
if (info && !isDismissed(info.buildNumber) && this.scene.isActive()) {
this.showUpdateBanner(layout, info);
}
}).catch(() => { /* network unavailable — silent */ });
}
private createLayout(width: number, height: number): MenuLayout {
@@ -576,4 +585,64 @@ export class MenuScene extends Phaser.Scene {
this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' });
});
}
/**
* Renders a slim dismissible notification bar at the bottom of the visible
* area when a newer CI build is available on Gitea.
*
* Layout: [ "Aggiornamento disponibile — build N" | Scarica → | ✕ ]
*/
private showUpdateBanner(layout: MenuLayout, info: UpdateInfo): void {
const bannerH = layout.isCompactViewport ? 40 : 48;
const bannerW = layout.visibleBounds.width - layout.frameInset * 2;
const cx = layout.visibleBounds.centerX;
// Float the banner along the very bottom edge of the visible area.
const cy = layout.visibleBounds.bottom - bannerH / 2 - 4;
const fs = layout.isCompactViewport ? 13 : 15;
const depth = 200;
// Collect all objects so the dismiss handler can destroy them together.
const objs: Phaser.GameObjects.GameObject[] = [];
const track = <T extends Phaser.GameObjects.GameObject>(o: T): T => {
objs.push(o); return o;
};
track(
this.add.rectangle(cx, cy, bannerW, bannerH, 0x0a1e3a, 0.96)
.setStrokeStyle(1, 0x4a90d9, 0.85)
.setDepth(depth),
);
track(
this.add.text(cx - bannerW / 2 + 12, cy,
`Aggiornamento disponibile — build ${info.buildNumber}`,
{ fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#b8d8f8', resolution: 2 },
).setOrigin(0, 0.5).setDepth(depth + 1),
);
// Dismiss (✕) — always present, rightmost.
const dismissBtn = track(
this.add.text(cx + bannerW / 2 - 12, cy, '✕', {
fontFamily: 'Georgia, serif', fontSize: `${fs + 4}px`, color: '#7a8a9a', resolution: 2,
}).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }),
) as Phaser.GameObjects.Text;
dismissBtn.on('pointerover', () => dismissBtn.setColor('#c0d0e0'));
dismissBtn.on('pointerout', () => dismissBtn.setColor('#7a8a9a'));
dismissBtn.on('pointerdown', () => {
dismissUpdate(info.buildNumber);
objs.forEach(o => o.destroy());
});
// "Scarica →" link — present when a direct APK URL is available.
if (info.apkUrl) {
const dlBtn = track(
this.add.text(cx + bannerW / 2 - 38, cy, 'Scarica →', {
fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#5ba3e8', resolution: 2,
}).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }),
) as Phaser.GameObjects.Text;
dlBtn.on('pointerover', () => dlBtn.setColor('#90c8ff'));
dlBtn.on('pointerout', () => dlBtn.setColor('#5ba3e8'));
dlBtn.on('pointerdown', () => window.open(info.apkUrl!, '_blank'));
}
}
}

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Gitea run number injected at build time by CI (0 in local dev builds). */
readonly VITE_APP_BUILD: string | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}