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:
@@ -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(); }
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user