Briscola all'oratorio: alle tavolate il giocatore di carte (segnaposto, sprite di Codex) sfida Andrea a briscola in due. Si sceglie il mazzo, toscane (cuori, mattoni, fiori, picche; gobbo, donna e regio) o napoletane, e il livello: «Alla bona», «Seria», «A fassi male» (conta le carte uscite, finale perfetto a mazzo finito, mai la mano altrui). Regole fedeli, conteggio delle prese a fine partita, rivincita, suoni di carte, comandi con mouse, tocco e tastiera. Carte e dorso col Regolo serpentello disegnati da Codex; motore e prove nel browser rivisti da Claude

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 13:56:39 +01:00
co-authored by Claude Opus 5.5
parent 2362fc7741
commit 92e7a289be
41 changed files with 1361 additions and 34 deletions
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { drawBriscola, cardAt, HAND } from '../src/engine/briscola-render.js';
import { newBriscola } from '../src/engine/briscola.js';
import { story } from '../src/story/story.js';
import { simulate } from '../tools/calibrate_briscola.mjs';
function context() {
const labels = [], rects = [];
return { labels, rects, save() {}, restore() {}, translate() {}, scale() {}, rotate() {}, beginPath() {}, closePath() {}, moveTo() {}, lineTo() {}, fill() {}, strokeRect() {},
fillRect(...args) { rects.push(args); }, fillText(text) { labels.push(text); } };
}
test('renderer puro: durante la partita nasconde punteggi e carte avversarie', () => {
const c = newBriscola(91421), before = JSON.stringify(c), ctx = context();
drawBriscola(ctx, c, story.closeups.briscola); assert.equal(JSON.stringify(c), before);
assert.ok(ctx.labels.includes('Mazzo: 34'));
assert.ok(!ctx.labels.some(t => /punti|Vince|Totale/.test(t)));
c.hands.cpu = c.hands.cpu.map(a => new Proxy(a, { get() { throw new Error('Il disegno legge una carta coperta'); } }));
assert.doesNotThrow(() => drawBriscola(context(), c, story.closeups.briscola));
});
test('bersagli delle carte: bordi, spazi vuoti e input fuori turno', () => {
const c = newBriscola(4); c.current = 'player';
for (let i = 0; i < 3; i++) {
const x = HAND.x + i * HAND.gap;
assert.equal(cardAt(c, [x, HAND.y]), i);
assert.equal(cardAt(c, [x + HAND.w - 1, HAND.y + HAND.h - 1]), i);
assert.equal(cardAt(c, [x + HAND.w + 1, HAND.y + 20]), -1);
}
c.hands.player.pop(); assert.equal(cardAt(c, [HAND.x + 2 * HAND.gap + 10, HAND.y + 10]), -1);
c.current = 'cpu'; assert.equal(cardAt(c, [HAND.x + 10, HAND.y + 10]), -1);
});
test('conteggio e risultato mostrano ogni carta conquistata e i totali solo alla fine', () => {
const c = simulate(91421, 'male'), ctx = context(), before = JSON.stringify(c);
drawBriscola(ctx, c, story.closeups.briscola);
assert.equal(ctx.labels.filter(t => /^\+\d/.test(t)).length, 40);
assert.ok(ctx.labels.includes(`${c.result.player} punti`)); assert.ok(ctx.labels.includes(`${c.result.cpu} punti`));
assert.equal(JSON.stringify(c), before);
c.step = 'count'; c.count = 3;
const partial = context(); drawBriscola(partial, c, story.closeups.briscola);
assert.equal(partial.labels.filter(t => /^\+\d/.test(t)).length, 3);
});
+174
View File
@@ -0,0 +1,174 @@
// Partite complete con eventi reali. Si accelerano solo le attese, mai le mosse di Andrea.
// npm run browser:briscola — /tmp/regolo-shots/briscola-*.png
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } 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 clickCard(page, mode, index) {
const r = await page.locator('#scene').boundingBox();
const x = r.x + (176 + index * 102 + 42) / 640 * r.width, y = r.y + 290 / 360 * r.height;
if (mode === 'touch') await page.touchscreen.tap(x, y);
else if (mode === 'keyboard') {
await page.keyboard.press(String(index + 1));
assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowLeft');
assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('Enter');
} else await page.mouse.click(x, y);
}
async function advance(page) {
return page.evaluate(async () => {
const { canPlay } = await import('/src/engine/briscola.js');
for (let i = 0; i < 6 && !canPlay(game.closeup) && !['count', 'result'].includes(game.closeup.step); i++) game.update(1.3);
return game.closeup.step;
});
}
async function artwork(page) {
const pictures = await page.evaluate(async () => {
await document.fonts.ready;
const { DECK_CARDS, newBriscola } = await import('/src/engine/briscola.js');
const { drawCard, drawBriscola } = await import('/src/engine/briscola-render.js');
const { story } = await import('/src/story/story.js');
const canvas = document.createElement('canvas'), ctx = canvas.getContext('2d'), pictures = {};
for (const [deck, cards] of Object.entries(DECK_CARDS)) {
canvas.width = 900; canvas.height = 560;
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = '#234c40'; ctx.fillRect(0, 0, 900, 560);
cards.forEach((card, i) => drawCard(ctx, card, 8 + (i % 10) * 89, 8 + Math.floor(i / 10) * 136));
pictures[`${deck}-mazzo`] = canvas.toDataURL().split(',')[1];
canvas.width = 640; canvas.height = 360;
const c = newBriscola(41, { deck }), before = JSON.stringify(c);
drawBriscola(ctx, c, story.closeups.briscola);
if (before !== JSON.stringify(c)) throw new Error('Il renderer modifica lo stato');
pictures[`${deck}-tavolo`] = canvas.toDataURL().split(',')[1];
}
return pictures;
});
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
}
try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
]);
scenarios.push({ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } });
for (const { name, mode, viewport } of scenarios) {
const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
page.on('pageerror', e => errors.push(e.message));
page.on('console', e => { if (e.type() === 'error') errors.push(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);
});
await page.waitForTimeout(1500);
if (name === 'desktop-mouse') {
await page.screenshot({ path: `${out}/briscola-oratorio.png` });
await artwork(page);
}
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
game.act('talk', 'giocatore_carte'); settle(game);
});
await page.locator('#choices li', { hasText: 'Una briscola?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
// Sul telefono le napoletane, altrove le toscane: tutti e due i mazzi passano dal dialogo.
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: 'A fassi male.' }).click();
assert.equal(await page.evaluate(async () => { (await import('/tests/helpers.mjs')).settle(game); return game.closeup.deck; }), deck);
await page.evaluate(async deck => {
const { newBriscola } = await import('/src/engine/briscola.js');
game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) };
}, deck);
await page.waitForSelector('#briscola-controls:not([hidden])');
assert.ok(await page.isHidden('#bocce-controls'));
const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
for (const id of ['#corner', '#sound']) {
const r = await page.locator(id).boundingBox(); assert.ok(r.y + r.height <= scene.y, `${id} fuori dal tavolo`);
}
assert.ok(scene.width * 84 / 640 >= 44, 'carte toccabili sul telefono');
await advance(page);
await page.screenshot({ path: `${out}/briscola-${name}-inizio.png` });
if (name === 'desktop-mouse') {
// Il tempo premuto non carica nulla; uscire dal bersaglio o annullare non gioca.
const r = await page.locator('#scene').boundingBox(), x = r.x + 218 / 640 * r.width, y = r.y + 290 / 360 * r.height;
const before = await page.evaluate(() => game.closeup.hands.player.length);
await page.mouse.move(x, y); await page.mouse.down(); await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before);
await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up();
assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before);
// Anche la selezione da tastiera sui pulsanti segue il focus.
await page.locator('#briscola-card-0').focus(); await page.keyboard.press('ArrowRight');
assert.equal(await page.evaluate(() => game.closeup.selected), 1);
assert.equal(await page.evaluate(() => document.activeElement.id), 'briscola-card-1');
await page.locator('#briscola-card-1').evaluate(b => b.blur());
// Muto e pausa fermano anche le attese della partita.
await page.keyboard.press('m'); assert.equal(await page.evaluate(() => music.muted), true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0);
await page.keyboard.press('m');
await page.click('#pause-button');
const paused = await page.evaluate(() => JSON.stringify(game.closeup)); await page.waitForTimeout(250);
assert.equal(await page.evaluate(() => JSON.stringify(game.closeup)), paused);
await page.keyboard.press('Escape');
}
let played = 0, capturedTrick = false, capturedEnd = false;
while (!['count', 'result'].includes(await advance(page))) {
const index = await page.evaluate(async () => {
const { observation, attentive } = await import('/src/engine/briscola.js');
return attentive(observation(game.closeup, 'player'));
});
const before = await page.evaluate(() => game.closeup.hands.player.length);
await clickCard(page, mode, index); played++;
assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before - 1, 'un gesto gioca esattamente una carta');
if (!capturedTrick) {
await page.evaluate(() => { if (game.closeup.step === 'play') game.update(0.75); });
assert.equal(await page.evaluate(() => game.closeup.step), 'trick');
assert.equal(await page.evaluate(() => game.closeup.table.length), 2);
await page.waitForTimeout(200);
await page.screenshot({ path: `${out}/briscola-${name}-presa.png` }); capturedTrick = true;
}
if (!capturedEnd && await page.evaluate(() => !game.closeup.stock.length)) {
await page.screenshot({ path: `${out}/briscola-${name}-fine-mazzo.png` }); capturedEnd = true;
}
assert.ok(played <= 20);
}
assert.equal(played, 20);
await page.evaluate(() => game.update(3));
await page.screenshot({ path: `${out}/briscola-${name}-conteggio.png` });
await page.evaluate(() => game.update(15));
await page.waitForFunction(() => game.closeup.step === 'result');
await page.waitForTimeout(50);
const result = await page.evaluate(() => game.closeup.result);
assert.equal(result.player + result.cpu, 120);
await page.screenshot({ path: `${out}/briscola-${name}-risultato.png` });
if (mode === 'keyboard') await page.keyboard.press('Enter');
else if (mode === 'touch') await page.tap('#closeup-close');
else await page.click('#closeup-close');
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
assert.equal(await page.evaluate(() => !!game.state.flags.briscola_vinta), result.winner === 'player');
assert.ok(await page.evaluate(() => game.state.flags.briscola_giocata));
// Rivincita e abbandono con Esc: nessun flag di vittoria regalato.
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
});
await page.locator('#choices li', { hasText: 'La rivincita?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Alla bona.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.keyboard.press('Escape'); assert.equal(await page.evaluate(() => game.closeup), null);
console.log(`${name}: 20 carte giocate, prese, conteggio, risultato e rivincita OK`);
await page.close();
}
assert.deepEqual(errors, []); console.log(`Schermate: ${out}/briscola-*.png`);
} finally { await browser?.close(); server.kill(); }
+183
View File
@@ -0,0 +1,183 @@
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)));
});
+1 -1
View File
@@ -102,7 +102,7 @@ export async function checkMultimedia(browser, port, out, errors) {
const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {};
for (const kind of ['page', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
for (const kind of ['page', 'card-slide', 'card-tap', 'card-shuffle', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
const ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true });
+11
View File
@@ -101,3 +101,14 @@ test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } });
assert.doesNotThrow(() => { s.unlock(); s.configure({ active: true }); s.update('paese', 'sera', 1); s.play('page'); });
});
test('carte: fruscio, battuta e mescolata seguono volume effetti, muto e pausa', () => {
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 }); s.unlock();
for (const kind of ['card-slide', 'card-tap', 'card-shuffle']) {
s.configure({ active: true, muted: false, volume: 0.27 }); s.play(kind);
assert.ok(s.nodes.size > 0, kind); assert.equal(s.master.gain.value, 0.27);
s.configure({ muted: true }); assert.equal(s.nodes.size, 0); s.play(kind); assert.equal(s.nodes.size, 0);
s.configure({ muted: false, volume: 0 }); s.play(kind); assert.equal(s.nodes.size, 0);
s.configure({ active: false, volume: 0.5 }); s.play(kind); assert.equal(s.nodes.size, 0);
}
});
+1 -1
View File
@@ -13,7 +13,7 @@ const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'd
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
for (const [id, c] of Object.entries(story.closeups ?? {})) {
for (const k of ['comment', 'closing', 'complete']) yield [`${id}.${k}`, c[k] ?? []];
for (const k of ['comment', 'closing', 'complete', 'win', 'lose', 'draw', 'quit']) yield [`${id}.${k}`, c[k] ?? []];
for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
}
for (const [id, scene] of Object.entries(story.scenes)) {