Scopa agli 11 all'oratorio, e il tressette rivisto dopo la revisione critica di Codex

Scopa: toscane o napoletane (mattoni o denari), presa obbligatoria, la carta uguale prima delle somme, scelta fra più prese toccando la tavola, coi pulsanti o con frecce e Invio; scope (non all'ultima carta), carte, mattoni, settebello, primiera; tre livelli tarati (92, 64, 52%); test e prova nel browser.
Tressette: fuori ai 21 presa per presa, vittoria subito definitiva (Esc non la cancella più), accuse dette al proprio turno e col seme che manca del tris, striscio e busso meno rigidi, il socio risponde al busso, tiene il 3 e il 2, non esce d'Asso scoperto, legge il volo; tastiera col fuoco sulla carta scelta, tavola descritta a parole, riepilogo che aspetta Andrea. Stesso Esc corretto anche nella scopa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 15:59:10 +01:00
co-authored by Claude Opus 5.5
parent e8152563d8
commit 30e324d7e2
25 changed files with 924 additions and 94 deletions
+210
View File
@@ -0,0 +1,210 @@
// 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;
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 [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]++;
c.last = { who, card, captured: captured ?? [], scopa };
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 } };
}
// 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];
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + (a.value === 7 ? 3 : a.value === 6 ? 1.2 : a.value === 1 ? 0.8 : 0);
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 92% contro «Alla bona», 64% contro «Seria», 52% contro «A fassi male».
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';
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 ? 1.3 : 0.6)) 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)}.`;
}
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} pensa…`;
}