diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 6734777..9b47e24 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write # required for creating releases packages: write jobs: @@ -16,6 +16,9 @@ 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 21 @@ -75,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 ──────────────────────────────────────────────────── @@ -136,3 +143,50 @@ jobs: app-release-unsigned.apk 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) ── + LAST_TAG=$(git tag --sort=-creatordate | grep -E '^build-[0-9]+$' | head -1 || true) + if [[ -n "$LAST_TAG" ]]; then + COMMIT_LOG=$(git log --oneline "${LAST_TAG}..HEAD" 2>/dev/null || git log --oneline | head -30) + else + COMMIT_LOG=$(git log --oneline | head -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 the release ─────────────────────────────────────────────── + 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)" + + # ── 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" diff --git a/src/game/ai.ts b/src/game/ai.ts index c4a9e68..373ea4b 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -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(); + 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); diff --git a/src/game/update-check.ts b/src/game/update-check.ts new file mode 100644 index 0000000..8449b7d --- /dev/null +++ b/src/game/update-check.ts @@ -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 { + 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; + 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>) + : []; + 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 */ } +} diff --git a/src/scenes/MenuScene.ts b/src/scenes/MenuScene.ts index a010753..f46857c 100644 --- a/src/scenes/MenuScene.ts +++ b/src/scenes/MenuScene.ts @@ -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 = (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')); + } + } } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..8c7ed77 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +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; +}