feat: CI creates Gitea releases with changelog, app polls for updates on startup
Some checks failed
Android Build & Publish / android (push) Failing after 2m0s
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)
This commit is contained in:
@@ -5,7 +5,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write # required for creating releases
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -16,6 +16,9 @@ jobs:
|
|||||||
# ── 1. Source ────────────────────────────────────────────────────────────
|
# ── 1. Source ────────────────────────────────────────────────────────────
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags required for the changelog step.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# ── 2. Java ──────────────────────────────────────────────────────────────
|
# ── 2. Java ──────────────────────────────────────────────────────────────
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
@@ -75,6 +78,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build web assets
|
- 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
|
run: npm run build
|
||||||
|
|
||||||
# ── 7. Capacitor sync ────────────────────────────────────────────────────
|
# ── 7. Capacitor sync ────────────────────────────────────────────────────
|
||||||
@@ -136,3 +143,50 @@ jobs:
|
|||||||
app-release-unsigned.apk
|
app-release-unsigned.apk
|
||||||
|
|
||||||
echo "📦 Package index: $BASE/$VERSION/"
|
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"
|
||||||
|
|||||||
@@ -239,6 +239,51 @@ function checkForcedMove(legalMoves: AIMove[], table: Card[]): AIMove | null {
|
|||||||
return 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 {
|
function buildFallbackInference(tracker: CardTracker | undefined): CardInferenceEngine {
|
||||||
return new CardInferenceEngine(tracker ?? new CardTracker());
|
return new CardInferenceEngine(tracker ?? new CardTracker());
|
||||||
}
|
}
|
||||||
@@ -315,6 +360,12 @@ function advancedMove(
|
|||||||
const forced = checkForcedMove(legalMoves, state.table);
|
const forced = checkForcedMove(legalMoves, state.table);
|
||||||
if (forced) return forced;
|
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 myTeam = teamOf(playerIdx);
|
||||||
const categoryStates = getCategoryStates(state, myTeam);
|
const categoryStates = getCategoryStates(state, myTeam);
|
||||||
const phase = getPhase(state);
|
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 myTeam = teamOf(playerIdx);
|
||||||
const categoryStates = getCategoryStates(state, myTeam);
|
const categoryStates = getCategoryStates(state, myTeam);
|
||||||
const parityState = analyzeTableParity(state.table);
|
const parityState = analyzeTableParity(state.table);
|
||||||
|
|||||||
87
src/game/update-check.ts
Normal file
87
src/game/update-check.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { Difficulty } from '../game/types';
|
import { Difficulty } from '../game/types';
|
||||||
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
|
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
|
||||||
|
import { checkForUpdate, isDismissed, dismissUpdate, CURRENT_BUILD, UpdateInfo } from '../game/update-check';
|
||||||
|
|
||||||
type MenuButtonPalette = {
|
type MenuButtonPalette = {
|
||||||
base: number;
|
base: number;
|
||||||
@@ -133,6 +134,14 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.createRulesPanel(layout);
|
this.createRulesPanel(layout);
|
||||||
this.createControlPanel(layout, audioPreferences);
|
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 {
|
private createLayout(width: number, height: number): MenuLayout {
|
||||||
@@ -576,4 +585,64 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' });
|
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
10
src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user