diff --git a/assets/voices/andrea-4fbe84fada.mp3 b/assets/voices/andrea-4fbe84fada.mp3
new file mode 100644
index 0000000..24da12c
Binary files /dev/null and b/assets/voices/andrea-4fbe84fada.mp3 differ
diff --git a/assets/voices/giocatore_carte-3215efd9dd.mp3 b/assets/voices/giocatore_carte-3215efd9dd.mp3
new file mode 100644
index 0000000..38a1574
Binary files /dev/null and b/assets/voices/giocatore_carte-3215efd9dd.mp3 differ
diff --git a/assets/voices/giocatore_carte-3e2671e35e.mp3 b/assets/voices/giocatore_carte-3e2671e35e.mp3
new file mode 100644
index 0000000..0c0ca1f
Binary files /dev/null and b/assets/voices/giocatore_carte-3e2671e35e.mp3 differ
diff --git a/assets/voices/giocatore_carte-5dbd39a25f.mp3 b/assets/voices/giocatore_carte-5dbd39a25f.mp3
new file mode 100644
index 0000000..67366e6
Binary files /dev/null and b/assets/voices/giocatore_carte-5dbd39a25f.mp3 differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index 8f8921a..94e70f4 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -1531,6 +1531,10 @@
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
+ "andrea|Un tressette ai 21?": {
+ "file": "andrea-4fbe84fada.mp3",
+ "sec": 1.66
+ },
"andrea|Ora cerco il palio. A dopo.": {
"file": "andrea-6e63dafda1.mp3",
"sec": 2.61
@@ -1543,6 +1547,10 @@
"file": "giocatore_carte-8dfadb4acc.mp3",
"sec": 3.4
},
+ "giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
+ "file": "giocatore_carte-3e2671e35e.mp3",
+ "sec": 4.88
+ },
"andrea|Con le toscane.": {
"file": "andrea-e8599cc8b6.mp3",
"sec": 1.03
@@ -2083,6 +2091,14 @@
"file": "giocatore_carte-729b3344da.mp3",
"sec": 3.3
},
+ "giocatore_carte|Ventuno per primi. Bella coppia, voi due!": {
+ "file": "giocatore_carte-3215efd9dd.mp3",
+ "sec": 3.51
+ },
+ "giocatore_carte|Ventuno! Il tressette è un'arte: si studia da piccini.": {
+ "file": "giocatore_carte-5dbd39a25f.mp3",
+ "sec": 4.44
+ },
"mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62
diff --git a/docs/STATO.md b/docs/STATO.md
index 84b7b37..14b1457 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -23,7 +23,9 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude),
disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
- Prossimo: il **tressette a coppie «ai 21»** con busso, striscio e volo (da chiedere ad Andrea: le 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`.
+ Prossimo: la **scopa** (7 bello, primiera, carte, mattoni/denari), poi basta coi giochi di carte.
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
diff --git a/docs/TRESSETTE.md b/docs/TRESSETTE.md
new file mode 100644
index 0000000..cb397da
--- /dev/null
+++ b/docs/TRESSETTE.md
@@ -0,0 +1,39 @@
+# Tressette ai 21
+
+All'oratorio, dal giocatore di carte: «Un tressette ai 21?». Quattro al tavolo, a coppie: Andrea e il socio
+(di fronte) contro il giocatore di carte e l'altro. [DA ANDREA: chi sono il socio e l'altro.]
+Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render.js` con le carte della briscola.
+
+## Regole
+
+- Mazzo da 40, toscane o napoletane (stessa scelta e stesso flag `carte_napoletane` della briscola). Dieci carte a testa.
+- Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata.
+- Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4.
+- Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi
+ avanzati si perdono (11 punti in tutto, a volte 10).
+- **Accuse** a inizio smazzata, contate subito nel punteggio: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
+ tre Assi, tre 2 o tre 3 fanno 3 punti, quattro fanno 4. Sono pubbliche: chi conta sa dove sono quelle carte.
+- **Segni**, solo di chi esce e onesti (il gioco rifiuta quelli falsi): busso (prendi e ritornami il seme; me ne
+ resta almeno un'altra), striscio (ne ho ancora almeno due), volo (era l'ultima). Si sentono: due nocche sul
+ tavolo, la carta strusciata, la carta lasciata cadere.
+- Si va **ai 21**: vince chi ci arriva a fine smazzata; pari sopra i 21 si gioca un'altra smazzata.
+
+Scelte da confermare con Andrea: accuse anche oltre i 21 a inizio smazzata (ora si controlla solo a fine
+smazzata); nessun «cappotto»; nessuna napoletana lunga.
+
+## Avversari
+
+- «Alla bona»: una carta a caso fra quelle buone, nessun segno.
+- «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta
+ quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro.
+- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme, ricorda le accuse, ritorna il seme al socio
+ che ha bussato. Il socio di Andrea gioca sempre così.
+
+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.
+
+## Prove
+
+`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
+col tocco (telefono e orizzontale) e con la tastiera, segni con pulsanti e tasti B, S, V, carta che non risponde
+al seme rifiutata. Schermate in `/tmp/regolo-shots/tressette-*.png`.
diff --git a/index.html b/index.html
index 8710606..0df0802 100644
--- a/index.html
+++ b/index.html
@@ -103,6 +103,38 @@
diff --git a/package.json b/package.json
index 9766e9c..acb4ea1 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
+ "browser:tressette": "node tests/tressette.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
diff --git a/src/engine/briscola-render.js b/src/engine/briscola-render.js
index 2f4efb8..9cd69a2 100644
--- a/src/engine/briscola-render.js
+++ b/src/engine/briscola-render.js
@@ -249,8 +249,8 @@ function countTable(ctx, c, names) {
}
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
}
-export function drawBriscola(ctx, c, def) {
- ctx.save(); ctx.imageSmoothingEnabled = false;
+// Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
+export function drawTable(ctx, title) {
rect(ctx, 0, 0, 640, 360, '#573720');
for (let y = 0; y < 360; y += 24) {
rect(ctx, 0, y, 640, 2, '#38271e');
@@ -258,7 +258,11 @@ export function drawBriscola(ctx, c, def) {
}
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
- text(ctx, 'BRISCOLA ALL’ORATORIO', 320, 15, 9, '#ffe0a0', 'center');
+ text(ctx, title, 320, 15, 9, '#ffe0a0', 'center');
+}
+export function drawBriscola(ctx, c, def) {
+ ctx.save(); ctx.imageSmoothingEnabled = false;
+ drawTable(ctx, 'BRISCOLA ALL’ORATORIO');
if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
diff --git a/src/engine/game.js b/src/engine/game.js
index 5506edd..0486ed1 100644
--- a/src/engine/game.js
+++ b/src/engine/game.js
@@ -4,10 +4,12 @@ import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js
import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
import * as briscola from './briscola.js';
+import * as tressette from './tressette.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = {
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
+ tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
};
diff --git a/src/engine/render.js b/src/engine/render.js
index a119762..76b1941 100644
--- a/src/engine/render.js
+++ b/src/engine/render.js
@@ -6,6 +6,7 @@ import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawBriscola } from './briscola-render.js';
+import { drawTressette } from './tressette-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -668,6 +669,7 @@ function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
+ if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js
index f3721af..8130cd4 100644
--- a/src/engine/soundscape.js
+++ b/src/engine/soundscape.js
@@ -103,6 +103,10 @@ export class Soundscape {
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
+ // I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
+ else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
+ else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
+ else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, pan); }
else if (kind === 'card-shuffle') {
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
diff --git a/src/engine/tressette-render.js b/src/engine/tressette-render.js
new file mode 100644
index 0000000..2ecf513
--- /dev/null
+++ b/src/engine/tressette-render.js
@@ -0,0 +1,92 @@
+import { canPlay, legal, accuseName, NAMES } from './tressette.js';
+import { drawCard, drawTable } from './briscola-render.js';
+import { text, panel } from './bocce-render.js';
+
+// La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco.
+export const CARD = { w: 42, h: 60, y: 288, lift: 8 };
+export function handX(n, i) {
+ const gap = n > 1 ? Math.min(44, (440 - CARD.w) / (n - 1)) : 0;
+ return Math.round(320 - (gap * (n - 1) + CARD.w) / 2 + i * gap);
+}
+// Le carte si coprono: vince quella sopra, cioè la più a destra.
+export function cardAt(c, [x, y]) {
+ if (!canPlay(c)) return -1;
+ const n = c.hands[0].length;
+ for (let i = n - 1; i >= 0; i--) {
+ const top = CARD.y - (c.selected === i ? CARD.lift : 0), left = handX(n, i);
+ if (x >= left && x < left + CARD.w && y >= top && y < top + CARD.h + CARD.lift) return i;
+ }
+ return -1;
+}
+// Dove cade la carta di ognuno al centro, e da dove arriva.
+const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]], FROM = [[0, 70], [80, 0], [0, -60], [-80, 0]];
+const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']];
+const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3';
+const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' };
+
+function others(ctx, c, names) {
+ // Il socio di fronte, gli avversari ai lati: carte coperte, quante ne hanno.
+ const n2 = c.hands[2].length, w2 = n2 ? (n2 - 1) * 12 + 26 : 0;
+ for (let i = 0; i < n2; i++) drawCard(ctx, null, 320 - w2 / 2 + i * 12, 46, 26, 37, true);
+ for (const [seat, x] of [[3, 30], [1, 584]]) {
+ for (let i = 0; i < c.hands[seat].length; i++) drawCard(ctx, null, x, 102 + i * 12, 26, 37, true);
+ }
+ for (const seat of [1, 2, 3]) {
+ // Chi deve giocare ha il nome in giallo.
+ const [x, y, align] = LABEL[seat], turn = c.step === 'play' && c.current === seat;
+ text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align);
+ }
+}
+function scoreboard(ctx, c) {
+ panel(ctx, 26, 34, 118, 38);
+ text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours);
+ text(ctx, `Loro ${c.score[1]}`, 136, 46, 7, theirs, 'right');
+ text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
+}
+function accuses(ctx, c, names) {
+ // Le accuse si vedono finché non è finita la prima presa.
+ if (c.tricks.length || !c.accuse.length) return;
+ 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));
+}
+function trick(ctx, c, names) {
+ c.table.forEach((p, i) => {
+ const slide = i === c.table.length - 1 && c.step === 'play' ? Math.max(0, 1 - c.t / 0.18) : 0;
+ const [x, y] = SPOT[p.who], [dx, dy] = FROM[p.who];
+ drawCard(ctx, p.card, x + dx * slide, y + dy * slide, CARD.w, CARD.h);
+ if (p.signal) {
+ const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30];
+ panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center');
+ }
+ });
+ if (c.step === 'trick') text(ctx, `Prende ${names[c.last.winner]}`, 320, 262, 6, gold, 'center');
+}
+function hand(ctx, c) {
+ const cards = c.hands[0], n = cards.length, mine = canPlay(c), ok = mine ? legal(cards, c.table) : [];
+ cards.forEach((card, i) => {
+ const x = handX(n, i), up = mine && c.selected === i ? CARD.lift : 0;
+ if (up) { ctx.fillStyle = '#ffdb76'; ctx.fillRect(x - 2, CARD.y - up - 2, CARD.w + 4, CARD.h + 4); }
+ drawCard(ctx, card, x, CARD.y - up, CARD.w, CARD.h);
+ // Le carte che non rispondono al seme si spengono.
+ if (mine && !ok.includes(i)) { ctx.fillStyle = 'rgba(20,28,24,0.55)'; ctx.fillRect(x, CARD.y, CARD.w, CARD.h); }
+ });
+ if (mine && !c.table.length && c.signal) text(ctx, `Segno: ${SAY[c.signal]}`, 320, 278, 6, gold, 'center');
+}
+function summary(ctx, c, title) {
+ const s = c.summary, row = (y, who, t, color) =>
+ text(ctx, `${who}: ${s.thirds[t]} terzi = ${s.points[t]} punti${s.accused[t] ? `, accuse +${s.accused[t]}` : ''}${s.lastTeam === t ? ' (con l’ultima)' : ''}`, 172, y, 6, color);
+ panel(ctx, 160, 96, 320, 132);
+ text(ctx, title, 320, 112, 8, gold, 'center');
+ row(140, 'Noi', 0, ours); row(158, 'Loro', 1, theirs);
+ text(ctx, `Siamo ${c.score[0]} a ${c.score[1]}`, 320, 186, 8, paper, 'center');
+ text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 210, 5, paper, 'center');
+}
+export function drawTressette(ctx, c, def) {
+ const names = def?.seats ?? NAMES;
+ ctx.save(); ctx.imageSmoothingEnabled = false;
+ drawTable(ctx, 'TRESSETTE AI 21');
+ scoreboard(ctx, c); others(ctx, c, names); accuses(ctx, c, names); trick(ctx, c, names); hand(ctx, c);
+ if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
+ if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Avete vinto!' : 'Hanno vinto loro');
+ ctx.restore();
+}
diff --git a/src/engine/tressette.js b/src/engine/tressette.js
new file mode 100644
index 0000000..f4d33f0
--- /dev/null
+++ b/src/engine/tressette.js
@@ -0,0 +1,236 @@
+// Tressette a coppie «ai 21», come si gioca in Mugello: quattro al tavolo, Andrea e il socio contro gli altri due.
+// Regole e avversari senza DOM, come la briscola. Segni di chi esce (busso, striscio, volo) e accuse a inizio smazzata.
+import { DECKS, cardName } from './briscola.js';
+
+export { cardName };
+export const LEVELS = ['bona', 'seria', 'male'];
+export const SIGNALS = ['busso', 'striscio', 'volo'];
+export const TARGET = 21;
+// Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra.
+export const SEATS = [0, 1, 2, 3];
+export const team = seat => seat % 2;
+export const next = seat => (seat + 1) % 4;
+export const partner = seat => (seat + 2) % 4;
+// Ordine di presa 3 > 2 > Asso > Re > Cavallo > Fante > 7 > 6 > 5 > 4 (indici delle carte: Asso, 2, …, 7, figure).
+const POWER = [7, 8, 9, 0, 1, 2, 3, 4, 5, 6];
+// Punti in terzi: l'Asso vale un punto, il 2, il 3 e le figure un terzo. 32 terzi più 3 dell'ultima presa: 11 punti.
+const THIRDS = [3, 1, 1, 0, 0, 0, 0, 1, 1, 1];
+const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
+ Object.freeze({ id: s * 10 + r, suit, rank, thirds: THIRDS[r], power: POWER[r] }))));
+export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
+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;
+}
+const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
+
+// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
+export function accuse(hand) {
+ const out = [], tops = ['Asso', '2', '3'];
+ for (const suit of new Set(hand.map(a => a.suit))) {
+ const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
+ if (cards.length === 3) out.push({ kind: 'napoletana', suit, points: 3, cards });
+ }
+ for (const rank of tops) {
+ const cards = hand.filter(a => a.rank === rank);
+ if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards });
+ }
+ 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}`;
+
+// Si risponde al seme quando si può; chi esce gioca quel che vuole.
+export function legal(hand, table) {
+ const led = table[0]?.card.suit, same = hand.flatMap((a, i) => a.suit === led ? [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);
+// I segni sono onesti: volo con l'ultima carta del seme, striscio se ne restano almeno due, busso se ne resta una.
+export function signalOk(hand, index, table, signal) {
+ if (!signal) return true;
+ if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
+ const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
+ return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 2 : rest >= 1;
+}
+// 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) {
+ const hand = c.hands[0], card = hand[index];
+ if (!card) return 'Scegli una carta.';
+ if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
+ if (!signal || signalOk(hand, index, c.table, signal)) return '';
+ if (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];
+}
+
+const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
+function deal(c) {
+ c.seed = lcg(c.seed);
+ const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
+ c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
+ // Fa le carte chi sta a sinistra di chi ha fatto prima; esce chi sta alla destra del mazziere.
+ c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
+ c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0];
+ c.accuse = SEATS.map(i => (c.mano + i) % 4).flatMap(who => accuse(c.hands[who]).map(a => ({ who, ...a })));
+ for (const a of c.accuse) c.score[team(a.who)] += a.points;
+ c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
+ c.events.push({ kind: 'card-shuffle' });
+}
+export function newTressette(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
+ const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: [0, 0], round: 0,
+ seed: seed >>> 0, aiSeed: (seed ^ 0x85ebca6b) >>> 0, age: 0, events: [], last: null, summary: null };
+ c.dealer = Math.floor(rng(seed)() * 4);
+ deal(c); return c;
+}
+export const canPlay = c => c.step === 'play' && c.current === 0;
+export function select(c, index) {
+ if (!canPlay(c) || !Number.isInteger(index) || !c.hands[0][index]) return false;
+ c.selected = index; c.refused = ''; return true;
+}
+export function nudge(c, direction) {
+ const n = c.hands[0].length;
+ return select(c, (c.selected + direction + n) % n);
+}
+// Il segno di Andrea vale per la prossima carta con cui esce; ripremuto, si toglie.
+export function setSignal(c, signal) {
+ if (!canPlay(c) || c.table.length || (signal && !SIGNALS.includes(signal))) return false;
+ c.signal = c.signal === signal ? null : signal; c.refused = ''; return true;
+}
+export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.signal : null) {
+ if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
+ if (!legal(c.hands[who], c.table).includes(index) || !signalOk(c.hands[who], index, c.table, signal)) {
+ if (who === 0) c.refused = reason(c, index, signal);
+ return false;
+ }
+ const [card] = c.hands[who].splice(index, 1);
+ c.table.push({ who, card, signal: signal || null }); c.t = 0; c.landing = true;
+ // Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
+ c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who });
+ if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); }
+ if (c.table.length === 4) { c.step = 'trick'; c.last = { winner: best(c.table).who, cards: c.table.map(p => p.card) }; }
+ else c.current = next(who);
+ return true;
+}
+export function collect(c) {
+ if (c.step !== 'trick') return false;
+ const winner = c.last.winner;
+ c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0);
+ c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
+ c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
+ if (c.hands[0].length) { c.step = 'play'; return true; }
+ // L'ultima presa vale un punto; i terzi avanzati non contano.
+ c.thirds[team(winner)] += 3;
+ const points = c.thirds.map(t => Math.floor(t / 3)), accused = [0, 1].map(t => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0));
+ c.score = c.score.map((s, t) => s + points[t]);
+ c.summary = { thirds: [...c.thirds], points, accused, lastTeam: team(winner), score: [...c.score] };
+ c.step = 'smazzata';
+ return true;
+}
+// Si va ai 21: chi arriva per primo a fine smazzata; pari sopra i 21, si gioca un'altra smazzata.
+export const finished = c => Math.max(...c.score) >= TARGET && c.score[0] !== c.score[1];
+export function advance(c) {
+ if (c.step !== 'smazzata') return false;
+ if (finished(c)) { c.step = 'result'; c.result = { winner: c.score[0] > c.score[1] ? 'player' : 'cpu', score: [...c.score] }; }
+ else deal(c);
+ return true;
+}
+
+// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
+export function observation(c, seat) {
+ return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })),
+ tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })),
+ accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
+}
+// Quel che un giocatore sa. Chi conta davvero («A fassi male») ricorda le carte uscite, i semi finiti di ognuno e le
+// carte accusate dagli altri.
+export function knowledge(v, full) {
+ // Chi non conta («Seria») vede solo le carte in tavola e le sue: un 3 già uscito per lui può essere ancora in giro.
+ const played = [...(full ? v.tricks.flatMap(t => t.cards) : []), ...v.table];
+ const gone = new Set([...v.hand, ...played.map(p => p.card)].map(a => a.id));
+ const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
+ const voids = SEATS.map(() => new Set()), holder = new Map();
+ if (full) {
+ for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => { if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit); });
+ 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);
+ // Qualcuno fra `seats` può ancora battere `card`?
+ const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
+ return { out, voids, could, beatable, full };
+}
+const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power); // il carico più grosso, senza buttare i 3
+const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
+function lead(v, k) {
+ const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
+ // Il socio ha bussato e la presa l'ho fatta io: gli ritorno il seme con la più bassa.
+ const prev = v.tricks.at(-1), knock = prev?.cards[0];
+ if (k.full && prev?.winner === v.seat && knock?.who === partner(v.seat) && knock.signal === 'busso') {
+ const same = hand.filter(a => a.suit === knock.card.suit);
+ if (same.length) return { index: hand.indexOf(cheap(same)), signal: null };
+ }
+ // Una carta che gli avversari non possono più battere: si fa la presa, e coi punti dentro.
+ const sure = hand.filter(a => !k.beatable(a, foes));
+ if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
+ // Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
+ const count = suit => hand.filter(a => a.suit === suit).length;
+ const longest = byMin(hand, a => -count(a.suit) * 10 - a.thirds).suit;
+ const card = cheap(hand.filter(a => a.suit === longest));
+ return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
+}
+function signalFor(hand, card, k) {
+ const rest = hand.filter(a => a !== card && a.suit === card.suit);
+ if (!rest.length) return 'volo';
+ // Busso: la mia più alta è la seconda buona del seme; il socio prenda con la prima e me lo ritorni.
+ const top = rest.reduce((a, b) => b.power > a.power ? b : a);
+ if (top.power > card.power && k.out.filter(a => a.suit === card.suit && a.power > top.power).length === 1) return 'busso';
+ return rest.length >= 2 ? 'striscio' : null;
+}
+function follow(v, k, moves) {
+ const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
+ const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
+ const pick = card => v.hand.indexOf(card);
+ // Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito).
+ if (mate && !k.beatable(top.card, after)) return pick(rich(cards));
+ if (cards[0].suit === top.card.suit) {
+ const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
+ if (safe.length) return pick(byMin(safe, a => a.power));
+ }
+ return pick(cheap(cards));
+}
+// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
+// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
+// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
+// Andrea e il socio vincono 86% contro «Alla bona», 59% contro «Seria», 52% contro «A fassi male» (docs/TRESSETTE.md).
+export function choose(v, level = 'seria', seed = 1) {
+ const moves = legal(v.hand, v.table);
+ if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
+ const k = knowledge(v, level === 'male');
+ return v.table.length ? { index: follow(v, k, moves), signal: null } : lead(v, k);
+}
+
+export function update(c, dt) {
+ if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
+ c.t += dt;
+ if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
+ if (c.step === 'trick' && c.t >= 1.2) collect(c);
+ else if (c.step === 'smazzata' && c.t >= 7) advance(c);
+ else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
+ c.aiSeed = lcg(c.aiSeed);
+ const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
+ play(c, index, seat, signal);
+ }
+}
+export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
+export function status(c, names = NAMES) {
+ if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
+ if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per continuare.`;
+ if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
+ if (c.current !== 0) {
+ const p = c.table.at(-1);
+ return p?.signal ? `${names[p.who]}: «${p.signal[0].toUpperCase() + p.signal.slice(1)}!»` : `${names[c.current]} pensa…`;
+ }
+ 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.`;
+ return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
+}
diff --git a/src/main.js b/src/main.js
index 17725aa..b7e17e5 100644
--- a/src/main.js
+++ b/src/main.js
@@ -12,6 +12,8 @@ import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js';
+import * as tressette from './engine/tressette.js';
+import { cardAt as tressetteAt } from './engine/tressette-render.js';
import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id);
@@ -401,9 +403,12 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
- if (story.closeups[game.closeup.id].type === 'briscola') {
+ const deck = CARD_AT[story.closeups[game.closeup.id].type];
+ if (deck) {
if (game.skip()) return;
- press = { card: cardAt(game.closeup, scenePoint(e)), state: game.closeup };
+ // Tressette: fra una smazzata e l'altra un tocco va avanti.
+ if (tressette.advance(game.closeup)) return;
+ press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
canvas.setPointerCapture(e.pointerId);
return;
}
@@ -425,7 +430,7 @@ canvas.addEventListener('pointerup', e => {
if (!press) return;
if (press.card !== undefined) {
const p = press; press = null;
- if (running && game.closeup === p.state && p.card >= 0 && cardAt(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
+ if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
return;
}
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
@@ -458,6 +463,7 @@ canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = {
+ tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' },
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
@@ -466,6 +472,15 @@ const miniState = () => running && game?.closeup && MINI[story.closeups[game.clo
const mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
+// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
+const CARD_AT = { briscola: cardAt, tressette: tressetteAt };
+for (let i = 0; i < 10; i++) {
+ const button = $('tressette-card-' + i);
+ button.addEventListener('click', () => game?.closeupPlay(i));
+ button.addEventListener('focus', () => { if (game?.closeup?.id === 'tressette') tressette.select(game.closeup, i); });
+}
+for (const signal of tressette.SIGNALS) $('tressette-' + signal).addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.setSignal(game.closeup, signal); });
+$('tressette-next').addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.advance(game.closeup); });
for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
@@ -560,6 +575,25 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
+ if (story.closeups[game.closeup.id].type === 'tressette') {
+ const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
+ if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
+ if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key)) return;
+ const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key];
+ if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) {
+ e.preventDefault();
+ if (e.repeat || game.skip()) return;
+ if (signal) tressette.setSignal(c, signal);
+ else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10);
+ else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
+ else if (c.step === 'result') game.closeupClose();
+ else if (!tressette.advance(c) && key === 'enter') game.closeupPlay();
+ return;
+ }
+ if (e.key === 'Escape') game.closeupClose();
+ else if (key === 'm') toggleMute();
+ return;
+ }
if (story.closeups[game.closeup.id].type === 'briscola') {
const inControls = e.target.closest?.('#briscola-controls');
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
@@ -666,12 +700,33 @@ function updateUi() {
updateChoices();
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
- const cards = def?.type === 'briscola';
+ const cards = def?.type === 'briscola' || def?.type === 'tressette';
$('bocce-controls').hidden = !mg || cards;
$('bocce-controls').inert = !running;
- $('briscola-controls').hidden = !cards;
+ $('briscola-controls').hidden = def?.type !== 'briscola';
$('briscola-controls').inert = !running;
- if (cards) {
+ $('tressette-controls').hidden = def?.type !== 'tressette';
+ $('tressette-controls').inert = !running;
+ if (def?.type === 'tressette') {
+ $('panel').hidden = $('choices').hidden = true;
+ const line = tressette.status(c, def.seats);
+ if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
+ const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
+ for (let i = 0; i < 10; i++) {
+ const button = $('tressette-card-' + i), card = c.hands[0][i];
+ const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
+ if (button.textContent !== label) button.textContent = label;
+ button.hidden = !card;
+ button.disabled = !ok.includes(i);
+ button.setAttribute('aria-pressed', String(c.selected === i && !!card));
+ }
+ for (const signal of tressette.SIGNALS) {
+ $('tressette-' + signal).disabled = !mine || c.table.length > 0;
+ $('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
+ }
+ $('tressette-next').hidden = c.step !== 'smazzata';
+ }
+ if (def?.type === 'briscola') {
$('panel').hidden = $('choices').hidden = true;
const line = briscola.status(c, def.names);
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
diff --git a/src/story/story.js b/src/story/story.js
index be05adf..0570fac 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -115,6 +115,15 @@ export const story = {
draw: [{ set: 'briscola_giocata' }, { say: 'Sessanta pari. Un si litiga nemmeno sul conto!', who: 'giocatore_carte', pose: 'talk' }],
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
},
+ // Il tressette a coppie ai 21 (idea di Andrea): busso, striscio, volo e accuse. [DA ANDREA: chi sono il socio e l'altro]
+ tressette: {
+ type: 'tressette', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
+ seats: ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'],
+ win: [{ set: 'tressette_vinto' }, { say: 'Ventuno per primi. Bella coppia, voi due!', who: 'giocatore_carte', pose: 'laugh' }],
+ lose: [{ say: 'Ventuno! Il tressette è un\'arte: si studia da piccini.', who: 'giocatore_carte', pose: 'laugh' }],
+ draw: [],
+ quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
+ },
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: {
type: 'bocce',
@@ -2145,6 +2154,7 @@ export const story = {
start: { options: [
{ text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
+ { text: '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' },
] },
// Il mazzo (idea di Andrea): le toscane di casa, o le napoletane. Stesse regole, cambiano i disegni.
@@ -2159,6 +2169,18 @@ export const story = {
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] },
{ text: 'Ci ripenso.', next: 'end' },
] },
+ // Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro.
+ mazzo_tressette: { options: [
+ { text: 'Con le toscane.', next: 'livello_tressette', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
+ { text: 'Con le napoletane.', next: 'livello_tressette', 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_tressette: { options: [
+ { text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'tressette', level: 'bona' }] },
+ { text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'tressette', 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: 'tressette', level: 'male' }] },
+ { text: 'Ci ripenso.', next: 'end' },
+ ] },
},
},
// Il chiosco della Tosca: le bibite si bevono subito; ghiacciolo e cedrata si portano via (una volta per tipo).
diff --git a/style.css b/style.css
index c92d780..99e2449 100644
--- a/style.css
+++ b/style.css
@@ -26,6 +26,15 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
+#tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
+#tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; }
+.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 { flex-basis: 22%; }
+.tressette-signals button[aria-pressed="true"], .tressette-hand button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; }
+.tressette-signals button:disabled, .tressette-hand button:disabled { opacity: .5; cursor: default; }
+#tressette-controls summary { cursor: pointer; padding: 8px 0; }
+#tressette-controls p { max-width: 76em; }
#briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
.briscola-hand { display: flex; gap: 8px; }
@@ -282,7 +291,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
- #briscola-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
+ #briscola-controls, #tressette-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
.briscola-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; }
diff --git a/tests/tressette.browser.mjs b/tests/tressette.browser.mjs
new file mode 100644
index 0000000..aea3692
--- /dev/null
+++ b/tests/tressette.browser.mjs
@@ -0,0 +1,128 @@
+// Tressette ai 21 nel browser: carte e segni con mouse, tocco e tastiera; si accelerano solo le attese.
+// npm run browser:tressette — /tmp/regolo-shots/tressette-*.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 playCard(page, mode, index, signal) {
+ if (signal) {
+ if (mode === 'keyboard') await page.keyboard.press(signal[0]);
+ else await tap(page, mode, `#tressette-${signal}`);
+ assert.equal(await page.evaluate(() => game.closeup.signal), signal);
+ }
+ if (mode === 'keyboard') {
+ await page.keyboard.press(String((index + 1) % 10));
+ assert.equal(await page.evaluate(() => game.closeup.selected), index);
+ await page.keyboard.press('Enter');
+ return;
+ }
+ const at = await page.evaluate(async i => {
+ const { handX, CARD } = await import('/src/engine/tressette-render.js');
+ return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30];
+ }, index);
+ const r = await page.locator('#scene').boundingBox(), x = r.x + at[0] / 640 * r.width, y = r.y + at[1] / 360 * r.height;
+ if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
+}
+async function wait(page) {
+ return page.evaluate(async () => {
+ const { canPlay } = await import('/src/engine/tressette.js');
+ for (let i = 0; i < 12 && !canPlay(game.closeup) && !['smazzata', 'result'].includes(game.closeup.step); i++) game.update(1.3);
+ 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: 'Un tressette ai 21?' }).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('#tressette-controls:not([hidden])');
+ assert.ok(await page.isHidden('#briscola-controls')); assert.ok(await page.isHidden('#bocce-controls'));
+ const scene = await page.locator('#scene').boundingBox();
+ assert.ok(scene.width * 42 / 640 >= 18, 'carte toccabili anche sul telefono');
+ await wait(page);
+ await page.screenshot({ path: `${out}/tressette-${name}-inizio.png` });
+ // Una carta che non risponde al seme non parte: resta in mano e il motivo si legge.
+ let refusedChecked = false, signals = 0, played = 0, shotSignal = false;
+ for (let steps = 0; steps < 400; steps++) {
+ const step = await wait(page);
+ if (step === 'result') break;
+ if (step === 'smazzata') {
+ await page.screenshot({ path: `${out}/tressette-${name}-smazzata.png` });
+ if (!full) break;
+ if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#tressette-next');
+ continue;
+ }
+ if (!refusedChecked) {
+ const bad = await page.evaluate(async () => {
+ const { legal } = await import('/src/engine/tressette.js');
+ const c = game.closeup, ok = legal(c.hands[0], c.table);
+ return c.hands[0].findIndex((_, i) => !ok.includes(i));
+ });
+ if (bad >= 0) {
+ const held = await page.evaluate(() => game.closeup.hands[0].length);
+ await playCard(page, mode, bad);
+ assert.match(await page.evaluate(() => game.closeup.refused), /Devi rispondere/);
+ await page.waitForFunction(() => /Devi rispondere/.test(document.getElementById('tressette-status').textContent));
+ assert.equal(await page.evaluate(() => game.closeup.hands[0].length), held);
+ refusedChecked = true;
+ }
+ }
+ const move = await page.evaluate(async () => {
+ const { observation, choose } = await import('/src/engine/tressette.js');
+ return choose(observation(game.closeup, 0), 'male', 7);
+ });
+ const before = await page.evaluate(() => game.closeup.hands[0].length);
+ await playCard(page, mode, move.index, move.signal); played++;
+ if (move.signal) {
+ signals++;
+ assert.equal(await page.evaluate(() => game.closeup.table.find(p => p.who === 0)?.signal), move.signal);
+ if (!shotSignal) { await page.waitForTimeout(60); await page.screenshot({ path: `${out}/tressette-${name}-segno.png` }); shotSignal = true; }
+ }
+ assert.equal(await page.evaluate(() => game.closeup.hands[0].length), before - 1, 'un gesto gioca una carta');
+ if (played === 1) { await page.evaluate(() => game.update(0.8)); await page.screenshot({ path: `${out}/tressette-${name}-presa.png` }); }
+ }
+ if (full) {
+ assert.equal(await page.evaluate(() => game.closeup.step), 'result');
+ await page.screenshot({ path: `${out}/tressette-${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.tressette_vinto), winner === 'player');
+ } else {
+ await page.keyboard.press('Escape');
+ assert.equal(await page.evaluate(() => game.closeup), null);
+ assert.equal(await page.evaluate(() => !!game.state.flags.tressette_vinto), false, 'chi lascia non vince');
+ }
+ console.log(`${name}: ${played} carte giocate (${signals} con un segno)${full ? ', partita ai 21 finita' : ', una smazzata'} OK`);
+ await page.close();
+ }
+ assert.deepEqual(errors, []); console.log(`Schermate: ${out}/tressette-*.png`);
+} finally { await browser?.close(); server.kill(); }
diff --git a/tests/tressette.test.mjs b/tests/tressette.test.mjs
new file mode 100644
index 0000000..29906ea
--- /dev/null
+++ b/tests/tressette.test.mjs
@@ -0,0 +1,124 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { DECK_CARDS, newTressette, accuse, legal, best, signalOk, reason, setSignal, play, collect, advance, update, observation, choose, knowledge, canPlay, finished, team, status, TARGET } from '../src/engine/tressette.js';
+import { 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 hand = list => list.map(([s, r]) => card(s, r));
+
+test('mazzo: 40 carte, 32 terzi di punti, ordine 3 > 2 > Asso > figure > 7 … 4', () => {
+ for (const cards of Object.values(DECK_CARDS)) {
+ assert.equal(cards.length, 40); assert.equal(cards.reduce((n, a) => n + a.thirds, 0), 32);
+ }
+ const order = ['3', '2', 'Asso', 'Regio', 'Donna', 'Gobbo', '7', '6', '5', '4'];
+ for (let i = 1; i < order.length; i++) assert.ok(card('cuori', order[i - 1]).power > card('cuori', order[i]).power, order[i]);
+ assert.equal(card('picche', 'Asso').thirds, 3); assert.equal(card('picche', 'Regio').thirds, 1); assert.equal(card('picche', '7').thirds, 0);
+});
+
+test('si risponde al seme; chi non ce l\'ha gioca quel che vuole; prende la più alta del seme di uscita', () => {
+ const h = hand([['cuori', '4'], ['fiori', '3'], ['cuori', 'Regio']]);
+ assert.deepEqual(legal(h, []), [0, 1, 2]);
+ assert.deepEqual(legal(h, [{ who: 1, card: card('cuori', '7') }]), [0, 2]);
+ assert.deepEqual(legal(h, [{ who: 1, card: card('picche', '7') }]), [0, 1, 2]);
+ const table = [{ who: 1, card: card('cuori', '4') }, { who: 2, card: card('fiori', '3') }, { who: 3, card: card('cuori', '2') }, { who: 0, card: card('cuori', 'Asso') }];
+ assert.equal(best(table).who, 3, 'il 3 di fiori non risponde: prende il 2 di cuori');
+});
+
+test('accuse: napoletana 3 punti, tre o quattro Assi, 2 o 3 per 3 o 4 punti, anche insieme', () => {
+ const h = hand([['cuori', 'Asso'], ['cuori', '2'], ['cuori', '3'], ['picche', '3'], ['fiori', '3'], ['mattoni', '7']]);
+ const a = accuse(h);
+ assert.deepEqual(a.map(x => [x.kind, x.points]), [['napoletana', 3], ['buongioco', 3]]);
+ assert.equal(accuse(hand([['cuori', '2'], ['picche', '2'], ['fiori', '2'], ['mattoni', '2']]))[0].points, 4);
+ assert.deepEqual(accuse(hand([['cuori', 'Asso'], ['cuori', '2'], ['picche', '3']])), []);
+});
+
+test('segni onesti: volo con l\'ultima, striscio con almeno due rimaste, busso con almeno una; solo chi esce', () => {
+ const h = hand([['cuori', '4'], ['cuori', '5'], ['cuori', '6'], ['fiori', '7']]);
+ assert.ok(signalOk(h, 3, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo'));
+ assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 3, [], 'striscio'));
+ assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(!signalOk(h, 3, [], 'busso'));
+ 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];
+ assert.ok(setSignal(c, 'volo')); assert.equal(play(c, 0), false); assert.match(c.refused, /Volo solo/);
+ assert.equal(c.hands[0].length, 4, 'la carta resta in mano');
+ assert.ok(play(c, 3)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null);
+ assert.ok(c.events.some(e => e.kind === 'card-volo'));
+});
+
+test('la carta che non risponde al seme si rifiuta con la ragione', () => {
+ const c = newTressette(9); c.current = 0; c.hands[0] = hand([['cuori', '4'], ['fiori', '3']]);
+ c.table = [{ who: 3, card: card('cuori', '7') }];
+ assert.equal(play(c, 1), false); assert.equal(reason(c, 1), 'Devi rispondere a cuori.');
+ 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', () => {
+ const c = newTressette(1234, { level: 'seria' });
+ const accused = [0, 1].map(t => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0));
+ assert.deepEqual(c.score, accused);
+ assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere');
+ assert.ok(c.hands.every(h => h.length === 10));
+ const firstDealer = c.dealer;
+ while (c.step !== 'smazzata') {
+ if (canPlay(c)) { const m = choose(observation(c, 0), 'seria'); assert.ok(play(c, m.index, 0, m.signal)); }
+ update(c, 20);
+ }
+ assert.equal(c.tricks.length, 10);
+ assert.equal(c.summary.thirds[0] + c.summary.thirds[1], 35);
+ assert.ok([10, 11].includes(c.summary.points[0] + c.summary.points[1]), 'si perdono al più i terzi avanzati');
+ assert.deepEqual(c.score, [0, 1].map(t => accused[t] + c.summary.points[t]));
+ if (!finished(c)) { advance(c); assert.equal(c.dealer, (firstDealer + 1) % 4); assert.equal(c.step, 'play'); }
+});
+
+test('si va ai 21: vince chi ci arriva per primo; pari sopra i 21 si continua', () => {
+ const c = newTressette(3);
+ c.step = 'smazzata'; c.score = [21, 21]; assert.ok(!finished(c)); advance(c); assert.equal(c.step, 'play');
+ c.step = 'smazzata'; c.score = [TARGET, 17]; advance(c); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
+ c.step = 'smazzata'; c.score = [22, 24]; advance(c); assert.equal(c.result.winner, 'cpu');
+ assert.match(status(c), /Hanno vinto loro, 22 a 24/);
+});
+
+test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => {
+ const a = simulate(77, 'male'), b = simulate(77, 'male');
+ assert.deepEqual(a.score, b.score); assert.ok(Math.max(...a.score) >= TARGET);
+ const t = newTressette(42), n = newTressette(42, { deck: 'napoletane' });
+ assert.deepEqual(t.hands.map(h => h.map(a => a.id)), n.hands.map(h => h.map(a => a.id)));
+ assert.equal(n.hands[0][0].suit === 'denari' || ['coppe', 'spade', 'bastoni'].includes(n.hands[0][0].suit), true);
+});
+
+test('gli altri non vedono le carte altrui: l\'osservazione ha solo la propria mano e il tavolo', () => {
+ const c = newTressette(11), v = observation(c, 1);
+ assert.deepEqual(v.hand.map(a => a.id), c.hands[1].map(a => a.id));
+ assert.equal(v.hands, undefined); assert.equal(v.seed, undefined);
+ const k = knowledge(v, true);
+ assert.equal(k.out.length, 30, 'fuori ci sono le 30 carte degli altri tre');
+ assert.ok(!k.could(1, k.out[0]), 'non può pensare di averle lui');
+ // Cambiare le mani degli altri non cambia la scelta.
+ const other = structuredClone(c); other.hands[0].reverse(); other.hands[2].reverse();
+ c.current = other.current = 1;
+ assert.deepEqual(choose(observation(c, 1), 'male', 5), choose(observation(other, 1), 'male', 5));
+});
+
+test('«A fassi male» conta: sa che il 3 è uscito, e col 2 si fa la presa sicura', () => {
+ const c = newTressette(8); c.current = 1; c.table = [];
+ c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]);
+ c.tricks = [{ leader: 0, winner: 0, cards: [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('fiori', '5') }, { who: 2, card: card('cuori', '4') }, { who: 3, card: card('cuori', '5') }] }];
+ assert.equal(choose(observation(c, 1), 'male').index, 0, 'esce col 2, ormai il più alto');
+ const foes = [0, 2];
+ assert.equal(knowledge(observation(c, 1), false).beatable(card('cuori', '2'), foes), true, 'chi non conta ha paura del 3');
+ assert.equal(knowledge(observation(c, 1), true).beatable(card('cuori', '2'), foes), false);
+});
+
+test('il socio ritorna il seme a chi ha bussato', () => {
+ const c = newTressette(8); c.current = 2; c.table = [];
+ c.hands[2] = hand([['cuori', '4'], ['fiori', '2'], ['cuori', '7']]);
+ c.tricks = [{ leader: 0, winner: 2, cards: [{ who: 0, card: card('cuori', '5'), signal: 'busso' }, { who: 1, card: card('cuori', '6') }, { who: 2, card: card('cuori', '3') }, { who: 3, card: card('picche', '4') }] }];
+ const m = choose(observation(c, 2), 'male');
+ assert.equal(c.hands[2][m.index].suit, 'cuori'); assert.equal(c.hands[2][m.index].rank, '4');
+});
+
+test('taratura: Alla bona < Seria < A fassi male, contro Andrea e il socio che giocano attenti', () => {
+ const runs = calibrate(60);
+ assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
+ assert.ok(runs[1].playerPercent >= runs[2].playerPercent, JSON.stringify(runs));
+});
diff --git a/tools/calibrate_tressette.mjs b/tools/calibrate_tressette.mjs
new file mode 100644
index 0000000..b87415d
--- /dev/null
+++ b/tools/calibrate_tressette.mjs
@@ -0,0 +1,24 @@
+// node tools/calibrate_tressette.mjs [partite per livello, predefinite 300]
+// Stesse carte per i tre livelli; Andrea gioca come il suo socio («A fassi male»), gli avversari al livello scelto.
+import { newTressette, observation, choose, play, update, canPlay, LEVELS } from '../src/engine/tressette.js';
+
+export function simulate(seed, level, me = 'male') {
+ const c = newTressette(seed, { level });
+ for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) {
+ if (canPlay(c)) { const m = choose(observation(c, 0), me, steps + 1); if (!play(c, m.index, 0, m.signal)) throw new Error('mossa rifiutata: ' + c.refused); }
+ update(c, 20); c.events.length = 0;
+ }
+ if (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));