// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello, // primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari. import { DECKS, cardName } from './briscola.js'; export { cardName }; export const LEVELS = ['bona', 'seria', 'male']; export const TARGET = 11; // I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi // volano sul mucchietto di chi ha preso. export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35; export const COIN = { toscane: 'mattoni', napoletane: 'denari' }; // Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14, // 3 13, 2 12, le figure 10. const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10]; const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) => Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] })))); export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)])); export const other = who => who === 'player' ? 'cpu' : 'player'; const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0; function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; } function shuffle(cards, random) { for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; } return cards; } // Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a // scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria. export function captures(table, card) { const same = table.filter(a => a.value === card.value); if (same.length) return same.map(a => [a]); const out = []; for (let mask = 1; mask < 1 << table.length; mask++) { const pick = table.filter((_, i) => mask & 1 << i); if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick); } return out; } const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7; // Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa. export function primiera(cards, deck) { const suits = DECKS[deck].suits; if (!suits.every(s => cards.some(a => a.suit === s))) return 0; return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0); } // I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa. export function tally(taken, scope, deck) { const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null; const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck); rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) }); rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) }); const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu'; rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven }); rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) }); const points = { player: 0, cpu: 0 }; for (const r of rows) if (r.winner) points[r.winner]++; rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null }); points.player += scope.player; points.cpu += scope.cpu; return { rows, points }; } function deal(c) { // Con tre o quattro re in tavola si rimescola. let stock; do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); } while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3); c.table = stock.splice(0, 4); c.stock = stock; c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 }; c.current = other(c.dealer); c.lastTaker = null; c.round++; refill(c); c.events.push({ kind: 'card-shuffle' }); } // Tre carte a testa, prima a chi gioca per primo. function refill(c) { for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift()); c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null; } export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) { const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 }, round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null }; c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu'; deal(c); return c; } export const canPlay = c => c.step === 'play' && c.current === 'player'; export function select(c, index) { if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; } if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false; c.selected = index; return true; } export function nudge(c, direction) { if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); } const n = c.hands.player.length; return select(c, (c.selected + direction + n) % n); } // Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»). export function play(c, index = c.selected, who = 'player', option = null) { if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false; const options = captures(c.table, c.hands[who][index]); if (options.length > 1 && option === null) { if (who !== 'player') return false; c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true; } return execute(c, who, index, options.length ? options[option ?? 0] : null); } export function take(c, k = c.choice?.selected) { if (c.step !== 'choose' || !c.choice.options[k]) return false; const { index, options } = c.choice; c.step = 'play'; c.choice = null; return execute(c, 'player', index, options[k]); } export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; } function execute(c, who, index, captured) { if (!captured && captures(c.table, c.hands[who][index]).length) return false; const before = [...c.table], [card] = c.hands[who].splice(index, 1); if (captured) { const ids = new Set(captured.map(a => a.id)); c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who; } else c.table.push(card); // L'ultima carta della smazzata non fa scopa. const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length; const scopa = !!captured && !c.table.length && !lastPlay; if (scopa) c.scope[who]++; // `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa. c.last = { who, card, captured: captured ?? [], scopa, from: index, before }; c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : [])); c.step = 'take'; c.t = 0; if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1)); return true; } function after(c) { c.current = other(c.last.who); c.t = 0; c.step = 'play'; if (c.hands.player.length || c.hands.cpu.length) return; if (c.stock.length) { refill(c); return; } // Fine smazzata: quel che resta in tavola va all'ultimo che ha preso. if (c.lastTaker) c.taken[c.lastTaker].push(...c.table); c.table = []; const t = tally(c.taken, c.scope, c.deck); c.score.player += t.points.player; c.score.cpu += t.points.cpu; c.summary = { ...t, score: { ...c.score } }; c.t = 0; // Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria. if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; } else c.step = 'smazzata'; } // Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra. export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu; // Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo. export function advance(c) { if (c.step !== 'smazzata') return false; c.dealer = other(c.dealer); deal(c); return true; } // La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte). export function observation(c, who = 'cpu') { return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] }, opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope }, lastTaker: c.lastTaker }; } // Le carte che non si sono viste: in mano all'altro o nel mazzo. export function unseen(v) { const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id)); return DECK_CARDS[v.deck].filter(a => !seen.has(a.id)); } // Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare // una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore // possono essere ancora in mano all'altro. export function score(v, card, captured, counting) { const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount; const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card]; // La primiera vale per quanto migliora la propria: un 7 di un seme dove si ha già un 7 non aggiunge niente, // la prima carta di un seme che manca aggiunge parecchio. const mine = v.taken[v.who] ?? [], best = suit => Math.max(0, ...mine.filter(a => a.suit === suit).map(a => a.prime)); const prime = a => Math.max(0, a.prime - best(a.suit)) / 4 + (best(a.suit) ? 0 : 1); const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + prime(a); let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6; const sum = left.reduce((n, a) => n + a.value, 0); if (left.length && sum <= 10 && v.opponentCount) { let p = 0.5; if (counting) { const pool = unseen(v), same = pool.filter(a => a.value === sum).length; p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0; } value -= p * 20; } // Lasciare in tavola un 7 o il settebello vuol dire regalarli. value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0); return value; } export function moves(v) { return v.hand.flatMap((card, index) => { const opts = captures(v.table, card); return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }]; }); } // «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più. // Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 96% contro «Alla bona», 76% contro «Seria», 50% contro «A fassi male». // A mazzo finito le carte dell'altro sono proprio quelle non viste: si calcola il finale esatto, fino al conteggio // (carte, mattoni, settebello, primiera, scope, e la tavola all'ultimo che prende). export function endgame(v) { const me = v.who, them = other(me), memo = new Map(); const final = (taken, scope, table, last) => { const all = { player: [...taken.player], cpu: [...taken.cpu] }; if (last) all[last].push(...table); const t = tally(all, scope, v.deck); return t.points[me] - t.points[them]; }; const solve = (hands, table, taken, scope, last, turn) => { if (!hands.player.length && !hands.cpu.length) return final(taken, scope, table, last); const key = `${turn}|${hands[me].map(a => a.id)}|${hands[them].map(a => a.id)}|${table.map(a => a.id).sort()}|${last}|${taken.player.length}|${scope.player}${scope.cpu}`; if (memo.has(key)) return memo.get(key); let best = turn === me ? -Infinity : Infinity; for (const m of moves({ hand: hands[turn], table })) { const r = step(hands, table, taken, scope, last, turn, m); const value = solve(...r); best = turn === me ? Math.max(best, value) : Math.min(best, value); } memo.set(key, best); return best; }; const step = (hands, table, taken, scope, last, turn, m) => { const card = hands[turn][m.index], rest = { ...hands, [turn]: hands[turn].filter((_, i) => i !== m.index) }; if (!m.captured) return [rest, [...table, card], taken, scope, last, other(turn)]; const ids = new Set(m.captured.map(a => a.id)), left = table.filter(a => !ids.has(a.id)); const end = !rest.player.length && !rest.cpu.length; return [rest, left, { ...taken, [turn]: [...taken[turn], card, ...m.captured] }, !left.length && !end ? { ...scope, [turn]: scope[turn] + 1 } : scope, turn, other(turn)]; }; const hands = { [me]: v.hand, [them]: unseen(v) }; const start = [hands, v.table, v.taken, v.scope ?? { player: 0, cpu: 0 }, v.lastTaker ?? null, me]; return moves(v).map(m => ({ m, value: solve(...step(...start, m)) })).reduce((a, b) => b.value > a.value ? b : a).m; } export function choose(v, level = 'seria', seed = 1) { const all = moves(v); if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)]; const counting = level === 'male'; if (counting && !v.stock && unseen(v).length === v.opponentCount) return endgame(v); return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a); } export function update(c, dt) { if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return; c.t += dt; if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c); else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) { c.aiSeed = lcg(c.aiSeed); const m = choose(observation(c, 'cpu'), c.level, c.aiSeed); play(c, m.index, 'cpu', m.option); } } export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) { if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`; if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`; if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.'; if (c.step === 'take') { const l = c.last; return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`; } // A parole anche la tavola, per chi non vede il disegno. const table = `In tavola: ${c.table.map(cardName).join(', ') || 'niente'}.`; return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} pensa…`; }