From 9524161481593d7f85edc085927446e4813a3e78 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Tue, 31 Mar 2026 19:59:38 +0200 Subject: [PATCH 1/2] feat(SCOPONE-0003): allow player to choose capture target - findCaptures() returns each direct match as separate option plus sum subsets - highlightMultipleCaptures() uses distinct colors per capture option - clicking highlighted table cards confirms that option's capture --- src/game/engine.ts | 12 ++++-------- src/scenes/GameScene.ts | 29 +++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/game/engine.ts b/src/game/engine.ts index 2bc96d4..6b2fdd6 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -41,17 +41,13 @@ export function shuffle(arr: T[]): T[] { export function findCaptures(played: Card, table: Card[]): Card[][] { const results: Card[][] = []; - // Check for direct value matches + // Each direct-match card is a separate single-card capture option const directMatches = table.filter(c => c.value === played.value); - if (directMatches.length > 0) { - // Must capture exactly one matching card (Italian rules: take one direct match) - // Actually: if there's one direct match take it; if multiple, still take all that match - // Standard Italian rule: take ALL cards of matching value - results.push([...directMatches]); - return results; // direct match takes priority, no sum captures allowed + for (const dm of directMatches) { + results.push([dm]); } - // No direct matches — find subsets that sum to played.value + // Also find multi-card subsets that sum to played.value const subsets = getSubsets(table); for (const subset of subsets) { if (subset.length >= 2) { diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 3dcff8d..1e8434c 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -674,32 +674,45 @@ export class GameScene extends Phaser.Scene { private highlightMultipleCaptures(captures: Card[][]): void { this.clearHighlights(); const W = this.scale.width; + + // Distinct color palette for each capture option + const palette = [ + { fill: 0x00cc66, stroke: 0x00ff88, text: '#00ffaa', bg: 0x001a0a }, + { fill: 0x3399ff, stroke: 0x66bbff, text: '#88ccff', bg: 0x001020 }, + { fill: 0xff8833, stroke: 0xffaa55, text: '#ffcc88', bg: 0x1a0d00 }, + { fill: 0xcc44cc, stroke: 0xff66ff, text: '#ff88ff', bg: 0x1a001a }, + { fill: 0x00cccc, stroke: 0x44ffff, text: '#88ffff', bg: 0x001a1a }, + ]; + captures.forEach((cap, i) => { + const color = palette[i % palette.length]; const label = cap.map(cardName).join(' + '); const y = SCOREBAR_H + 70 + i * 36; const bg = this.add.graphics().setDepth(20); - bg.fillStyle(0x001a0a, 0.9); + bg.fillStyle(color.bg, 0.9); bg.fillRoundedRect(W / 2 - 180, y - 14, 360, 28, 7); - bg.lineStyle(1, 0x00ff88, 0.7); + bg.lineStyle(2, color.stroke, 0.8); bg.strokeRoundedRect(W / 2 - 180, y - 14, 360, 28, 7); const btn = this.add.zone(W / 2, y, 360, 28).setInteractive({ useHandCursor: true }).setDepth(21); const txt = this.add.text(W / 2, y, `Cattura: ${label}`, { - fontFamily: 'serif', fontSize: '14px', color: '#00ffaa', + fontFamily: 'serif', fontSize: '14px', color: color.text, }).setOrigin(0.5).setDepth(21); btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap)); (bg as any)._captureBtn = true; this.tableHighlights.push(bg, btn, txt); - }); - for (const cap of captures) { + + // Highlight table cards belonging to this option with the matching color for (const c of cap) { const img = this.cardImages.get(c.id); if (img) { - const hl = this.add.rectangle(img.x, img.y, CW_H + 8, CH_H + 8, 0xffff00, 0.15) - .setStrokeStyle(2, 0xffff00, 0.8).setDepth(4); + const hl = this.add.rectangle(img.x, img.y, CW_H + 8, CH_H + 8, color.fill, 0.2) + .setStrokeStyle(2, color.stroke, 0.9).setDepth(4); this.tableHighlights.push(hl); + img.setInteractive({ useHandCursor: true }); + img.once('pointerdown', () => this.confirmMove(this.selectedCard!, cap)); } } - } + }); } private highlightTableForDump(card: Card): void { From 21384c81918e26722d874e0669775ab67c13842e Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Tue, 31 Mar 2026 21:07:28 +0200 Subject: [PATCH 2/2] chore: update findings and atlas --- docs/FINDINGS.md | 21 ++++++++++++++++++++- public/atlas.json | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index b3cf0a5..0b4fc19 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -83,10 +83,29 @@ A team missing an entire suit **cannot win primiera** (even 3×21=63 loses to 21 ### Codebase Capture Rule Validation The `findCaptures()` in `engine.ts` correctly implements: - Direct match priority over sum captures ✓ -- Multiple direct matches: takes ALL matching cards (slight deviation — pagat.com says choose ONE, but Wikipedia says take all. The codebase takes all direct matches. This is the **existing behavior** and must not be altered per success criteria.) +- Multiple direct matches: takes ALL matching cards (slight deviation — pagat.com says choose ONE, but Wikipedia says take all. The codebase takes all direct matches.) - Sum subsets via power set enumeration ✓ - `applyMove()` auto-captures when possible ✓ +### SCOPONE-0003: findCaptures() Change Analysis (2026-03-31) + +**Current behavior** (engine.ts lines 43-62): When direct matches exist, `findCaptures()` bundles ALL into one `Card[]` and returns immediately (`results.push([...directMatches]); return results`). Sum captures are never computed when direct matches exist. + +**Required behavior**: +1. Each direct match returned as a **separate** single-card option: `[match_a]`, `[match_b]`, etc. +2. Sum captures computed **alongside** direct matches (removing the early return). +3. Both direct-match and sum options presented to the player for choice. + +**Mathematical proof that sums never include direct-match cards**: A direct-match card has value V = played.value. Any subset of size ≥ 2 containing this card sums to at least V + 1 (minimum card value is 1). Since we're looking for subsets summing to exactly V, no valid sum can ever include a direct-match card. Therefore, computing sums from the entire table is safe — no filtering needed. + +**Callers impacted**: +- `canCapture()` (engine.ts:77): Uses `.length > 0` — unaffected. +- `applyMove()` (engine.ts:139): Uses `captures[0]` as default — now defaults to the first direct-match card (single card) instead of all bundled matches. Correct behavior. +- `chooseMove()` (ai.ts:23): Iterates and scores all capture sets — now scores each direct match individually. **Improvement**: AI can now prefer the higher-value direct match (e.g., 7♦ over 7♣). +- `opponentThreatScore()` (ai.ts:151): Uses `caps[0].length` — changes from N to 1 for multi-match scenarios. More accurate (opponent captures one card, not all). +- `scoreDump()` (ai.ts:103): Uses `caps` for threat checking — unaffected. +- `GameScene.onCardClick()`: Already routes `captures.length > 1` to `highlightMultipleCaptures()` — works correctly with new behavior. + ### Minimax Feasibility Analysis - 10 cards per player × 4 players = 40 total moves per round - Full game tree: ~10^12 nodes — infeasible for exhaustive search diff --git a/public/atlas.json b/public/atlas.json index 7dad1b5..9e8491e 100644 --- a/public/atlas.json +++ b/public/atlas.json @@ -805,4 +805,4 @@ "image": "atlas.png", "scale": "1" } -} \ No newline at end of file +}