Files
leggenda-del-regolo/tests/briscola.test.mjs
T
manzoloandClaude Opus 5.5 7e1e6f6144 Giochi di carte dopo la revisione di Codex sui tre insieme; player dei video «Come si gioca» e primo copione
Esito che vale e si salva appena deciso, per tutti i minigiochi (anche tornando al titolo dalla schermata del risultato); briscola: uscire durante il conteggio non perde più la vittoria; tressette: si esce ai 21 col primo evento che ci porta, accusa compresa (prima vinceva sempre Andrea se ci arrivavano tutte e due), il carico che fa vincere si dà anche se è il 2, accuse che non escono dal tavolo, convenzioni scritte nell'aiuto; scopa: primiera valutata per quel che migliora, finale esatto a mazzo finito, presa sulla tavola al rilascio con scelta e conferma, frecce e Invio sempre sulla presa scelta. Tastiera: Invio e Spazio giocano sempre la carta scelta. Tavola descritta a parole. Classi CSS comuni ai tre giochi.
Video: player nel gioco (pulsante «Guarda il video» nel «Come si gioca», compare quando il video esiste), video/narra.py per la voce del narratore, pezzi comuni dei copioni e il copione della briscola; scaletta in video/SCALETTA-CARTE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 16:39:22 +01:00

206 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { CARDS, SUITS, DECKS, DECK_CARDS, newBriscola, points, beats, trickWinner, play, collect, update, observation, unseen, chooseCard, attentive, perfect, result, counted, canPlay, select, nudge } from '../src/engine/briscola.js';
import { simulate, calibrate } from '../tools/calibrate_briscola.mjs';
import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
const card = (suit, rank) => CARDS.find(a => a.suit === suit && a.rank === rank);
test('mazzo italiano: 40 carte distinte, 120 punti, ordine di presa completo', () => {
assert.equal(CARDS.length, 40); assert.equal(new Set(CARDS.map(a => a.id)).size, 40); assert.equal(points(CARDS), 120);
const order = ['Asso', '3', 'Regio', 'Donna', 'Gobbo', '7', '6', '5', '4', '2'];
for (const suit of SUITS) for (let i = 0; i < order.length; i++) for (let j = 0; j < order.length; j++)
assert.equal(beats(card(suit, order[i]), card(suit, order[j]), 'mattoni'), i < j);
assert.equal(beats(card('mattoni', '2'), card('cuori', 'Asso'), 'mattoni'), true);
assert.equal(beats(card('picche', 'Asso'), card('cuori', '2'), 'mattoni'), false);
assert.equal(trickWinner([{ who: 'cpu', card: card('cuori', '2') }, { who: 'player', card: card('picche', 'Asso') }], 'mattoni'), 'cpu');
});
test('distribuzione: seed ripetibile, mazziere casuale, primo di mano e briscola in fondo', () => {
const dealers = new Set();
for (let n = 1; n <= 30; n++) {
const seed = Math.imul(n, 2654435761) >>> 0, c = newBriscola(seed);
assert.deepEqual(c, newBriscola(seed)); dealers.add(c.dealer);
assert.notEqual(c.current, c.dealer);
assert.equal(c.hands.player.length, 3); assert.equal(c.hands.cpu.length, 3);
assert.equal(c.stock.length, 34); assert.equal(c.stock.at(-1).id, c.trumpCard.id);
assert.equal(new Set([...c.stock, ...c.hands.player, ...c.hands.cpu].map(a => a.id)).size, 40);
}
assert.equal(dealers.size, 2);
});
test('nessun obbligo di seme; la presa resta sul tavolo, chi prende pesca prima ed esce', () => {
const c = newBriscola(42);
c.current = 'cpu'; c.trump = 'mattoni';
c.hands.cpu = [card('cuori', 'Asso')];
c.hands.player = [card('cuori', '2'), card('mattoni', '2')];
const stock = [...c.stock];
assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1));
assert.equal(c.step, 'trick'); assert.equal(c.last.winner, 'player'); assert.equal(c.table.length, 2);
update(c, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length);
update(c, 0.26);
assert.equal(c.hands.player.at(-1).id, stock[0].id);
assert.equal(c.hands.cpu.at(-1).id, stock[1].id);
assert.equal(c.current, 'player'); assert.equal(c.table.length, 0);
});
test('l’ultima pescata è la briscola, poi tre prese senza mazzo; totale e conteggio finale', () => {
for (const level of ['bona', 'seria', 'male']) {
const c = newBriscola(612347, { level }); let lastDraw = false;
for (let i = 0; i < 200 && c.step !== 'count'; i++) {
if (canPlay(c)) play(c, attentive(observation(c, 'player')));
if (c.step === 'trick' && c.stock.length === 2) {
const winner = c.last.winner; collect(c);
const loser = winner === 'cpu' ? 'player' : 'cpu';
assert.equal(c.hands[loser].at(-1).id, c.trumpCard.id);
assert.equal(c.drawnTrump, loser); assert.equal(c.hands.player.length, 3); assert.equal(c.hands.cpu.length, 3);
assert.equal(c.history.length, 17); lastDraw = true;
} else update(c, 2);
}
assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20);
assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0);
assert.equal(c.result.player + c.result.cpu, 120, 'il risultato è già deciso: il conteggio lo mostra soltanto');
assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40);
assert.deepEqual(counted(c), { player: 0, cpu: 0 });
update(c, 0.36); assert.equal(c.count, 1);
update(c, 14); assert.equal(c.step, 'result'); assert.equal(c.result.player + c.result.cpu, 120);
assert.deepEqual(counted(c), { player: c.result.player, cpu: c.result.cpu });
}
});
test('60–60 è patta, 61 vince; input bloccato fuori turno e selezione valida', () => {
// Due semi per ciascuno: ogni seme vale esattamente 30.
assert.deepEqual(result({ player: CARDS.slice(0, 20), cpu: CARDS.slice(20) }), { player: 60, cpu: 60, winner: null });
const c = newBriscola(1); c.current = 'player';
assert.ok(select(c, 2)); assert.ok(nudge(c, 1)); assert.equal(c.selected, 0);
assert.equal(select(c, 3), false); assert.equal(play(c, -1), false);
assert.ok(play(c)); assert.equal(play(c), false); assert.equal(select(c, 1), false);
assert.equal(result({ player: [card('cuori', 'Asso')], cpu: [] }).winner, 'player');
const p = [11, 10, 11, 10, 11, 4, 4].map((n, i) => ({ id: i, points: n }));
assert.equal(result({ player: p, cpu: [{ points: 59 }] }).winner, 'player');
});
test('i tre livelli sono deterministici per tutta la partita', () => {
for (const level of ['bona', 'seria', 'male']) {
const a = simulate(927421, level), b = simulate(927421, level);
assert.deepEqual(a, b); assert.equal(a.history.length, 20);
assert.equal(a.result.player + a.result.cpu, 120);
}
assert.equal(newBriscola(1, { level: 'inesistente' }).level, 'seria');
});
test('Seria prende il carico con la briscola piccola e liscia senza sprecarla', () => {
const v = { hand: [card('mattoni', 'Regio'), card('picche', '2'), card('mattoni', '2')], trump: 'mattoni', remaining: 20, table: [{ who: 'player', card: card('cuori', 'Asso') }] };
assert.equal(chooseCard(v, 'seria'), 2);
v.table[0].card = card('cuori', '4'); assert.equal(chooseCard(v, 'seria'), 1);
});
test('IA senza informazioni nascoste: vista invariata anche invertendo mano e mazzo veri', () => {
const c = newBriscola(9127), before = observation(c), d = structuredClone(c);
[d.hands.player[0], d.stock[0]] = [d.stock[0], d.hands.player[0]]; d.stock.reverse(); d.aiSeed = 999;
assert.deepEqual(observation(d), before);
for (const level of ['bona', 'seria', 'male']) assert.equal(chooseCard(observation(d), level, 432), chooseCard(before, level, 432));
// Fallisce immediatamente se observation prova a leggere qualcosa oltre il numero di carte.
const hidden = a => new Proxy(a, { get(target, key) { if (key !== 'length') throw new Error('Informazione nascosta: ' + String(key)); return target.length; } });
c.stock = hidden(c.stock); c.hands.player = hidden(c.hands.player);
assert.deepEqual(observation(c), before);
const saved = JSON.stringify(before); chooseCard(before, 'male', 17); assert.equal(JSON.stringify(before), saved);
assert.ok(!('seed' in before) && !('stock' in before));
});
test('finale noto: complementare delle carte viste e minimax contro ogni risposta possibile', () => {
const c = newBriscola(69298, { level: 'male' });
while (c.stock.length) {
if (canPlay(c)) play(c, attentive(observation(c, 'player')));
update(c, 2);
}
for (const who of ['player', 'cpu']) {
const v = observation(c, who);
assert.deepEqual(unseen(v).map(a => a.id).sort((a,b) => a-b), c.hands[who === 'cpu' ? 'player' : 'cpu'].map(a => a.id).sort((a,b) => a-b));
}
// Oracolo indipendente: enumera ogni seguito del motore reale, senza euristiche.
function exhaustive(state, who) {
if (!state.hands.player.length && !state.hands.cpu.length) return points(state.taken[who]);
const values = state.hands[state.current].map((_, i) => {
const next = structuredClone(state); play(next, i, state.current); if (next.step === 'trick') collect(next);
return exhaustive(next, who);
});
return state.current === who ? Math.max(...values) : Math.min(...values);
}
const who = c.current, v = observation(c, who), scores = perfect(v.hand, unseen(v), v.table, v.trump, who);
const oracle = v.hand.map((_, i) => { const d = structuredClone(c); play(d, i, who); if (d.step === 'trick') collect(d); return exhaustive(d, who); });
const chosen = chooseCard(v, 'male');
assert.equal(oracle[chosen], Math.max(...oracle)); assert.equal(scores[chosen], Math.max(...scores));
// Anche tutte le successive risposte, con la prima carta già sul tavolo.
while (c.hands.player.length || c.hands.cpu.length) {
const turn = c.current, view = observation(c, turn);
const expected = view.hand.map((_, i) => {
const d = structuredClone(c); play(d, i, turn); if (d.step === 'trick') collect(d);
return exhaustive(d, turn);
});
const index = chooseCard(view, 'male'); assert.equal(expected[index], Math.max(...expected));
play(c, index, turn); if (c.step === 'trick') collect(c);
}
});
test('taratura su mazzi uguali: Alla bona < Seria < A fassi male contro il giocatore attento', () => {
const runs = calibrate(160);
assert.ok(runs[0].wins.player > runs[1].wins.player, JSON.stringify(runs));
assert.ok(runs[1].wins.player > runs[2].wins.player, JSON.stringify(runs));
});
test('oratorio: dialogo, livelli, abbandono, risultati, rivincita e salvataggio facoltativo', () => {
for (const [outcome, winner] of [['win', 'player'], ['lose', 'cpu'], ['draw', null], ['quit', undefined]]) {
const g = new Game(story); settle(g); g.go('oratorio'); settle(g);
g.act('talk', 'giocatore_carte'); settle(g); g.choose(0); settle(g);
assert.deepEqual(g.options().map(o => o.text), ['Con le toscane.', 'Con le napoletane.', 'Ci ripenso.']);
const deck = winner === 'cpu' ? 'napoletane' : 'toscane';
g.choose(deck === 'napoletane' ? 1 : 0); settle(g);
assert.deepEqual(g.options().slice(0, 3).map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.']);
g.choose(2); settle(g); assert.equal(g.closeup.level, 'male'); assert.equal(g.closeup.deck, deck);
assert.equal(g.closeupPress(), false); assert.equal(g.closeupRelease(), false); assert.equal(g.closeupCancel(), false);
assert.equal(g.closeupAim([200, 250]), false);
if (outcome !== 'quit') Object.assign(g.closeup, { step: 'result', result: { winner } });
g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
g.act('talk', 'giocatore_carte'); settle(g);
assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'Rivincita a briscola?');
assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
}
});
test('due mazzi, stesse regole: le napoletane cambiano solo i nomi', () => {
const t = newBriscola(7), n = newBriscola(7, { deck: 'napoletane' });
assert.equal(t.deck, 'toscane'); assert.equal(n.deck, 'napoletane');
assert.equal(newBriscola(7, { deck: 'piacentine' }).deck, 'toscane');
assert.deepEqual(n.stock.map(a => a.id), t.stock.map(a => a.id));
assert.equal(points(DECK_CARDS.napoletane), 120);
assert.ok(n.hands.player.every(a => DECKS.napoletane.suits.includes(a.suit)));
assert.ok(t.hands.player.every(a => DECKS.toscane.suits.includes(a.suit)));
assert.ok(DECK_CARDS.toscane.some(a => a.rank === 'Regio' && a.points === 4 && a.suit === 'mattoni'));
// L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane.
assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit)));
});
// Codex (revisione dei tre giochi): l'esito vale appena è deciso. Uscire durante il conteggio della briscola non lo
// perde, e i flag finiscono nel salvataggio anche se dal risultato si torna al titolo senza uscire dal tavolo.
test('carte all\'oratorio: l\'esito si salva appena deciso, e uscire durante il conteggio non lo perde', () => {
const games = [['Una briscola?', 'briscola', 'briscola_vinta'], ['Un tressette ai 21?', 'tressette', 'tressette_vinto'], ['Una scopa?', 'scopa', 'scopa_vinta']];
for (const [ask, id, flag] of games) {
const saved = [], g = new Game(story, { onSave: s => saved.push(s) });
settle(g); g.go('oratorio'); settle(g);
g.act('talk', 'giocatore_carte'); settle(g);
for (const text of [ask, 'Con le toscane.', 'Seria.']) { g.choose(g.options().findIndex(o => o.text === text)); settle(g); }
assert.equal(g.closeup?.id, id);
// La briscola durante il conteggio: il risultato c'è già. Gli altri due, a risultato.
Object.assign(g.closeup, id === 'briscola' ? { step: 'count', result: { winner: 'player', player: 70, cpu: 50 } } : { step: 'result', result: { winner: 'player' } });
g.update(0.01);
assert.equal(saved.at(-1).flags[flag], true, `${id}: la vittoria è già nel salvataggio`);
assert.equal(g.state.flags[flag], true);
g.closeupClose(); const said = settle(g).map(s => s.text);
const win = story.closeups[id].win.find(e => e.say).say, quit = story.closeups[id].quit[0].say;
assert.ok(said.includes(win) && !said.includes(quit), `${id}: esce con la battuta della vittoria, non con quella di chi lascia`);
assert.equal(g.state.flags[flag], true);
}
});