Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
184 lines
12 KiB
JavaScript
184 lines
12 KiB
JavaScript
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)));
|
||
});
|