Tressette ai 21 all'oratorio: in quattro a coppie, Andrea e il socio contro il giocatore di carte e l'altro. Obbligo di seme, punti in terzi e ultima presa, accuse (napoletana, tre o quattro Assi, 2 o 3), segni onesti di chi esce (busso, striscio, volo) coi loro suoni, si va ai 21 smazzata dopo smazzata. Avversari «Alla bona», «Seria» (gioca a regola ma non conta) e «A fassi male» (conta, legge semi finiti, accuse e busso del socio). Tavolo a quattro, pulsanti dei segni e della mano, tastiera B/S/V; test, taratura e prova nel browser

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:14:46 +01:00
co-authored by Claude Opus 5.5
parent fef236dc54
commit e8152563d8
21 changed files with 803 additions and 11 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",
"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
+3 -1
View File
@@ -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.
+39
View File
@@ -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`.
+32
View File
@@ -103,6 +103,38 @@
<div id="inventory" aria-label="Inventario"></div>
</div>
<ol id="choices" hidden></ol>
<section id="tressette-controls" hidden aria-label="Comandi del tressette">
<p id="tressette-status" role="status"></p>
<div class="tressette-signals" aria-label="Segni per il socio, solo quando esci">
<button id="tressette-busso" type="button">Busso</button>
<button id="tressette-striscio" type="button">Striscio</button>
<button id="tressette-volo" type="button">Volo</button>
<button id="tressette-next" type="button" hidden>Prossima smazzata</button>
</div>
<div class="tressette-hand" aria-label="La mano di Andrea">
<button id="tressette-card-0" type="button"></button>
<button id="tressette-card-1" type="button"></button>
<button id="tressette-card-2" type="button"></button>
<button id="tressette-card-3" type="button"></button>
<button id="tressette-card-4" type="button"></button>
<button id="tressette-card-5" type="button"></button>
<button id="tressette-card-6" type="button"></button>
<button id="tressette-card-7" type="button"></button>
<button id="tressette-card-8" type="button"></button>
<button id="tressette-card-9" type="button"></button>
</div>
<details><summary>Come si gioca</summary>
<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
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>Accuse, a inizio smazzata: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno
3 punti, quattro fanno 4.</p>
<p>Segni, solo di chi esce: <b>busso</b> (prendi e ritornami il seme; me ne resta almeno un’altra),
<b>striscio</b> (di questo seme ne ho ancora parecchie), <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>
</details>
</section>
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
<p id="briscola-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea">
+1
View File
@@ -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"
+7 -3
View File
@@ -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));
+2
View File
@@ -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 },
};
+2
View File
@@ -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));
+4
View File
@@ -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);
+92
View File
@@ -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();
}
+236
View File
@@ -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.`;
}
+61 -6
View File
@@ -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;
+22
View File
@@ -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).
+10 -1
View File
@@ -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; }
+128
View File
@@ -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(); }
+124
View File
@@ -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));
});
+24
View File
@@ -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));