Files
leggenda-del-regolo/src/engine/scopa.js
T

256 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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…`;
}