diff --git a/assets/voices/andrea-d8dfb86266.mp3 b/assets/voices/andrea-d8dfb86266.mp3 new file mode 100644 index 0000000..a1453c8 Binary files /dev/null and b/assets/voices/andrea-d8dfb86266.mp3 differ diff --git a/assets/voices/giocatore_carte-445e962a1b.mp3 b/assets/voices/giocatore_carte-445e962a1b.mp3 new file mode 100644 index 0000000..398b0d2 Binary files /dev/null and b/assets/voices/giocatore_carte-445e962a1b.mp3 differ diff --git a/assets/voices/giocatore_carte-9a43eb8b2c.mp3 b/assets/voices/giocatore_carte-9a43eb8b2c.mp3 new file mode 100644 index 0000000..65e638a Binary files /dev/null and b/assets/voices/giocatore_carte-9a43eb8b2c.mp3 differ diff --git a/assets/voices/giocatore_carte-af54c11857.mp3 b/assets/voices/giocatore_carte-af54c11857.mp3 new file mode 100644 index 0000000..bd17095 Binary files /dev/null and b/assets/voices/giocatore_carte-af54c11857.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 94e70f4..2320693 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -1531,6 +1531,10 @@ "file": "andrea-98e4366a56.mp3", "sec": 0.83 }, + "andrea|Una scopa?": { + "file": "andrea-d8dfb86266.mp3", + "sec": 0.65 + }, "andrea|Un tressette ai 21?": { "file": "andrea-4fbe84fada.mp3", "sec": 1.66 @@ -1547,6 +1551,10 @@ "file": "giocatore_carte-8dfadb4acc.mp3", "sec": 3.4 }, + "giocatore_carte|Ai undici. E occhio al settebello: quello è sempre mio.": { + "file": "giocatore_carte-af54c11857.mp3", + "sec": 4.27 + }, "giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": { "file": "giocatore_carte-3e2671e35e.mp3", "sec": 4.88 @@ -2099,6 +2107,14 @@ "file": "giocatore_carte-5dbd39a25f.mp3", "sec": 4.44 }, + "giocatore_carte|Settebello, primiera e pure le scope. Oggi l'hai vista bene.": { + "file": "giocatore_carte-9a43eb8b2c.mp3", + "sec": 5.54 + }, + "giocatore_carte|Undici! Il settebello lo sento arrivare da lontano.": { + "file": "giocatore_carte-445e962a1b.mp3", + "sec": 4.1 + }, "mariano|Aspetta, ci vuole il metro...": { "file": "mariano-e8b4ebf3d9.mp3", "sec": 1.62 diff --git a/docs/SCOPA.md b/docs/SCOPA.md new file mode 100644 index 0000000..1965e02 --- /dev/null +++ b/docs/SCOPA.md @@ -0,0 +1,35 @@ +# Scopa agli 11 + +All'oratorio, dal giocatore di carte: «Una scopa?». In due, Andrea contro il giocatore di carte, con le toscane o +le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Claude), tavolo `src/engine/scopa-render.js`. + +## Regole + +- Valori: Asso 1, poi i numeri, gobbo (fante) 8, donna (cavallo) 9, regio (re) 10. +- Quattro carte in tavola (con tre o quattro re si rimescola), tre a testa; finite le tre, se ne danno altre tre. + Gioca per primo chi non fa le carte; il mazziere cambia a ogni smazzata. +- Una carta prende quella dello stesso valore (se ce n'è più d'una si sceglie quale); se non c'è, un gruppo di carte + che somma il suo valore (più gruppi: si sceglie). Se la presa c'è, è obbligatoria. Se no, la carta resta in tavola. +- **Scopa**: chi pulisce la tavola fa un punto; non vale con l'ultima carta della smazzata. +- A fine smazzata le carte in tavola vanno all'ultimo che ha preso. Un punto per: **carte** (più di 20), **mattoni** + (con le napoletane i denari, più di 5), **settebello** (il 7 di mattoni o di denari), **primiera** (la carta + migliore di ogni seme: 7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12, figure 10; senza un seme non + si fa), e uno per scopa. A pari, il punto non va a nessuno. +- Si va **agli 11**, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), un'altra smazzata. + +## Avversari + +- «Alla bona»: una mossa a caso fra quelle possibili. +- «Seria»: sceglie la mossa che vale di più (settebello, sette, mattoni, primiera, scope) e teme a occhio di + lasciare una scopa. +- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, e non lascia 7 + in tavola. + +Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 92% contro +«Alla bona», 64% contro «Seria», 52% contro «A fassi male»; circa quattro smazzate a partita. + +## Prove + +`npm test` (`tests/scopa.test.mjs`) e `npm run browser:scopa`: partita intera col mouse, una smazzata col tocco +(telefono e orizzontale) e con la tastiera; in ogni caso una presa da scegliere fra due (toccando la tavola, con +un pulsante o con frecce e Invio). Schermate in `/tmp/regolo-shots/scopa-*.png`. diff --git a/docs/STATO.md b/docs/STATO.md index 14b1457..188bd8f 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -25,7 +25,9 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`. **Tressette ai 21** (stesso giocatore, «Un tressette ai 21?»): in quattro a coppie, busso/striscio/volo e accuse, motore e tavolo di Claude, regole e taratura in `docs/TRESSETTE.md`, prova `npm run browser:tressette`. - Prossimo: la **scopa** (7 bello, primiera, carte, mattoni/denari), poi basta coi giochi di carte. + **Scopa agli 11** («Una scopa?»): carte, mattoni (o denari), settebello, primiera e scope, presa a scelta fra + più possibili; `docs/SCOPA.md`, prova `npm run browser:scopa`. Con la scopa i giochi di carte sono finiti + (decisione di Andrea, 26 ottobre). Tressette rivisto dopo la revisione critica di Codex (in `docs/TRESSETTE.md`). - **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci. diff --git a/docs/TRESSETTE.md b/docs/TRESSETTE.md index cb397da..807c644 100644 --- a/docs/TRESSETTE.md +++ b/docs/TRESSETTE.md @@ -10,27 +10,43 @@ Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render. - Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata. - Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. - Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi - avanzati si perdono (11 punti in tutto, a volte 10). -- **Accuse** a inizio smazzata, contate subito nel punteggio: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; - tre Assi, tre 2 o tre 3 fanno 3 punti, quattro fanno 4. Sono pubbliche: chi conta sa dove sono quelle carte. -- **Segni**, solo di chi esce e onesti (il gioco rifiuta quelli falsi): busso (prendi e ritornami il seme; me ne - resta almeno un'altra), striscio (ne ho ancora almeno due), volo (era l'ultima). Si sentono: due nocche sul - tavolo, la carta strusciata, la carta lasciata cadere. -- Si va **ai 21**: vince chi ci arriva a fine smazzata; pari sopra i 21 si gioca un'altra smazzata. + avanzati si perdono: le due coppie fanno sempre 11 punti in tutto. +- **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3 + dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4. + Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari. +- **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho + altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche + sul tavolo, la carta strusciata, la carta lasciata cadere. +- Si va **ai 21**, presa per presa: il punteggio in alto conta accuse e punti interi già presi, e la coppia che + arriva a 21 per prima è fuori e ha vinto, anche a metà smazzata (le accuse contano alla fine della presa in cui + si dicono). Il risultato è subito definitivo: uscire dal riepilogo non lo cancella. Il riepilogo di fine + smazzata resta finché Andrea non va avanti. -Scelte da confermare con Andrea: accuse anche oltre i 21 a inizio smazzata (ora si controlla solo a fine -smazzata); nessun «cappotto»; nessuna napoletana lunga. +Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si +sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa. ## Avversari - «Alla bona»: una carta a caso fra quelle buone, nessun segno. - «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro. -- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme, ricorda le accuse, ritorna il seme al socio - che ha bussato. Il socio di Andrea gioca sempre così. +- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse, + risponde al busso del socio prendendo con la migliore e poi gli ritorna il seme. Il socio di Andrea gioca sempre così. +- Tutti: il carico al socio che prende di sicuro, ma senza buttarci il 3 o il 2; a parità di lunghezza si esce dal + seme con meno punti da esporre (niente Asso scoperto). Taratura (`node tools/calibrate_tressette.mjs 300`, Andrea che gioca come il socio): Andrea e il socio vincono -86% contro «Alla bona», 59% contro «Seria», 52% contro «A fassi male»; circa tre smazzate a partita. +87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male»; circa tre smazzate a partita. + +## Revisione di Codex (26 ottobre) + +Revisione critica sul primo giro. Corretti: vittoria persa uscendo dal riepilogo; accuse tutte pubbliche prima +della prima carta; il seme mancante del tris noto all'IA ma non ad Andrea; striscio troppo stretto e busso con +un requisito inventato; il socio che non rispondeva al busso, sprecava il 3 su una presa già sua o usciva +d'Asso scoperto; il volo non letto da «A fassi male»; tastiera che poteva giocare la carta sbagliata; il +riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole per chi non vede il disegno. +Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione +precedente dell'IA. ## Prove diff --git a/index.html b/index.html index 0df0802..1432bc5 100644 --- a/index.html +++ b/index.html @@ -103,6 +103,24 @@
+ diff --git a/package.json b/package.json index acb4ea1..8991bc0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "browser": "node tests/browser.mjs", "browser:biliardo": "node tests/biliardo.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs", + "browser:scopa": "node tests/scopa.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs", "browser:effects": "node tests/ambient.browser.mjs", "serve": "python3 -m http.server 8000" diff --git a/src/engine/game.js b/src/engine/game.js index 0486ed1..f147ce1 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -5,11 +5,13 @@ import * as bocce from './bocce.js'; import * as biliardo from './biliardo.js'; import * as briscola from './briscola.js'; import * as tressette from './tressette.js'; +import * as scopa from './scopa.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. export const MINIGAMES = { briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play }, tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play }, + scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play }, bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen }, biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen }, }; diff --git a/src/engine/render.js b/src/engine/render.js index 76b1941..38082c4 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js'; import { drawBiliardo } from './biliardo-render.js'; import { drawBriscola } from './briscola-render.js'; import { drawTressette } from './tressette-render.js'; +import { drawScopa } from './scopa-render.js'; import { versioned } from '../version.js'; export const WIDTH = 640, HEIGHT = 360; @@ -670,6 +671,7 @@ function drawCloseup(ctx, game, time) { if (!def) return; if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; } if (def.type === 'tressette') { drawTressette(ctx, c, def); return; } + if (def.type === 'scopa') { drawScopa(ctx, c, def); return; } if (def.type === 'torch') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawTorch(ctx, c, def, backgrounds.get(def.picture)); diff --git a/src/engine/scopa-render.js b/src/engine/scopa-render.js new file mode 100644 index 0000000..d6fd9b7 --- /dev/null +++ b/src/engine/scopa-render.js @@ -0,0 +1,84 @@ +import { canPlay, TARGET } from './scopa.js'; +import { drawCard, drawTable } from './briscola-render.js'; +import { text, panel } from './bocce-render.js'; + +// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola. +export const HAND = { y: 262, w: 64, h: 92, gap: 72 }; +export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap); +const TABLE = { w: 42, h: 60, gap: 47, row: 8 }; +export function tableXY(n, i) { + const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row); + return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132]; +} +export function cardAt(c, [x, y]) { + if (!canPlay(c)) return -1; + return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h); +} +export function tableAt(c, [x, y]) { + return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; }); +} +const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3'; +const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); }; + +function board(ctx, c, names) { + // Un giocatore per riga: i nomi lunghi non si accavallano. + panel(ctx, 26, 34, 150, 50); + for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) { + text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right'); + } + text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center'); + // Il mazzo a sinistra, le prese a destra con le scope. + if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true); + text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center'); + for (const [who, y] of [['cpu', 92], ['player', 196]]) { + if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true); + if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center'); + } +} +function table(ctx, c) { + const take = c.step === 'take' && c.last.captured.length, shown = take ? [...c.table, ...c.last.captured] : c.table; + const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id)); + shown.forEach((card, i) => { + const [x, y] = tableXY(shown.length, i); + if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h); + else if (c.step === 'take' && !take && card.id === c.last.card.id) frame(ctx, x, y, TABLE.w, TABLE.h, '#9fe3ff'); + drawCard(ctx, card, x, y, TABLE.w, TABLE.h); + }); + if (take) { + drawCard(ctx, c.last.card, 299, 200, TABLE.w, TABLE.h); + if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); } + } +} +function hands(ctx, c) { + c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true)); + const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose'; + c.hands.player.forEach((card, i) => { + const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i; + const up = chosen ? 8 : 0; + if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h); + drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h); + }); +} +function summary(ctx, c, names, title) { + const s = c.summary; + panel(ctx, 140, 80, 360, 200); + text(ctx, title, 320, 96, 8, gold, 'center'); + text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center'); + s.rows.forEach((r, i) => { + const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v); + text(ctx, r.label, 160, y, 7, paper); + text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center'); + text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center'); + }); + text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center'); + text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center'); +} +export function drawScopa(ctx, c, def) { + const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' }; + ctx.save(); ctx.imageSmoothingEnabled = false; + drawTable(ctx, 'SCOPA ALL’ORATORIO'); + board(ctx, c, names); table(ctx, c); hands(ctx, c); + if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`); + if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`); + ctx.restore(); +} diff --git a/src/engine/scopa.js b/src/engine/scopa.js new file mode 100644 index 0000000..61883ba --- /dev/null +++ b/src/engine/scopa.js @@ -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…`; +} diff --git a/src/engine/tressette-render.js b/src/engine/tressette-render.js index 2ecf513..eee8d2a 100644 --- a/src/engine/tressette-render.js +++ b/src/engine/tressette-render.js @@ -44,8 +44,8 @@ function scoreboard(ctx, c) { text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center'); } function accuses(ctx, c, names) { - // Le accuse si vedono finché non è finita la prima presa. - if (c.tricks.length || !c.accuse.length) return; + // Le accuse dette restano in vista per tutta la smazzata. + if (!c.accuse.length) return; panel(ctx, 456, 34, 158, 12 + c.accuse.length * 11); c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 463, 41 + i * 11, 5, a.who % 2 ? theirs : ours)); } @@ -87,6 +87,6 @@ export function drawTressette(ctx, c, def) { drawTable(ctx, 'TRESSETTE AI 21'); scoreboard(ctx, c); others(ctx, c, names); accuses(ctx, c, names); trick(ctx, c, names); hand(ctx, c); if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`); - if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Avete vinto!' : 'Hanno vinto loro'); + if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro'); ctx.restore(); } diff --git a/src/engine/tressette.js b/src/engine/tressette.js index f4d33f0..c315d55 100644 --- a/src/engine/tressette.js +++ b/src/engine/tressette.js @@ -27,7 +27,8 @@ function shuffle(cards, random) { const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a); // Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti. -export function accuse(hand) { +// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre. +export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) { const out = [], tops = ['Asso', '2', '3']; for (const suit of new Set(hand.map(a => a.suit))) { const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank)); @@ -35,11 +36,12 @@ export function accuse(hand) { } for (const rank of tops) { const cards = hand.filter(a => a.rank === rank); - if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards }); + if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null }); } return out; } -export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}` : `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}`; +export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}` + : `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`; // Si risponde al seme quando si può; chi esce gioca quel che vuole. export function legal(hand, table) { @@ -47,12 +49,13 @@ export function legal(hand, table) { return same.length ? same : hand.map((_, i) => i); } export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a); -// I segni sono onesti: volo con l'ultima carta del seme, striscio se ne restano almeno due, busso se ne resta una. +// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e +// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi. export function signalOk(hand, index, table, signal) { if (!signal) return true; if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false; const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length; - return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 2 : rest >= 1; + return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true; } // Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene. export function reason(c, index, signal = c.signal) { @@ -60,8 +63,9 @@ export function reason(c, index, signal = c.signal) { if (!card) return 'Scegli una carta.'; if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`; if (!signal || signalOk(hand, index, c.table, signal)) return ''; + if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.'; if (c.table.length) return 'I segni li fa solo chi esce.'; - return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne restano almeno due.`, busso: `Busso solo se di ${card.suit} te ne resta almeno un'altra.` }[signal]; + return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal]; } const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id; @@ -69,11 +73,12 @@ function deal(c) { c.seed = lcg(c.seed); const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order)); - // Fa le carte chi sta a sinistra di chi ha fatto prima; esce chi sta alla destra del mazziere. + // Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere. c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++; - c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; - c.accuse = SEATS.map(i => (c.mano + i) % 4).flatMap(who => accuse(c.hands[who]).map(a => ({ who, ...a }))); - for (const a of c.accuse) c.score[team(a.who)] += a.points; + c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score]; + // Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano. + c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a }))); + c.accuse = []; c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = ''; c.events.push({ kind: 'card-shuffle' }); } @@ -103,6 +108,10 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign if (who === 0) c.refused = reason(c, index, signal); return false; } + if (!c.tricks.length && c.pending[who].length) { + c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c); + c.events.push({ kind: 'accusa', who }); + } const [card] = c.hands[who].splice(index, 1); c.table.push({ who, card, signal: signal || null }); c.t = 0; c.landing = true; // Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto. @@ -112,28 +121,35 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign else c.current = next(who); return true; } +// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi. +const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0); +function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); } export function collect(c) { if (c.step !== 'trick') return false; - const winner = c.last.winner; - c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0); + const winner = c.last.winner, end = !c.hands[0].length; + // L'ultima presa vale un punto; i terzi avanzati non contano. + c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0); c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = []; c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' }); - if (c.hands[0].length) { c.step = 'play'; return true; } - // L'ultima presa vale un punto; i terzi avanzati non contano. - c.thirds[team(winner)] += 3; - const points = c.thirds.map(t => Math.floor(t / 3)), accused = [0, 1].map(t => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0)); - c.score = c.score.map((s, t) => s + points[t]); - c.summary = { thirds: [...c.thirds], points, accused, lastTeam: team(winner), score: [...c.score] }; - c.step = 'smazzata'; + recount(c); + // Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata (una presa porta punti a una + // coppia sola, quindi non ci arrivano mai insieme). + const out = [0, 1].find(t => c.score[t] >= TARGET); + if (end || out !== undefined) { + c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)), + lastTeam: end ? team(winner) : null, score: [...c.score], out: out ?? null }; + if (out !== undefined) { c.step = 'result'; c.result = { winner: out === 0 ? 'player' : 'cpu', score: [...c.score] }; } + else c.step = 'smazzata'; + return true; + } + c.step = 'play'; return true; } -// Si va ai 21: chi arriva per primo a fine smazzata; pari sopra i 21, si gioca un'altra smazzata. -export const finished = c => Math.max(...c.score) >= TARGET && c.score[0] !== c.score[1]; +export const finished = c => c.step === 'result'; +// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo. export function advance(c) { if (c.step !== 'smazzata') return false; - if (finished(c)) { c.step = 'result'; c.result = { winner: c.score[0] > c.score[1] ? 'player' : 'cpu', score: [...c.score] }; } - else deal(c); - return true; + deal(c); return true; } // La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo. @@ -151,7 +167,11 @@ export function knowledge(v, full) { const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id)); const voids = SEATS.map(() => new Set()), holder = new Map(); if (full) { - for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => { if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit); }); + // Chi non risponde al seme l'ha finito; chi vola anche. + for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => { + if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit); + if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit); + }); for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who); } const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat); @@ -159,7 +179,8 @@ export function knowledge(v, full) { const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a))); return { out, voids, could, beatable, full }; } -const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power); // il carico più grosso, senza buttare i 3 +// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo. +const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0)); const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno function lead(v, k) { const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4]; @@ -174,7 +195,8 @@ function lead(v, k) { if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; } // Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme. const count = suit => hand.filter(a => a.suit === suit).length; - const longest = byMin(hand, a => -count(a.suit) * 10 - a.thirds).suit; + // A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri). + const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit; const card = cheap(hand.filter(a => a.suit === longest)); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; } @@ -190,6 +212,12 @@ function follow(v, k, moves) { const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat); const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat)); const pick = card => v.hand.indexOf(card); + // Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme). + const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full; + if (knock && cards[0].suit === top.card.suit) { + const over = cards.filter(a => a.power > top.card.power); + if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a)); + } // Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). if (mate && !k.beatable(top.card, after)) return pick(rich(cards)); if (cards[0].suit === top.card.suit) { @@ -201,7 +229,7 @@ function follow(v, k, moves) { // «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta // le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male». // Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti): -// Andrea e il socio vincono 86% contro «Alla bona», 59% contro «Seria», 52% contro «A fassi male» (docs/TRESSETTE.md). +// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md). export function choose(v, level = 'seria', seed = 1) { const moves = legal(v.hand, v.table); if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null }; @@ -214,7 +242,6 @@ export function update(c, dt) { c.t += dt; if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; } if (c.step === 'trick' && c.t >= 1.2) collect(c); - else if (c.step === 'smazzata' && c.t >= 7) advance(c); else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) { c.aiSeed = lcg(c.aiSeed); const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed); @@ -224,13 +251,12 @@ export function update(c, dt) { export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro']; export function status(c, names = NAMES) { if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`; - if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per continuare.`; + if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`; if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`; - if (c.current !== 0) { - const p = c.table.at(-1); - return p?.signal ? `${names[p.who]}: «${p.signal[0].toUpperCase() + p.signal.slice(1)}!»` : `${names[c.current]} pensa…`; - } + // Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno. + const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; '); + if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`; if (c.refused) return c.refused; - if (c.table.length) return `Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`; + if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`; return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`; } diff --git a/src/main.js b/src/main.js index b7e17e5..547c803 100644 --- a/src/main.js +++ b/src/main.js @@ -14,6 +14,8 @@ import * as briscola from './engine/briscola.js'; import { cardAt } from './engine/briscola-render.js'; import * as tressette from './engine/tressette.js'; import { cardAt as tressetteAt } from './engine/tressette-render.js'; +import * as scopa from './engine/scopa.js'; +import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js'; import { VERSION, versioned } from './version.js'; const $ = id => document.getElementById(id); @@ -403,11 +405,18 @@ canvas.addEventListener('pointerdown', e => { if (!running || e.button > 0) return; // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. if (game.closeup) { - const deck = CARD_AT[story.closeups[game.closeup.id].type]; + const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type]; if (deck) { if (game.skip()) return; - // Tressette: fra una smazzata e l'altra un tocco va avanti. - if (tressette.advance(game.closeup)) return; + // Tressette e scopa: fra una smazzata e l'altra un tocco va avanti. + if (MINI[type].advance?.(game.closeup)) return; + // Scopa, più prese possibili: si tocca una carta in tavola; se sta in una presa sola, si prende quella. + if (type === 'scopa' && game.closeup.step === 'choose') { + const c = game.closeup, card = c.table[scopaTableAt(c, scenePoint(e))]; + const options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []); + if (options.length === 1) scopa.take(c, options[0]); else if (options.length) scopa.select(c, options[0]); + return; + } press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck }; canvas.setPointerCapture(e.pointerId); return; @@ -463,6 +472,7 @@ canvas.addEventListener('pointerleave', () => { mouse = null; }); // Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio. const MINI = { + scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' }, tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' }, briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' }, bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' }, @@ -473,7 +483,14 @@ const mini = () => MINI[story.closeups[game.closeup.id].type]; $('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); }); $('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); }); // Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco. -const CARD_AT = { briscola: cardAt, tressette: tressetteAt }; +const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt }; +for (let i = 0; i < 3; i++) { + const button = $('scopa-card-' + i); + button.addEventListener('click', () => game?.closeupPlay(i)); + button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); }); +} +$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); }); +$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); }); for (let i = 0; i < 10; i++) { const button = $('tressette-card-' + i); button.addEventListener('click', () => game?.closeupPlay(i)); @@ -575,6 +592,27 @@ window.addEventListener('keydown', e => { if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; } // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro. if (running && game.closeup) { + if (story.closeups[game.closeup.id].type === 'scopa') { + const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase(); + if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; } + if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key)) return; + if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) { + e.preventDefault(); + if (e.repeat || game.skip()) return; + if (/^[1-9]$/.test(key)) scopa.select(c, Number(key) - 1); + else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1); + else if (c.step === 'result') game.closeupClose(); + else if (c.step === 'choose') scopa.take(c); + else if (!scopa.advance(c) && key === 'enter') game.closeupPlay(); + // Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta. + if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus(); + return; + } + // Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo. + if (e.key === 'Escape') { if (!scopa.cancel(c)) game.closeupClose(); } + else if (key === 'm') toggleMute(); + return; + } if (story.closeups[game.closeup.id].type === 'tressette') { const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup; if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; } @@ -588,6 +626,8 @@ window.addEventListener('keydown', e => { else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1); else if (c.step === 'result') game.closeupClose(); else if (!tressette.advance(c) && key === 'enter') game.closeupPlay(); + // Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta. + if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus(); return; } if (e.key === 'Escape') game.closeupClose(); @@ -700,11 +740,38 @@ function updateUi() { updateChoices(); updateSpeech(); const mg = miniState() && mini(), def = mg && story.closeups[c.id]; - const cards = def?.type === 'briscola' || def?.type === 'tressette'; + const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type); $('bocce-controls').hidden = !mg || cards; $('bocce-controls').inert = !running; $('briscola-controls').hidden = def?.type !== 'briscola'; $('briscola-controls').inert = !running; + $('scopa-controls').hidden = def?.type !== 'scopa'; + $('scopa-controls').inert = !running; + if (def?.type === 'scopa') { + $('panel').hidden = $('choices').hidden = true; + const line = scopa.status(c, def.names); + if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line; + for (let i = 0; i < 3; i++) { + const button = $('scopa-card-' + i), card = c.hands.player[i]; + const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`; + if (button.textContent !== label) button.textContent = label; + button.disabled = !scopa.canPlay(c) || !card || !!game.speech; + button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play')); + } + // Le prese fra cui scegliere, una per pulsante. + const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : []; + const box = $('scopa-options'); + if (box.dataset.list !== options.join('|')) { + box.dataset.list = options.join('|'); + box.replaceChildren(...options.map((o, k) => { + const b = Object.assign(document.createElement('button'), { type: 'button', textContent: `Prendi ${o}` }); + b.dataset.option = k; return b; + })); + } + box.hidden = !options.length; + [...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k))); + $('scopa-next').hidden = c.step !== 'smazzata'; + } $('tressette-controls').hidden = def?.type !== 'tressette'; $('tressette-controls').inert = !running; if (def?.type === 'tressette') { diff --git a/src/story/story.js b/src/story/story.js index 0570fac..a1352fe 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -124,6 +124,14 @@ export const story = { draw: [], quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }], }, + // La scopa ai 11 (idea di Andrea): carte, mattoni (o denari), settebello, primiera e scope. + scopa: { + type: 'scopa', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, + win: [{ set: 'scopa_vinta' }, { say: 'Settebello, primiera e pure le scope. Oggi l\'hai vista bene.', who: 'giocatore_carte', pose: 'talk' }], + lose: [{ say: 'Undici! Il settebello lo sento arrivare da lontano.', who: 'giocatore_carte', pose: 'laugh' }], + draw: [], + quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }], + }, // Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria. bocce: { type: 'bocce', @@ -2154,6 +2162,7 @@ export const story = { start: { options: [ { text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] }, { text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] }, + { text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] }, { text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] }, { text: 'Ora cerco il palio. A dopo.', next: 'end' }, ] }, @@ -2169,6 +2178,18 @@ export const story = { { text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] }, { text: 'Ci ripenso.', next: 'end' }, ] }, + // La scopa: stesse scelte (e stesse battute) della briscola. + mazzo_scopa: { options: [ + { text: 'Con le toscane.', next: 'livello_scopa', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] }, + { text: 'Con le napoletane.', next: 'livello_scopa', then: [{ set: 'carte_napoletane' }, { say: 'Le napoletane? Le tengo per i villeggianti. Come la vuoi?', who: 'giocatore_carte', pose: 'shuffle' }] }, + { text: 'Ci ripenso.', next: 'end' }, + ] }, + livello_scopa: { options: [ + { text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'bona' }] }, + { text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'scopa', level: 'male' }] }, + { text: 'Ci ripenso.', next: 'end' }, + ] }, // Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro. mazzo_tressette: { options: [ { text: 'Con le toscane.', next: 'livello_tressette', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] }, diff --git a/style.css b/style.css index 99e2449..083300a 100644 --- a/style.css +++ b/style.css @@ -26,15 +26,17 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene.torch { touch-action: none; } #scene.bocce { touch-action: none; } -#tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } -#tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; } +#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } +#scopa-status, #tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; } .tressette-signals, .tressette-hand { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } -.tressette-signals button, .tressette-hand button { flex: 1 1 18%; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; } +.tressette-signals button, .tressette-hand button { flex: 1 1 18%; overflow-wrap: anywhere; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; } .tressette-signals button { flex-basis: 22%; } .tressette-signals button[aria-pressed="true"], .tressette-hand button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; } .tressette-signals button:disabled, .tressette-hand button:disabled { opacity: .5; cursor: default; } -#tressette-controls summary { cursor: pointer; padding: 8px 0; } -#tressette-controls p { max-width: 76em; } +#scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; } +#scopa-controls p, #tressette-controls p { max-width: 76em; } +#scopa-next { width: 100%; min-height: 40px; margin-bottom: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; cursor: pointer; } +.briscola-hand + .tressette-signals { margin-top: 8px; } #briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } #briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; } .briscola-hand { display: flex; gap: 8px; } @@ -291,7 +293,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins #stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; } #stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } - #briscola-controls, #tressette-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } + #briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } + /* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */ + .tressette-hand button, .tressette-signals button { flex-basis: 40%; overflow-wrap: anywhere; } .briscola-hand { flex-direction: column; gap: 4px; } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } #sentence { font-size: 10.08px; } diff --git a/tests/scopa.browser.mjs b/tests/scopa.browser.mjs new file mode 100644 index 0000000..7c9e39a --- /dev/null +++ b/tests/scopa.browser.mjs @@ -0,0 +1,149 @@ +// Scopa ai 11 nel browser: carte con mouse, tocco e tastiera, scelta fra più prese; si accelerano solo le attese. +// npm run browser:scopa — /tmp/regolo-shots/scopa-*.png +import { chromium } from 'playwright'; +import { mkdirSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { staticServer } from './helpers.mjs'; + +const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true }); +const server = await staticServer(), errors = []; +let browser; +async function tap(page, mode, selector) { + if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); } + else await page.click(selector); +} +async function at(page, mode, [px, py]) { + // Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa. + await page.locator('#scene').scrollIntoViewIfNeeded(); + const r = await page.locator('#scene').boundingBox(), x = r.x + px / 640 * r.width, y = r.y + py / 360 * r.height; + if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); +} +async function playCard(page, mode, index) { + if (mode === 'keyboard') { + await page.keyboard.press(String(index + 1)); + assert.equal(await page.evaluate(() => game.closeup.selected), index); + await page.keyboard.press('Enter'); + return; + } + const p = await page.evaluate(async i => { + const { handX, HAND } = await import('/src/engine/scopa-render.js'); + return [handX(game.closeup.hands.player.length, i) + 32, HAND.y + 46]; + }, index); + await at(page, mode, p); +} +async function wait(page) { + return page.evaluate(async () => { + const { canPlay } = await import('/src/engine/scopa.js'); + // Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui. + for (let i = 0; i < 60; i++) { + if (game.speech) game.skip(); + else if (canPlay(game.closeup) || ['smazzata', 'result', 'choose'].includes(game.closeup.step)) break; + else game.update(1.4); + } + return game.closeup.step; + }); +} +try { + browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); + const scenarios = [ + { name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true }, + { name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } }, + { name: 'desktop-keyboard', mode: 'keyboard', viewport: { width: 1280, height: 860 } }, + { name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } }, + ]; + for (const { name, mode, viewport, full } of scenarios) { + const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 }); + page.on('pageerror', e => errors.push(`${name}: ${e.message}`)); + page.on('console', e => { if (e.type() === 'error') errors.push(`${name}: ${e.text()}`); }); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.click('#splash'); await page.click('#new-game'); + await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro'); + await page.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); game.go('oratorio'); settle(game); game.act('talk', 'giocatore_carte'); settle(game); }); + await page.locator('#choices li', { hasText: 'Una scopa?' }).click(); + await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); + const deck = name.startsWith('telefono') ? 'napoletane' : 'toscane'; + await page.locator('#choices li', { hasText: deck === 'napoletane' ? 'Con le napoletane.' : 'Con le toscane.' }).click(); + await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); + await page.locator('#choices li', { hasText: 'Seria.' }).click(); + await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); + assert.deepEqual(await page.evaluate(() => [game.closeup.deck, game.closeup.level]), [deck, 'seria']); + await page.waitForSelector('#scopa-controls:not([hidden])'); + assert.ok(await page.isHidden('#tressette-controls')); assert.ok(await page.isHidden('#briscola-controls')); + await wait(page); + await page.screenshot({ path: `${out}/scopa-${name}-inizio.png` }); + // Una volta per scenario: tavola preparata con due prese possibili per il 7, scelta toccando la tavola, + // con un pulsante o con frecce e Invio. + await page.evaluate(async () => { + // Una partita nuova in cui esce Andrea, così in tavola ci sono proprio quattro carte. + const { newScopa } = await import('/src/engine/scopa.js'), old = game.closeup; + let seed = 1, c; + do c = newScopa(seed++, { level: old.level, deck: old.deck }); while (c.current !== 'player'); + const pool = [...c.stock, ...c.table, c.hands.player[0]]; + const free = v => pool.splice(pool.findIndex(a => a.value === v), 1)[0]; + c.table = [free(3), free(4), free(2), free(5)]; c.hands.player[0] = free(7); c.stock = pool; + game.closeup = { id: old.id, ...c }; + }); + // La tavola preparata non perde né duplica carte. + const ids = await page.evaluate(() => { const c = game.closeup; return [...c.stock, ...c.table, ...c.hands.player, ...c.hands.cpu, ...c.taken.player, ...c.taken.cpu].map(a => a.id); }); + assert.equal(new Set(ids).size, 40); assert.equal(ids.length, 40); + await playCard(page, mode, 0); + assert.equal(await page.evaluate(() => game.closeup.step), 'choose'); + await page.waitForSelector('#scopa-options:not([hidden])'); + await page.screenshot({ path: `${out}/scopa-${name}-scelta.png` }); + if (mode === 'keyboard') { await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); } + else if (mode === 'touch') { + // Il 5 in tavola sta solo nella presa 2+5: toccarlo la sceglie. + const p = await page.evaluate(async () => { + const { tableXY } = await import('/src/engine/scopa-render.js'), c = game.closeup; + const i = c.table.findIndex(a => a.value === 5), [x, y] = tableXY(c.table.length, i); return [x + 21, y + 30]; + }); + await at(page, mode, p); + } else await tap(page, mode, '#scopa-options button:nth-child(2)'); + assert.equal(await page.evaluate(() => game.closeup.step), 'take'); + assert.deepEqual(await page.evaluate(() => game.closeup.last.captured.map(a => a.value).sort()), [2, 5]); + await page.screenshot({ path: `${out}/scopa-${name}-presa.png` }); + let played = 1; + for (let steps = 0; steps < 400; steps++) { + const step = await wait(page); + if (step === 'result') break; + if (step === 'smazzata') { + await page.screenshot({ path: `${out}/scopa-${name}-smazzata.png` }); + if (!full) break; + if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-next'); + continue; + } + if (step !== 'play' && step !== 'choose') throw new Error('partita ferma in ' + step); + if (step === 'choose') { + if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-options button:first-child'); + continue; + } + const move = await page.evaluate(async () => { + const { observation, choose } = await import('/src/engine/scopa.js'); + return choose(observation(game.closeup, 'player'), 'male', 3); + }); + const before = await page.evaluate(() => game.closeup.hands.player.length); + await playCard(page, mode, move.index); played++; + const after = await page.evaluate(() => game.closeup.step === 'choose' ? -1 : game.closeup.hands.player.length); + // Il gioco intanto va avanti da solo: dopo l'ultima carta la mano può essere già ridata. + assert.ok(after === -1 || after === before - 1 || (before === 1 && after === 3), `un gesto gioca una carta (${name}: ${before} → ${after})`); + } + const count = await page.evaluate(() => game.closeup.summary ? game.closeup.summary.rows : null); + assert.ok(count, 'conteggio della smazzata'); + if (full) { + assert.equal(await page.evaluate(() => game.closeup.step), 'result'); + await page.screenshot({ path: `${out}/scopa-${name}-risultato.png` }); + const winner = await page.evaluate(() => game.closeup.result.winner); + await page.keyboard.press('Enter'); + await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); + assert.equal(await page.evaluate(() => game.closeup), null); + assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), winner === 'player'); + } else { + await page.keyboard.press('Escape'); + assert.equal(await page.evaluate(() => game.closeup), null); + assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), false); + } + console.log(`${name}: ${played} carte giocate, presa scelta fra due${full ? ', partita ai 11 finita' : ', una smazzata'} OK`); + await page.close(); + } + assert.deepEqual(errors, []); console.log(`Schermate: ${out}/scopa-*.png`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/scopa.test.mjs b/tests/scopa.test.mjs new file mode 100644 index 0000000..2661685 --- /dev/null +++ b/tests/scopa.test.mjs @@ -0,0 +1,108 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DECK_CARDS, COIN, newScopa, captures, primiera, tally, play, take, cancel, select, nudge, update, advance, finished, observation, unseen, choose, canPlay, status, TARGET } from '../src/engine/scopa.js'; +import { simulate, calibrate } from '../tools/calibrate_scopa.mjs'; + +const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank); + +test('valori: Asso 1, numeri, gobbo 8, donna 9, regio 10; primiera 7 = 21, 6 = 18, Asso 16 … figure 10', () => { + assert.deepEqual(['Asso', '5', 'Gobbo', 'Donna', 'Regio'].map(r => card('cuori', r).value), [1, 5, 8, 9, 10]); + assert.deepEqual(['7', '6', 'Asso', '5', '4', '3', '2', 'Regio'].map(r => card('fiori', r).prime), [21, 18, 16, 15, 14, 13, 12, 10]); + assert.equal(COIN.toscane, 'mattoni'); assert.equal(COIN.napoletane, 'denari'); +}); + +test('prese: la carta uguale ha la precedenza; se no ogni somma; più carte uguali, si sceglie', () => { + const t = [card('cuori', '3'), card('fiori', '4'), card('picche', '7'), card('mattoni', '2'), card('cuori', '5')]; + assert.deepEqual(captures(t, card('mattoni', '7')).map(o => o.map(a => a.id)), [[card('picche', '7').id]], 'col 7 si prende il 7, non 3+4 o 2+5'); + const sums = captures([card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')], card('picche', '7')); + assert.equal(sums.length, 2, '3+4 oppure 2+5'); + assert.deepEqual(captures([card('cuori', '3'), card('fiori', '3')], card('picche', '3')).length, 2); + assert.deepEqual(captures([card('cuori', '3')], card('picche', 'Donna')), []); +}); + +test('primiera: la migliore per seme, e senza un seme non si fa', () => { + const all = [card('cuori', '7'), card('mattoni', '6'), card('fiori', 'Asso'), card('picche', 'Regio'), card('picche', '2')]; + assert.equal(primiera(all, 'toscane'), 21 + 18 + 16 + 12); + assert.equal(primiera(all.slice(0, 3), 'toscane'), 0); +}); + +test('conteggio: carte, mattoni, settebello, primiera e scope; pari non si dà il punto', () => { + const deck = DECK_CARDS.toscane, mine = deck.filter(a => a.suit === 'mattoni' || (a.suit === 'cuori' && a.value <= 2)); + const theirs = deck.filter(a => !mine.includes(a)); + const t = tally({ player: mine, cpu: theirs }, { player: 2, cpu: 0 }, 'toscane'); + const row = k => t.rows.find(r => r.key === k); + assert.equal(row('carte').winner, 'cpu'); assert.equal(row('coin').winner, 'player'); assert.equal(row('coin').label, 'Mattoni'); + assert.equal(row('settebello').winner, 'player'); assert.equal(row('primiera').winner, null, 'a nessuno ci sono tutti e quattro i semi'); + assert.deepEqual(t.points, { player: 4, cpu: 1 }); + const half = tally({ player: deck.slice(0, 20), cpu: deck.slice(20) }, { player: 0, cpu: 0 }, 'napoletane'); + assert.equal(half.rows[0].winner, null, '20 a 20: le carte non le prende nessuno'); + assert.equal(tally({ player: [card('denari', '7', 'napoletane')], cpu: [] }, { player: 0, cpu: 0 }, 'napoletane').rows[2].winner, 'player'); +}); + +test('smazzata: 4 in tavola (mai tre re), tre a testa, la presa è obbligatoria, la scelta fra più prese', () => { + for (let s = 1; s < 60; s++) assert.ok(newScopa(s).table.filter(a => a.value === 10).length < 3); + const c = newScopa(3); c.current = 'player'; + assert.equal(c.table.length, 4); assert.equal(c.hands.player.length, 3); assert.equal(c.stock.length, 30); + c.table = [card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')]; + c.hands.player = [card('picche', '7'), card('picche', 'Regio')]; + assert.ok(play(c, 0)); assert.equal(c.step, 'choose'); assert.equal(c.choice.options.length, 2); + assert.ok(nudge(c, 1)); assert.ok(cancel(c)); assert.equal(c.step, 'play'); assert.equal(c.hands.player.length, 2); + assert.ok(play(c, 0)); assert.ok(select(c, 1)); assert.ok(take(c)); + assert.equal(c.step, 'take'); assert.equal(c.table.length, 2); assert.equal(c.taken.player.length, 3); +}); + +test('scopa: chi pulisce la tavola fa un punto, ma non con l\'ultima carta della smazzata', () => { + const c = newScopa(4); c.current = 'player'; c.table = [card('cuori', '3'), card('fiori', '4')]; + c.hands.player = [card('picche', '7'), card('picche', '2')]; + assert.ok(play(c, 0)); assert.equal(c.scope.player, 1); assert.equal(c.last.scopa, true); assert.match(status(c), /Scopa di Andrea/); + const d = newScopa(4); d.current = 'player'; d.stock = []; d.hands.cpu = []; d.table = [card('cuori', '3')]; + d.hands.player = [card('picche', '3')]; + assert.ok(play(d, 0)); assert.equal(d.scope.player, 0, 'all\'ultima non è scopa'); +}); + +test('a fine smazzata le carte in tavola vanno all\'ultimo che ha preso; tutte le 40 carte si contano', () => { + const c = newScopa(5, { level: 'seria' }); + while (c.step !== 'smazzata') { + if (canPlay(c)) { const m = choose(observation(c, 'player'), 'seria'); assert.ok(play(c, m.index, 'player', m.option)); } + update(c, 20); + } + assert.equal(c.taken.player.length + c.taken.cpu.length, 40); + assert.equal(c.table.length, 0); + const before = c.dealer; + if (!finished(c)) { advance(c); assert.notEqual(c.dealer, before); assert.equal(c.current, before, 'gioca per primo chi non fa le carte'); } +}); + +test('si va agli 11: a fine smazzata vince chi ne ha di più; pari, un\'altra; il risultato è subito definitivo', () => { + const c = newScopa(6); c.stock = []; c.hands = { player: [], cpu: [card('cuori', '4')] }; + c.table = [card('fiori', '4')]; c.current = 'cpu'; c.score = { player: 9, cpu: 10 }; + c.taken = { player: DECK_CARDS.toscane.filter(a => a.suit !== 'fiori' || a.value !== 4).slice(0, 30), cpu: [] }; + assert.ok(play(c, 0, 'cpu')); update(c, 2); + assert.equal(c.step, 'result', 'senza passare dal riepilogo: uscire non cancella la vittoria'); + assert.equal(c.result.winner, 'player'); + const d = newScopa(6); d.step = 'smazzata'; assert.ok(advance(d)); assert.equal(d.step, 'play'); + assert.ok(finished({ score: { player: TARGET, cpu: 9 } })); assert.ok(!finished({ score: { player: 12, cpu: 12 } })); +}); + +test('partite deterministiche col seme; l\'avversario vede solo la sua mano, la tavola e le prese', () => { + assert.deepEqual(simulate(31, 'male').score, simulate(31, 'male').score); + const c = newScopa(12), v = observation(c, 'cpu'); + assert.equal(v.hands, undefined); assert.equal(v.stock, 30); + assert.equal(unseen(v).length, 33, 'mano dell\'altro e mazzo'); + const other = structuredClone(c); other.hands.player.reverse(); other.stock.reverse(); + assert.deepEqual(choose(observation(c, 'cpu'), 'male', 3), choose(observation(other, 'cpu'), 'male', 3)); +}); + +test('l\'avversario prende il settebello e la scopa quando può, e non lascia scope facili', () => { + const v = { who: 'cpu', deck: 'toscane', hand: [card('cuori', '7'), card('fiori', 'Regio')], table: [card('mattoni', '7'), card('picche', '4')], taken: { player: [], cpu: [] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } }; + const m = choose(v, 'seria'); assert.equal(v.hand[m.index].rank, '7', 'prende il settebello'); + const w = { ...v, hand: [card('cuori', '7'), card('fiori', '4')], table: [card('picche', '3'), card('mattoni', '4')] }; + const s = choose(w, 'male'); assert.equal(w.hand[s.index].rank, '7', 'scopa con 3+4'); + const x = { ...v, hand: [card('cuori', '5'), card('fiori', 'Regio')], table: [card('picche', '2')] }; + const d = choose(x, 'male'); assert.equal(x.hand[d.index].rank, 'Regio', 'col 5 lascerebbe 7 in tavola: meglio il regio (12)'); +}); + +test('taratura: Alla bona < Seria < A fassi male', () => { + const runs = calibrate(60); + assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs)); + assert.ok(runs[1].playerPercent > runs[2].playerPercent, JSON.stringify(runs)); +}); diff --git a/tests/tressette.browser.mjs b/tests/tressette.browser.mjs index aea3692..a8b90b7 100644 --- a/tests/tressette.browser.mjs +++ b/tests/tressette.browser.mjs @@ -28,13 +28,20 @@ async function playCard(page, mode, index, signal) { const { handX, CARD } = await import('/src/engine/tressette-render.js'); return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30]; }, index); + // Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa. + await page.locator('#scene').scrollIntoViewIfNeeded(); const r = await page.locator('#scene').boundingBox(), x = r.x + at[0] / 640 * r.width, y = r.y + at[1] / 360 * r.height; if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); } async function wait(page) { return page.evaluate(async () => { const { canPlay } = await import('/src/engine/tressette.js'); - for (let i = 0; i < 12 && !canPlay(game.closeup) && !['smazzata', 'result'].includes(game.closeup.step); i++) game.update(1.3); + // Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui. + for (let i = 0; i < 60; i++) { + if (game.speech) game.skip(); + else if (canPlay(game.closeup) || ['smazzata', 'result'].includes(game.closeup.step)) break; + else game.update(1.3); + } return game.closeup.step; }); } diff --git a/tests/tressette.test.mjs b/tests/tressette.test.mjs index 29906ea..c284417 100644 --- a/tests/tressette.test.mjs +++ b/tests/tressette.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DECK_CARDS, newTressette, accuse, legal, best, signalOk, reason, setSignal, play, collect, advance, update, observation, choose, knowledge, canPlay, finished, team, status, TARGET } from '../src/engine/tressette.js'; +import { DECK_CARDS, newTressette, accuse, accuseName, legal, best, signalOk, reason, setSignal, play, collect, advance, update, observation, choose, knowledge, canPlay, finished, team, status, TARGET } from '../src/engine/tressette.js'; import { simulate, calibrate } from '../tools/calibrate_tressette.mjs'; const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank); @@ -30,18 +30,22 @@ test('accuse: napoletana 3 punti, tre o quattro Assi, 2 o 3 per 3 o 4 punti, anc assert.deepEqual(a.map(x => [x.kind, x.points]), [['napoletana', 3], ['buongioco', 3]]); assert.equal(accuse(hand([['cuori', '2'], ['picche', '2'], ['fiori', '2'], ['mattoni', '2']]))[0].points, 4); assert.deepEqual(accuse(hand([['cuori', 'Asso'], ['cuori', '2'], ['picche', '3']])), []); + // Del tris si dice il seme che manca, così Andrea sa quello che sa l'avversario. + const tris = accuse(hand([['picche', '3'], ['fiori', '3'], ['mattoni', '3']]), ['cuori', 'mattoni', 'fiori', 'picche'])[0]; + assert.equal(tris.missing, 'cuori'); assert.equal(accuseName(tris), 'tre 3, manca cuori'); }); -test('segni onesti: volo con l\'ultima, striscio con almeno due rimaste, busso con almeno una; solo chi esce', () => { - const h = hand([['cuori', '4'], ['cuori', '5'], ['cuori', '6'], ['fiori', '7']]); - assert.ok(signalOk(h, 3, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo')); - assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 3, [], 'striscio')); - assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(!signalOk(h, 3, [], 'busso')); +test('segni: volo con l\'ultima, striscio se ne resta un\'altra, busso sempre; solo chi esce', () => { + const h = hand([['cuori', '4'], ['cuori', '5'], ['fiori', '7']]); + assert.ok(signalOk(h, 2, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo')); + assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 2, [], 'striscio')); + assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(signalOk(h, 2, [], 'busso')); assert.ok(!signalOk(h, 0, [{ who: 1, card: card('cuori', '7') }], 'busso')); - const c = newTressette(5); c.current = 0; c.table = []; c.hands[0] = [...h]; + const c = newTressette(5); c.current = 0; c.table = []; c.hands[0] = [...h]; c.pending[0] = []; assert.ok(setSignal(c, 'volo')); assert.equal(play(c, 0), false); assert.match(c.refused, /Volo solo/); - assert.equal(c.hands[0].length, 4, 'la carta resta in mano'); - assert.ok(play(c, 3)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null); + assert.equal(c.hands[0].length, 3, 'la carta resta in mano'); + assert.equal(play(c, 0, 0, 'xyz'), false); assert.equal(c.refused, 'Segno non riconosciuto.'); + assert.ok(play(c, 2)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null); assert.ok(c.events.some(e => e.kind === 'card-volo')); }); @@ -52,30 +56,43 @@ test('la carta che non risponde al seme si rifiuta con la ragione', () => { assert.ok(canPlay(c)); assert.ok(play(c, 0)); }); -test('una smazzata: 10 prese, 11 punti fra terzi e ultima presa, accuse nel punteggio, poi la seconda', () => { +test('una smazzata: accuse dette al proprio turno, 10 prese, sempre 11 punti fra terzi e ultima presa', () => { const c = newTressette(1234, { level: 'seria' }); - const accused = [0, 1].map(t => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0)); - assert.deepEqual(c.score, accused); + assert.deepEqual(c.score, [0, 0]); assert.deepEqual(c.accuse, [], 'prima di giocare nessuno ha accusato'); + const owed = c.pending.flat(); assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere'); assert.ok(c.hands.every(h => h.length === 10)); const firstDealer = c.dealer; - while (c.step !== 'smazzata') { + while (!['smazzata', 'result'].includes(c.step)) { if (canPlay(c)) { const m = choose(observation(c, 0), 'seria'); assert.ok(play(c, m.index, 0, m.signal)); } update(c, 20); + if (c.tricks.length === 1) assert.equal(c.accuse.length, owed.length, 'dopo la prima presa hanno accusato tutti'); + } + if (c.step === 'smazzata') { + assert.equal(c.tricks.length, 10); + assert.equal(c.summary.thirds[0] + c.summary.thirds[1], 35); + assert.equal(c.summary.points[0] + c.summary.points[1], 11, 'i terzi avanzati sommano sempre a due'); + assert.deepEqual(c.score, [0, 1].map(t => c.summary.accused[t] + c.summary.points[t])); + assert.ok(advance(c)); assert.equal(c.dealer, (firstDealer + 1) % 4); assert.equal(c.step, 'play'); } - assert.equal(c.tricks.length, 10); - assert.equal(c.summary.thirds[0] + c.summary.thirds[1], 35); - assert.ok([10, 11].includes(c.summary.points[0] + c.summary.points[1]), 'si perdono al più i terzi avanzati'); - assert.deepEqual(c.score, [0, 1].map(t => accused[t] + c.summary.points[t])); - if (!finished(c)) { advance(c); assert.equal(c.dealer, (firstDealer + 1) % 4); assert.equal(c.step, 'play'); } }); -test('si va ai 21: vince chi ci arriva per primo; pari sopra i 21 si continua', () => { - const c = newTressette(3); - c.step = 'smazzata'; c.score = [21, 21]; assert.ok(!finished(c)); advance(c); assert.equal(c.step, 'play'); - c.step = 'smazzata'; c.score = [TARGET, 17]; advance(c); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); - c.step = 'smazzata'; c.score = [22, 24]; advance(c); assert.equal(c.result.winner, 'cpu'); - assert.match(status(c), /Hanno vinto loro, 22 a 24/); +test('si va ai 21: chi ci arriva per primo è fuori, anche a metà smazzata, e la vittoria non si perde', () => { + const c = newTressette(3); c.pending = [[], [], [], []]; + c.base = [20, 19]; c.score = [20, 19]; c.thirds = [2, 0]; + // Una presa da un terzo porta noi a 21 (3 terzi = 1 punto): fuori subito. + c.table = [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('cuori', '4') }, { who: 2, card: card('cuori', '5') }, { who: 3, card: card('cuori', '6') }]; + c.step = 'trick'; c.last = { winner: 0, cards: c.table.map(p => p.card) }; + assert.ok(collect(c)); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); assert.deepEqual(c.score, [21, 19]); + assert.ok(finished(c)); assert.equal(advance(c), false); + assert.match(status(c), /Avete vinto voi, 21 a 19/); + // Anche le accuse bastano: dette giocando, contano alla fine di quella presa. + const d = newTressette(3); d.base = [0, 19]; d.score = [0, 19]; d.current = 1; d.table = []; d.tricks = []; + d.pending = [[], [{ who: 1, kind: 'napoletana', suit: 'fiori', points: 3, cards: [] }], [], []]; + d.hands[1] = [card('fiori', '7'), ...d.hands[1].slice(1)]; + assert.ok(play(d, 0, 1)); assert.equal(d.score[1], 22); + for (const seat of [2, 3, 0]) { d.current = seat; play(d, choose(observation(d, seat), 'seria').index, seat); } + assert.equal(d.step, 'trick'); collect(d); assert.equal(d.step, 'result'); assert.equal(d.result.winner, 'cpu'); }); test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => { @@ -117,6 +134,26 @@ test('il socio ritorna il seme a chi ha bussato', () => { assert.equal(c.hands[2][m.index].suit, 'cuori'); assert.equal(c.hands[2][m.index].rank, '4'); }); +test('chi riceve il busso prende con la migliore; il socio non spreca il 3 su una presa già sua', () => { + const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 2; + c.table = [{ who: 0, card: card('cuori', '4'), signal: 'busso' }, { who: 1, card: card('cuori', '5') }]; + c.hands[2] = hand([['cuori', '2'], ['cuori', '6'], ['fiori', '7']]); + assert.equal(c.hands[2][choose(observation(c, 2), 'male').index].rank, '2', 'al busso risponde col 2'); + const d = newTressette(8); d.pending = [[], [], [], []]; d.current = 3; + d.table = [{ who: 0, card: card('fiori', '6') }, { who: 1, card: card('fiori', '2') }, { who: 2, card: card('fiori', '4') }]; + d.hands[3] = hand([['fiori', '3'], ['fiori', '5'], ['cuori', '7']]); + // L'avversario del posto 3: il suo socio (1) prende col 2 e lui è l'ultimo; tiene il 3 e dà il 5. + assert.equal(d.hands[3][choose(observation(d, 3), 'male').index].rank, '5'); +}); + +test('chi vola ha finito il seme: per «A fassi male» non può più avere il 3', () => { + const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 1; + c.tricks = [{ leader: 2, winner: 0, cards: [{ who: 2, card: card('cuori', '4'), signal: 'volo' }, { who: 3, card: card('cuori', '5') }, { who: 0, card: card('cuori', '6') }, { who: 1, card: card('cuori', '7') }] }]; + c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]); c.table = []; + const k = knowledge(observation(c, 1), true); + assert.equal(k.could(2, card('cuori', '3')), false); +}); + test('taratura: Alla bona < Seria < A fassi male, contro Andrea e il socio che giocano attenti', () => { const runs = calibrate(60); assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs)); diff --git a/tools/calibrate_scopa.mjs b/tools/calibrate_scopa.mjs new file mode 100644 index 0000000..f3af1cc --- /dev/null +++ b/tools/calibrate_scopa.mjs @@ -0,0 +1,24 @@ +// node tools/calibrate_scopa.mjs [partite per livello, predefinite 300] +// Stesse carte per i tre livelli; Andrea gioca come «A fassi male», l'avversario al livello scelto. +import { newScopa, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/scopa.js'; + +export function simulate(seed, level, me = 'male') { + const c = newScopa(seed, { level }); + for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) { + if (canPlay(c)) { const m = choose(observation(c, 'player'), me, steps + 1); if (!play(c, m.index, 'player', m.option)) throw new Error('mossa rifiutata'); } + if (!advance(c)) update(c, 20); c.events.length = 0; + } + if (c.step !== 'result') throw new Error('Partita non conclusa'); + return c; +} +export function calibrate(n = 300, me = 'male') { + return LEVELS.map(level => { + let wins = 0, rounds = 0; + for (let i = 1; i <= n; i++) { + const c = simulate(Math.imul(i, 2654435761) >>> 0, level, me); + wins += c.result.winner === 'player'; rounds += c.round; + } + return { level, games: n, playerPercent: 100 * wins / n, rounds: rounds / n }; + }); +} +if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 300), null, 2)); diff --git a/tools/calibrate_tressette.mjs b/tools/calibrate_tressette.mjs index b87415d..2fde9d1 100644 --- a/tools/calibrate_tressette.mjs +++ b/tools/calibrate_tressette.mjs @@ -1,12 +1,12 @@ // node tools/calibrate_tressette.mjs [partite per livello, predefinite 300] // Stesse carte per i tre livelli; Andrea gioca come il suo socio («A fassi male»), gli avversari al livello scelto. -import { newTressette, observation, choose, play, update, canPlay, LEVELS } from '../src/engine/tressette.js'; +import { newTressette, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/tressette.js'; export function simulate(seed, level, me = 'male') { const c = newTressette(seed, { level }); for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) { if (canPlay(c)) { const m = choose(observation(c, 0), me, steps + 1); if (!play(c, m.index, 0, m.signal)) throw new Error('mossa rifiutata: ' + c.refused); } - update(c, 20); c.events.length = 0; + if (!advance(c)) update(c, 20); c.events.length = 0; } if (c.step !== 'result') throw new Error('Partita non conclusa'); return c;