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:
@@ -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
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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user