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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -1531,6 +1531,10 @@
"file": "andrea-98e4366a56.mp3", "file": "andrea-98e4366a56.mp3",
"sec": 0.83 "sec": 0.83
}, },
"andrea|Una scopa?": {
"file": "andrea-d8dfb86266.mp3",
"sec": 0.65
},
"andrea|Un tressette ai 21?": { "andrea|Un tressette ai 21?": {
"file": "andrea-4fbe84fada.mp3", "file": "andrea-4fbe84fada.mp3",
"sec": 1.66 "sec": 1.66
@@ -1547,6 +1551,10 @@
"file": "giocatore_carte-8dfadb4acc.mp3", "file": "giocatore_carte-8dfadb4acc.mp3",
"sec": 3.4 "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.": { "giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
"file": "giocatore_carte-3e2671e35e.mp3", "file": "giocatore_carte-3e2671e35e.mp3",
"sec": 4.88 "sec": 4.88
@@ -2099,6 +2107,14 @@
"file": "giocatore_carte-5dbd39a25f.mp3", "file": "giocatore_carte-5dbd39a25f.mp3",
"sec": 4.44 "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...": { "mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3", "file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62 "sec": 1.62
+35
View File
@@ -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`.
+3 -1
View File
@@ -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`. 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, **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`. 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 - **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. riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
+28 -12
View File
@@ -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. - 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. - 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 - 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). avanzati si perdono: le due coppie fanno sempre 11 punti in tutto.
- **Accuse** a inizio smazzata, contate subito nel punteggio: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; - **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3
tre Assi, tre 2 o tre 3 fanno 3 punti, quattro fanno 4. Sono pubbliche: chi conta sa dove sono quelle carte. dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4.
- **Segni**, solo di chi esce e onesti (il gioco rifiuta quelli falsi): busso (prendi e ritornami il seme; me ne Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari.
resta almeno un'altra), striscio (ne ho ancora almeno due), volo (era l'ultima). Si sentono: due nocche sul - **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho
tavolo, la carta strusciata, la carta lasciata cadere. altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche
- Si va **ai 21**: vince chi ci arriva a fine smazzata; pari sopra i 21 si gioca un'altra smazzata. 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 Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si
smazzata); nessun «cappotto»; nessuna napoletana lunga. sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa.
## Avversari ## Avversari
- «Alla bona»: una carta a caso fra quelle buone, nessun segno. - «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 - «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. 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 - «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse,
che ha bussato. Il socio di Andrea gioca sempre così. 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 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 ## Prove
+24 -5
View File
@@ -103,6 +103,24 @@
<div id="inventory" aria-label="Inventario"></div> <div id="inventory" aria-label="Inventario"></div>
</div> </div>
<ol id="choices" hidden></ol> <ol id="choices" hidden></ol>
<section id="scopa-controls" hidden aria-label="Comandi della scopa">
<p id="scopa-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea">
<button id="scopa-card-0" type="button"></button>
<button id="scopa-card-1" type="button"></button>
<button id="scopa-card-2" type="button"></button>
</div>
<div id="scopa-options" class="tressette-signals" hidden aria-label="Quale presa"></div>
<button id="scopa-next" type="button" hidden>Prossima smazzata</button>
<details><summary>Come si gioca</summary>
<p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano
il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10.
Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p>
<p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di
mattoni o di denari), la primiera (la carta migliore di ogni seme: 7, 6, Asso, 5, 4, 3, 2, figure), e uno per
ogni scopa. Si va agli 11.</p>
</details>
</section>
<section id="tressette-controls" hidden aria-label="Comandi del tressette"> <section id="tressette-controls" hidden aria-label="Comandi del tressette">
<p id="tressette-status" role="status"></p> <p id="tressette-status" role="status"></p>
<div class="tressette-signals" aria-label="Segni per il socio, solo quando esci"> <div class="tressette-signals" aria-label="Segni per il socio, solo quando esci">
@@ -127,11 +145,12 @@
<p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra. <p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra.
Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta
del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p> del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p>
<p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21, smazzata dopo smazzata.</p> <p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21: la coppia che
<p>Accuse, a inizio smazzata: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p>
3 punti, quattro fanno 4.</p> <p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
<p>Segni, solo di chi esce: <b>busso</b> (prendi e ritornami il seme; me ne resta almeno un’altra), tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p>
<b>striscio</b> (di questo seme ne ho ancora parecchie), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta. <p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme),
<b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta.
Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p> Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p>
</details> </details>
</section> </section>
+1
View File
@@ -8,6 +8,7 @@
"browser": "node tests/browser.mjs", "browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs", "browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs", "browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000" "serve": "python3 -m http.server 8000"
+2
View File
@@ -5,11 +5,13 @@ import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js'; import * as biliardo from './biliardo.js';
import * as briscola from './briscola.js'; import * as briscola from './briscola.js';
import * as tressette from './tressette.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. // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = { export const MINIGAMES = {
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play }, 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 }, 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 }, 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 }, biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
}; };
+2
View File
@@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js'; import { drawBiliardo } from './biliardo-render.js';
import { drawBriscola } from './briscola-render.js'; import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js'; import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
@@ -670,6 +671,7 @@ function drawCloseup(ctx, game, time) {
if (!def) return; if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; } if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'tressette') { drawTressette(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 (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture)); drawTorch(ctx, c, def, backgrounds.get(def.picture));
+84
View File
@@ -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();
}
+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…`;
}
+3 -3
View File
@@ -44,8 +44,8 @@ function scoreboard(ctx, c) {
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center'); text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
} }
function accuses(ctx, c, names) { function accuses(ctx, c, names) {
// Le accuse si vedono finché non è finita la prima presa. // Le accuse dette restano in vista per tutta la smazzata.
if (c.tricks.length || !c.accuse.length) return; if (!c.accuse.length) return;
panel(ctx, 456, 34, 158, 12 + c.accuse.length * 11); 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)); 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'); drawTable(ctx, 'TRESSETTE AI 21');
scoreboard(ctx, c); others(ctx, c, names); accuses(ctx, c, names); trick(ctx, c, names); hand(ctx, c); 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 === '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(); ctx.restore();
} }
+61 -35
View File
@@ -27,7 +27,8 @@ function shuffle(cards, random) {
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a); 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. // 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']; const out = [], tops = ['Asso', '2', '3'];
for (const suit of new Set(hand.map(a => a.suit))) { for (const suit of new Set(hand.map(a => a.suit))) {
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank)); 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) { for (const rank of tops) {
const cards = hand.filter(a => a.rank === rank); 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; 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. // Si risponde al seme quando si può; chi esce gioca quel che vuole.
export function legal(hand, table) { export function legal(hand, table) {
@@ -47,12 +49,13 @@ export function legal(hand, table) {
return same.length ? same : hand.map((_, i) => i); 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); 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) { export function signalOk(hand, index, table, signal) {
if (!signal) return true; if (!signal) return true;
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false; if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length; 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. // 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) { 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 (!card) return 'Scegli una carta.';
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`; 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 (!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.'; 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; 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); c.seed = lcg(c.seed);
const stock = shuffle([...DECK_CARDS[c.deck]], rng(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)); 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.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.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
c.accuse = SEATS.map(i => (c.mano + i) % 4).flatMap(who => accuse(c.hands[who]).map(a => ({ who, ...a }))); // Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
for (const a of c.accuse) c.score[team(a.who)] += a.points; 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.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
c.events.push({ kind: 'card-shuffle' }); 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); if (who === 0) c.refused = reason(c, index, signal);
return false; 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); const [card] = c.hands[who].splice(index, 1);
c.table.push({ who, card, signal: signal || null }); c.t = 0; c.landing = true; 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. // 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); else c.current = next(who);
return true; 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) { export function collect(c) {
if (c.step !== 'trick') return false; if (c.step !== 'trick') return false;
const winner = c.last.winner; const winner = c.last.winner, end = !c.hands[0].length;
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0); // 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.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' }); c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
if (c.hands[0].length) { c.step = 'play'; return true; } recount(c);
// L'ultima presa vale un punto; i terzi avanzati non contano. // Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata (una presa porta punti a una
c.thirds[team(winner)] += 3; // coppia sola, quindi non ci arrivano mai insieme).
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)); const out = [0, 1].find(t => c.score[t] >= TARGET);
c.score = c.score.map((s, t) => s + points[t]); if (end || out !== undefined) {
c.summary = { thirds: [...c.thirds], points, accused, lastTeam: team(winner), score: [...c.score] }; c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
c.step = 'smazzata'; 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; 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 => c.step === 'result';
export const finished = c => Math.max(...c.score) >= TARGET && c.score[0] !== c.score[1]; // Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) { export function advance(c) {
if (c.step !== 'smazzata') return false; 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] }; } deal(c); return true;
else deal(c);
return true;
} }
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo. // 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 out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
const voids = SEATS.map(() => new Set()), holder = new Map(); const voids = SEATS.map(() => new Set()), holder = new Map();
if (full) { 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); 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); 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))); 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 }; 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 const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
function lead(v, k) { function lead(v, k) {
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4]; 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) }; } 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. // 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 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)); const card = cheap(hand.filter(a => a.suit === longest));
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; 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 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 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); 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). // 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 (mate && !k.beatable(top.card, after)) return pick(rich(cards));
if (cards[0].suit === top.card.suit) { 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 // «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». // 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): // 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) { export function choose(v, level = 'seria', seed = 1) {
const moves = legal(v.hand, v.table); const moves = legal(v.hand, v.table);
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null }; 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; c.t += dt;
if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; } 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); 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) { else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
c.aiSeed = lcg(c.aiSeed); c.aiSeed = lcg(c.aiSeed);
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, 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 const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
export function status(c, names = NAMES) { 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 === '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.step === 'trick') return `Prende ${names[c.last.winner]}.`;
if (c.current !== 0) { // Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
const p = c.table.at(-1); const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
return p?.signal ? `${names[p.who]}: «${p.signal[0].toUpperCase() + p.signal.slice(1)}!»` : `${names[c.current]} pensa…`; if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
}
if (c.refused) return c.refused; 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.`; return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
} }
+72 -5
View File
@@ -14,6 +14,8 @@ import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js'; import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js'; import * as tressette from './engine/tressette.js';
import { cardAt as tressetteAt } from './engine/tressette-render.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'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -403,11 +405,18 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return; if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { 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 (deck) {
if (game.skip()) return; if (game.skip()) return;
// Tressette: fra una smazzata e l'altra un tocco va avanti. // Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
if (tressette.advance(game.closeup)) return; 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 }; press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
return; return;
@@ -463,6 +472,7 @@ canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio. // Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = { 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.' }, 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.' }, 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.' }, 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-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); }); $('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. // 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++) { for (let i = 0; i < 10; i++) {
const button = $('tressette-card-' + i); const button = $('tressette-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(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; } 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. // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) { 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') { if (story.closeups[game.closeup.id].type === 'tressette') {
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup; const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; } 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 (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose(); else if (c.step === 'result') game.closeupClose();
else if (!tressette.advance(c) && key === 'enter') game.closeupPlay(); 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; return;
} }
if (e.key === 'Escape') game.closeupClose(); if (e.key === 'Escape') game.closeupClose();
@@ -700,11 +740,38 @@ function updateUi() {
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id]; 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').hidden = !mg || cards;
$('bocce-controls').inert = !running; $('bocce-controls').inert = !running;
$('briscola-controls').hidden = def?.type !== 'briscola'; $('briscola-controls').hidden = def?.type !== 'briscola';
$('briscola-controls').inert = !running; $('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').hidden = def?.type !== 'tressette';
$('tressette-controls').inert = !running; $('tressette-controls').inert = !running;
if (def?.type === 'tressette') { if (def?.type === 'tressette') {
+21
View File
@@ -124,6 +124,14 @@ export const story = {
draw: [], draw: [],
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }], 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. // Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: { bocce: {
type: 'bocce', type: 'bocce',
@@ -2154,6 +2162,7 @@ export const story = {
start: { options: [ 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: '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: '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: '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' }, { 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: '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' }, { 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. // Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro.
mazzo_tressette: { options: [ 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' }] }, { 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' }] },
+10 -6
View File
@@ -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 { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; } #scene.torch { touch-action: none; }
#scene.bocce { 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; } #scopa-controls, #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-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, .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 { flex-basis: 22%; }
.tressette-signals button[aria-pressed="true"], .tressette-hand button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; } .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-signals button:disabled, .tressette-hand button:disabled { opacity: .5; cursor: default; }
#tressette-controls summary { cursor: pointer; padding: 8px 0; } #scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; }
#tressette-controls p { max-width: 76em; } #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-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-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
.briscola-hand { display: flex; gap: 8px; } .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 { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#stage.briscola { 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; } .briscola-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; } #sentence { font-size: 10.08px; }
+149
View File
@@ -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(); }
+108
View File
@@ -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));
});
+8 -1
View File
@@ -28,13 +28,20 @@ async function playCard(page, mode, index, signal) {
const { handX, CARD } = await import('/src/engine/tressette-render.js'); const { handX, CARD } = await import('/src/engine/tressette-render.js');
return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30]; return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30];
}, index); }, 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; 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); if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
} }
async function wait(page) { async function wait(page) {
return page.evaluate(async () => { return page.evaluate(async () => {
const { canPlay } = await import('/src/engine/tressette.js'); 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; return game.closeup.step;
}); });
} }
+61 -24
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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'; 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); 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.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.equal(accuse(hand([['cuori', '2'], ['picche', '2'], ['fiori', '2'], ['mattoni', '2']]))[0].points, 4);
assert.deepEqual(accuse(hand([['cuori', 'Asso'], ['cuori', '2'], ['picche', '3']])), []); 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', () => { test('segni: volo con l\'ultima, striscio se ne resta un\'altra, busso sempre; solo chi esce', () => {
const h = hand([['cuori', '4'], ['cuori', '5'], ['cuori', '6'], ['fiori', '7']]); const h = hand([['cuori', '4'], ['cuori', '5'], ['fiori', '7']]);
assert.ok(signalOk(h, 3, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo')); assert.ok(signalOk(h, 2, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo'));
assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 3, [], 'striscio')); assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 2, [], 'striscio'));
assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(!signalOk(h, 3, [], 'busso')); assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(signalOk(h, 2, [], 'busso'));
assert.ok(!signalOk(h, 0, [{ who: 1, card: card('cuori', '7') }], '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.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.equal(c.hands[0].length, 3, 'la carta resta in mano');
assert.ok(play(c, 3)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null); 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')); 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)); 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 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, [0, 0]); assert.deepEqual(c.accuse, [], 'prima di giocare nessuno ha accusato');
assert.deepEqual(c.score, accused); const owed = c.pending.flat();
assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere'); assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere');
assert.ok(c.hands.every(h => h.length === 10)); assert.ok(c.hands.every(h => h.length === 10));
const firstDealer = c.dealer; 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)); } if (canPlay(c)) { const m = choose(observation(c, 0), 'seria'); assert.ok(play(c, m.index, 0, m.signal)); }
update(c, 20); 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', () => { 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); const c = newTressette(3); c.pending = [[], [], [], []];
c.step = 'smazzata'; c.score = [21, 21]; assert.ok(!finished(c)); advance(c); assert.equal(c.step, 'play'); c.base = [20, 19]; c.score = [20, 19]; c.thirds = [2, 0];
c.step = 'smazzata'; c.score = [TARGET, 17]; advance(c); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); // Una presa da un terzo porta noi a 21 (3 terzi = 1 punto): fuori subito.
c.step = 'smazzata'; c.score = [22, 24]; advance(c); assert.equal(c.result.winner, 'cpu'); 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') }];
assert.match(status(c), /Hanno vinto loro, 22 a 24/); 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', () => { 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'); 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', () => { test('taratura: Alla bona < Seria < A fassi male, contro Andrea e il socio che giocano attenti', () => {
const runs = calibrate(60); const runs = calibrate(60);
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs)); assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
+24
View File
@@ -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));
+2 -2
View File
@@ -1,12 +1,12 @@
// node tools/calibrate_tressette.mjs [partite per livello, predefinite 300] // 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. // 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') { export function simulate(seed, level, me = 'male') {
const c = newTressette(seed, { level }); const c = newTressette(seed, { level });
for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) { 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); } 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'); if (c.step !== 'result') throw new Error('Partita non conclusa');
return c; return c;