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))); });