fix(SCOPONE-0011): complete iteration 0 - tune ai and ui
This commit is contained in:
@@ -245,24 +245,26 @@ const RAW_FIXTURES: RawFixture[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'safe-low-dump',
|
||||
name: 'Safe Low Dump',
|
||||
description: 'The search should prefer the lone safe release over cards that either capture or create leverage for the next player.',
|
||||
tags: ['table-control'],
|
||||
dealer: 2,
|
||||
id: 'safe-scopa-conversion',
|
||||
name: 'Safe Scopa Conversion',
|
||||
description: 'When a clean sweep is available, the search should take the safe scopa instead of settling for a smaller direct capture.',
|
||||
tags: ['safe-scopa', 'scopa-window'],
|
||||
dealer: 3,
|
||||
currentPlayer: 0,
|
||||
handSizes: [5, 5, 5, 5],
|
||||
hands: [[
|
||||
'coppe_10',
|
||||
'spade_8',
|
||||
'bastoni_6',
|
||||
'denara_4',
|
||||
'coppe_3',
|
||||
'bastoni_10',
|
||||
'denara_6',
|
||||
'coppe_9',
|
||||
'bastoni_3',
|
||||
'spade_2',
|
||||
], undefined, undefined, undefined],
|
||||
table: ['denara_2', 'spade_9', 'bastoni_4', 'coppe_5'],
|
||||
piles: PILES_TEMPLATE_A,
|
||||
table: ['coppe_4', 'spade_6'],
|
||||
pileCardCounts: [5, 4, 5, 4],
|
||||
totalPoints: [8, 8],
|
||||
expectedMove: {
|
||||
cardId: 'coppe_3',
|
||||
cardId: 'bastoni_10',
|
||||
captureIds: ['coppe_4', 'spade_6'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -337,25 +339,25 @@ const RAW_FIXTURES: RawFixture[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'direct-eight-conversion',
|
||||
name: 'Direct Eight Conversion',
|
||||
description: 'The direct eight capture should be preferred when it removes the strongest immediate counter-card from the table.',
|
||||
tags: ['material-swing'],
|
||||
dealer: 2,
|
||||
id: 'duplicate-rank-opening-release',
|
||||
name: 'Duplicate Rank Opening Release',
|
||||
description: 'On an empty opening table, the search should release the non-denari duplicate high rank instead of opening with weaker denari or singleton alternatives.',
|
||||
tags: ['opening-release', 'duplicate-rank'],
|
||||
dealer: 3,
|
||||
currentPlayer: 0,
|
||||
handSizes: [5, 5, 5, 5],
|
||||
hands: [[
|
||||
'coppe_10',
|
||||
'spade_5',
|
||||
'coppe_8',
|
||||
'denara_8',
|
||||
'bastoni_9',
|
||||
'coppe_3',
|
||||
'denara_7',
|
||||
'denara_6',
|
||||
'bastoni_2',
|
||||
], undefined, undefined, undefined],
|
||||
table: ['denara_2', 'coppe_8', 'bastoni_4', 'spade_9'],
|
||||
piles: PILES_TEMPLATE_A,
|
||||
table: [],
|
||||
pileCardCounts: [5, 5, 5, 5],
|
||||
totalPoints: [7, 7],
|
||||
expectedMove: {
|
||||
cardId: 'denara_8',
|
||||
captureIds: ['coppe_8'],
|
||||
cardId: 'coppe_8',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
154
src/game/ai.ts
154
src/game/ai.ts
@@ -721,6 +721,72 @@ function getPriorityThreatSummary(
|
||||
return countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
|
||||
}
|
||||
|
||||
function isImmediateTacticalConcession(
|
||||
afterTable: Card[],
|
||||
nextIsOpp: boolean,
|
||||
threats: ScopaThreatSummary | null,
|
||||
): boolean {
|
||||
if (!nextIsOpp || afterTable.length === 0) return false;
|
||||
|
||||
const tableSum = sumCardValues(afterTable);
|
||||
if (afterTable.length === 1 && tableSum <= 10) return true;
|
||||
if (afterTable.length === 2 && tableSum <= 10) return true;
|
||||
|
||||
return Boolean(threats?.nextOppCanScopa);
|
||||
}
|
||||
|
||||
function evaluateSafeScopaPriority(
|
||||
clearsTable: boolean,
|
||||
afterTable: Card[],
|
||||
lastPlay: boolean,
|
||||
nextIsOpp: boolean,
|
||||
threats: ScopaThreatSummary | null,
|
||||
): number {
|
||||
if (!clearsTable || lastPlay) return 0;
|
||||
return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2;
|
||||
}
|
||||
|
||||
function evaluateFirstHandOpeningReleasePriority(
|
||||
card: Card,
|
||||
myHand: Card[],
|
||||
projectedHand: Card[],
|
||||
afterTable: Card[],
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
tracker: CardTracker | undefined,
|
||||
nextIsOpp: boolean,
|
||||
roleContext: DealerRoleContext,
|
||||
): number {
|
||||
if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length;
|
||||
if (nextHandSize <= 0) return 0;
|
||||
|
||||
const sameValueCount = countValueInHand(myHand, card.value);
|
||||
const immediateScopaRisk = handLikelyHasValue(
|
||||
card.value,
|
||||
nextHandSize,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
projectedHand,
|
||||
afterTable,
|
||||
);
|
||||
|
||||
let score = 0;
|
||||
score += Math.max(0, sameValueCount - 1) * 2;
|
||||
if (sameValueCount >= 3) score += 2;
|
||||
score += Math.round((0.32 - immediateScopaRisk) * 12);
|
||||
|
||||
if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 1;
|
||||
if (card.suit === 'denara') score -= 1;
|
||||
if (card.value === 7) score -= 1;
|
||||
|
||||
return clampPriorityBand(score, -8, 8);
|
||||
}
|
||||
|
||||
function evaluateAntiScopaPriority(
|
||||
afterTable: Card[],
|
||||
nextIsOpp: boolean,
|
||||
@@ -1062,10 +1128,12 @@ function scoreCaptureAdv(
|
||||
): number {
|
||||
const allCaptured = [played, ...captured];
|
||||
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
||||
const projectedHand = myHand.filter(card => card.id !== played.id);
|
||||
const isScopa = afterTable.length === 0;
|
||||
const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7);
|
||||
const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7);
|
||||
const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx);
|
||||
const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx);
|
||||
const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats);
|
||||
let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6;
|
||||
|
||||
material += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 14 : 8);
|
||||
@@ -1109,7 +1177,7 @@ function scoreCaptureAdv(
|
||||
if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10;
|
||||
|
||||
return scoreTacticalPriorityLadder({
|
||||
scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0,
|
||||
scopa: scopaPriority,
|
||||
settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0,
|
||||
antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats),
|
||||
partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
|
||||
@@ -1126,10 +1194,22 @@ function scoreDumpAdv(
|
||||
lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null,
|
||||
): number {
|
||||
const afterTable = [...table, card];
|
||||
const projectedHand = myHand.filter(held => held.id !== card.id);
|
||||
|
||||
// --- HARD RULES ---
|
||||
if (card.suit === 'denara' && card.value === 7) return -10000;
|
||||
const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx);
|
||||
const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx);
|
||||
const openingReleasePriority = evaluateFirstHandOpeningReleasePriority(
|
||||
card,
|
||||
myHand,
|
||||
projectedHand,
|
||||
afterTable,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
nextIsOpp,
|
||||
roleContext,
|
||||
);
|
||||
let material = -20 + phase * 6;
|
||||
|
||||
if (card.suit === 'denara') material -= race.behindInDenari ? 28 : 16;
|
||||
@@ -1186,7 +1266,7 @@ function scoreDumpAdv(
|
||||
return scoreTacticalPriorityLadder({
|
||||
scopa: 0,
|
||||
settebello: 0,
|
||||
antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats),
|
||||
antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority,
|
||||
partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
|
||||
sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s),
|
||||
denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari),
|
||||
@@ -1947,18 +2027,37 @@ function scoreControlOverrideCandidate(
|
||||
playerIdx: PlayerIndex,
|
||||
race: RaceState,
|
||||
roleContext: DealerRoleContext,
|
||||
tracker: CardTracker | undefined,
|
||||
): number {
|
||||
const hand = state.players[playerIdx].hand;
|
||||
const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
|
||||
const lastPlay = isLastPlay(state, playerIdx);
|
||||
const summary = summarizeMoveTactics(move, hand, state.table);
|
||||
const projectedHand = hand.filter(card => card.id !== move.card.id);
|
||||
const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx);
|
||||
const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats);
|
||||
const openingReleasePriority = move.capture.length === 0
|
||||
? evaluateFirstHandOpeningReleasePriority(
|
||||
move.card,
|
||||
hand,
|
||||
projectedHand,
|
||||
summary.projectedTable,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
nextIsOpp,
|
||||
roleContext,
|
||||
)
|
||||
: 0;
|
||||
let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55);
|
||||
|
||||
score += summary.projectedTable.length * 48;
|
||||
score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260;
|
||||
score += scopaPriority * 600;
|
||||
|
||||
if (move.capture.length === 0) {
|
||||
if (summary.highQuietRelease) score += 220;
|
||||
score += openingReleasePriority * 180;
|
||||
if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70;
|
||||
if (nextIsOpp && summary.projectedTable.length >= 5) score += 110;
|
||||
if (
|
||||
@@ -1972,6 +2071,9 @@ function scoreControlOverrideCandidate(
|
||||
}
|
||||
} else {
|
||||
if (!isForcingSearchMove(summary, race)) score -= 200;
|
||||
if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) {
|
||||
score -= 180;
|
||||
}
|
||||
if (nextIsOpp && summary.projectedTable.length <= 3) score -= 150;
|
||||
if (nextIsOpp) score -= summary.exposedDenariCount * 90;
|
||||
if (nextIsOpp) score -= summary.exposedSevenCount * 70;
|
||||
@@ -2004,9 +2106,11 @@ function findStrategicControlOverride(
|
||||
playerIdx: PlayerIndex,
|
||||
race: RaceState,
|
||||
roleContext: DealerRoleContext,
|
||||
tracker: CardTracker | undefined,
|
||||
): AIMove | undefined {
|
||||
if (legalMoves.length <= 1) return undefined;
|
||||
if (isLastPlay(state, playerIdx)) return undefined;
|
||||
const lastPlay = isLastPlay(state, playerIdx);
|
||||
if (lastPlay) return undefined;
|
||||
if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined;
|
||||
|
||||
let bestQuiet:
|
||||
@@ -2015,9 +2119,21 @@ function findStrategicControlOverride(
|
||||
let bestCapture:
|
||||
| { move: AIMove; score: number }
|
||||
| undefined;
|
||||
let bestSafeScopa:
|
||||
| { move: AIMove; score: number }
|
||||
| undefined;
|
||||
|
||||
for (const move of legalMoves) {
|
||||
const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext);
|
||||
const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker);
|
||||
const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table);
|
||||
const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id);
|
||||
const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx);
|
||||
const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats);
|
||||
|
||||
if (scopaPriority > 0) {
|
||||
if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score };
|
||||
}
|
||||
|
||||
if (move.capture.length === 0) {
|
||||
if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score };
|
||||
continue;
|
||||
@@ -2026,6 +2142,8 @@ function findStrategicControlOverride(
|
||||
if (!bestCapture || score > bestCapture.score) bestCapture = { move, score };
|
||||
}
|
||||
|
||||
if (bestSafeScopa) return bestSafeScopa.move;
|
||||
|
||||
if (!bestQuiet) return undefined;
|
||||
|
||||
const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table);
|
||||
@@ -2216,7 +2334,7 @@ async function masterMove(
|
||||
roleContext,
|
||||
rankResidue,
|
||||
);
|
||||
const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext);
|
||||
const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext, tracker);
|
||||
if (controlOverride) {
|
||||
reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, {
|
||||
cardsRemaining,
|
||||
@@ -2395,13 +2513,28 @@ function quickEval(
|
||||
const projectedTableHasDenari = projectedTable.some(card => card.suit === 'denara');
|
||||
const projectedTableHasSeven = projectedTable.some(card => card.value === 7);
|
||||
const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
|
||||
const threats = getPriorityThreatSummary(projectedTable, projectedHand, tracker, state, playerIdx);
|
||||
const scopaPriority = evaluateSafeScopaPriority(moveSummary.clearsTable, projectedTable, lastPlay, nextIsOpp, threats);
|
||||
const openingReleasePriority = move.capture.length === 0
|
||||
? evaluateFirstHandOpeningReleasePriority(
|
||||
move.card,
|
||||
hand,
|
||||
projectedHand,
|
||||
projectedTable,
|
||||
state,
|
||||
playerIdx,
|
||||
tracker,
|
||||
nextIsOpp,
|
||||
roleContext,
|
||||
)
|
||||
: 0;
|
||||
const capturesSettebello = move.capture.some(card => card.suit === 'denara' && card.value === 7);
|
||||
const tableHasSettebello = table.some(card => card.suit === 'denara' && card.value === 7);
|
||||
const tableHasDenari = table.some(card => card.suit === 'denara');
|
||||
|
||||
// Scopa (not on last play!)
|
||||
if (move.capture.length > 0 && projectedTable.length === 0) {
|
||||
score += lastPlay ? 50 : 1200;
|
||||
score += lastPlay ? 50 : scopaPriority * 780;
|
||||
}
|
||||
|
||||
// Settebello
|
||||
@@ -2430,6 +2563,7 @@ function quickEval(
|
||||
|
||||
if (move.capture.length === 0) {
|
||||
score -= 200;
|
||||
score += openingReleasePriority * 220;
|
||||
if (move.card.value >= 8) score += 40;
|
||||
if (move.card.suit === 'denara') score -= 130;
|
||||
if (move.card.value === 7) score -= 100;
|
||||
@@ -2480,6 +2614,10 @@ function quickEval(
|
||||
}
|
||||
}
|
||||
|
||||
if (move.capture.length > 0 && !moveSummary.clearsTable && isImmediateTacticalConcession(projectedTable, nextIsOpp, threats)) {
|
||||
score -= 180;
|
||||
}
|
||||
|
||||
// Partner awareness
|
||||
const next = nextPlayer(playerIdx);
|
||||
if (!isOpponent(playerIdx, next) && projectedTable.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user