Files
leggenda-del-regolo/tests/briscola.test.mjs
T

184 lines
12 KiB
JavaScript
Raw Permalink 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, undefined, 'risultato solo dopo il conteggio');
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?' : 'La rivincita?');
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)));
});