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)
This commit is contained in:
Giancarmine Salucci
2026-05-25 09:39:08 +02:00
parent 49e51748d7
commit b2a84eb167
5 changed files with 289 additions and 1 deletions

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);