import test from 'node:test'; import assert from 'node:assert/strict'; import * as r from '../src/engine/rinquarto.js'; import { R, PIN, LEN, WID, predict, FIXED_STEP, VIEW, simulate, reach, speedFor } from '../src/engine/biliardo.js'; import { pinFall } from '../src/engine/biliardo-render.js'; import { drawRinquarto } from '../src/engine/rinquarto-render.js'; import { Game, MINIGAMES } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; function roll(c, frames = [1 / 60]) { for (let i = 0; i < 10000 && c.step === 'rolling'; i++) r.update(c, frames[i % frames.length]); assert.ok(['settle', 'result'].includes(c.step), 'il tiro si deve fermare'); } function next(c) { for (let i = 0; i < 20 && c.step === 'settle'; i++) r.update(c, 0.25); assert.ok(['aim', 'result'].includes(c.step)); } function play(c, shot = c.solution) { Object.assign(c, shot); assert.ok(r.shoot(c)); assert.equal(r.shoot(c), false, 'un solo tiro per posizione'); roll(c); } test('stesso seme e livello riproducono tutte le sei posizioni e le soluzioni', () => { for (const level of Object.keys(r.LEVELS)) { const a = r.newRinquarto(123, { level }), b = r.newRinquarto(123, { level }); for (let i = 1; i <= a.skill.positions; i++) { assert.equal(a.position, i); assert.deepEqual(a, b); assert.match(r.status(a), /Bertovo: «Oh tirami questo rinquarto!\?»/); play(a); play(b); next(a); next(b); } assert.deepEqual(a, b); } assert.notDeepEqual(r.newRinquarto(12).balls, r.newRinquarto(13).balls); }); test('ogni posizione è coperta geometricamente e il solver trova un tiro che fa punto davvero', () => { for (const level of Object.keys(r.LEVELS)) for (const seed of [0, 1, 42, 0xffffffff]) { const c = r.newRinquarto(seed, { level }); for (let i = 0; i < c.skill.positions; i++) { assert.ok(r.directCovered(c)); // Verifica indipendente: il disturbo sul segmento sta fra i due centri. const a = c.balls.player, b = c.balls.cpu, d = c.balls.blocker; const distance = Math.hypot(b.x - a.x, b.y - a.y); const area = Math.abs((b.x - a.x) * (d.y - a.y) - (b.y - a.y) * (d.x - a.x)); assert.ok(area / distance < 2 * R); assert.ok(Math.hypot(d.x - a.x, d.y - a.y) + Math.hypot(b.x - d.x, b.y - d.y) <= distance + 1e-6); for (const ball of Object.values(c.balls)) { assert.ok(ball.x >= R && ball.x <= LEN - R && ball.y >= R && ball.y <= WID - R); for (const other of Object.values(c.balls)) if (other !== ball) assert.ok(Math.hypot(ball.x - other.x, ball.y - other.y) > 2 * R); } const before = structuredClone(c), solution = r.solve(c); assert.ok(solution); assert.deepEqual(c, before, 'la ricerca non cambia lo stato né il seme'); const expected = r.tryShot(c, solution.angle, solution.power); assert.ok(expected.point && expected.cushions >= c.skill.cushions); assert.equal(r.tryShot(c, c.angle, 1).point, false, 'il diretto è coperto'); const points = c.points; play(c, solution); assert.deepEqual(c.attempt, expected); assert.equal(c.points, points + 1); next(c); } } }); const clear = level => { const c = r.newRinquarto(42, { level }); c.balls = { player: { role: 'player', x: 80, y: 50, vx: 0, vy: 0 }, cpu: { role: 'cpu', x: 80, y: 100, vx: 0, vy: 0 }, }; c.pins = []; return c; }; test('un diretto libero non vale; una sponda vale solo alla bona, senza recuperare dopo il primo contatto', () => { assert.deepEqual(Object.values(r.LEVELS).map(k => k.cushions), [1, 2, 3]); for (const level of Object.keys(r.LEVELS)) { const c = clear(level); assert.equal(r.directCovered(c), false); const direct = r.tryShot(c, Math.PI / 2, 1); assert.equal(direct.cushions, 0); assert.equal(direct.point, false); assert.equal(direct.reason, 'poche sponde'); const bank = r.tryShot(c, -Math.PI / 2, 0.7); assert.equal(bank.cushions, 1); assert.equal(bank.point, level === 'bona'); play(c, { angle: -Math.PI / 2, power: 0.7 }); assert.equal(c.points, Number(level === 'bona')); assert.deepEqual(c.attempt, bank); } }); test('biglie e birilli di disturbo prima del bersaglio annullano il tiro', () => { for (const kind of ['ball', 'pin']) { const c = clear('bona'); if (kind === 'ball') c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 }; else c.pins.push({ x: 80, y: 25, down: false }); const result = r.tryShot(c, -Math.PI / 2, 1); assert.equal(result.point, false); assert.equal(result.reason, 'disturbo toccato'); play(c, { angle: -Math.PI / 2, power: 1 }); assert.equal(c.points, 0); assert.equal(c.attempt.reason, result.reason); } }); test('un disturbo dopo il bersaglio non annulla un punto già fatto', () => { const c = clear('bona'); // Dopo la sponda alta la bianca colpisce la gialla, che abbatte il birillo. c.pins = [{ x: 80, y: 120, down: false }]; const events = []; simulate(c, 'player', -Math.PI / 2, 1, { onContact: e => events.push({ kind: e.kind, role: e.a.role }) }); assert.ok(events.findIndex(e => e.kind === 'pin') > events.findIndex(e => e.kind === 'ball')); play(c, { angle: -Math.PI / 2, power: 1 }); assert.ok(c.pins[0].down, 'il contatto successivo avviene davvero'); assert.equal(c.points, 1); assert.equal(c.attempt.reason, null); }); test('durante l’esito continua la caduta dei birilli, senza assegnare altri punti', () => { const c = clear('bona'); play(c, { angle: -Math.PI / 2, power: 0.7 }); const p = { down: true, visualDownAt: c.visual.time }; c.pins.push(p); const history = structuredClone(c.history), points = c.points; r.update(c, 0.12); assert.ok(Math.abs(pinFall(p, c.visual.time) - 0.5) < 1e-10); r.update(c, 0.12); assert.ok(Math.abs(pinFall(p, c.visual.time) - 1) < 1e-10); assert.equal(c.step, 'settle'); assert.equal(c.points, points); assert.deepEqual(c.history, history); }); test('un birillo colpito appena prima dell’arresto finisce di cadere anche nel riepilogo finale', () => { const c = clear('bona'); c.position = c.skill.positions; // Urto reale negli ultimi centimetri di un tiro debole: il riepilogo arriva durante la caduta. c.pins = [{ x: 80 + reach(speedFor(0)) + R + PIN - 0.01, y: 50, down: false }]; play(c, { angle: 0, power: 0 }); assert.equal(c.step, 'result'); const p = c.pins[0], before = structuredClone(c); assert.ok(p.down && pinFall(p, c.visual.time) < 1); r.update(c, 0.25); assert.equal(pinFall(p, c.visual.time), 1); before.visual.time = c.visual.time; assert.deepEqual(c, before, 'avanza solo l’animazione, senza nuovi tiri o punti'); for (const dt of [0, -1, NaN, Infinity]) r.update(c, dt); assert.deepEqual(c, before, 'tempi non validi non cambiano il riepilogo'); }); test('gloria alla soglia di LEVELS, sconfitta un punto sotto: la sfida dura sempre tutte le posizioni', () => { for (const level of Object.keys(r.LEVELS)) for (const win of [true, false]) { const c = r.newRinquarto(17, { level }), target = c.skill.glory - Number(!win); for (let i = 0; i < c.skill.positions; i++) { play(c, i < target ? c.solution : { angle: c.angle, power: 1 }); if (i < c.skill.positions - 1) assert.equal(c.result, undefined); next(c); } assert.equal(c.history.length, c.skill.positions); assert.equal(c.points, target); assert.equal(c.result.winner, win ? 'player' : 'cpu'); assert.match(r.status(c), win ? /Gloria!/ : /Sfida persa/); const final = structuredClone(c); r.update(c, 10); final.visual.time = c.visual.time; assert.deepEqual(c, final, 'il riepilogo rimane visibile'); assert.equal(r.press(c), false); } }); test('guida lunga fino alla seconda sponda, corta fino a 65 cm, assente a fassi male', () => { const c = clear('bona'); c.balls.cpu.x = 200; c.angle = -Math.PI / 2; c.power = 1; assert.deepEqual(r.guide(c), predict(c).points.slice(0, 3)); assert.equal(r.guide(c).length, 3); c.skill = r.LEVELS.seria; const short = r.guide(c); assert.ok(short.length <= 2); assert.ok(Math.hypot(short[1][0] - short[0][0], short[1][1] - short[0][1]) <= 65); c.skill = r.LEVELS.male; assert.deepEqual(r.guide(c), []); }); test('la guida si ferma al primo disturbo o all’arresto della biglia', () => { const c = clear('bona'); c.angle = -Math.PI / 2; c.power = 1; c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 }; let points = r.guide(c); assert.equal(points.length, 2); assert.ok(Math.abs(points[1][1] - (25 + 2 * R)) < 1e-7); delete c.balls.extra; c.angle = 0; c.power = 0; points = r.guide(c); assert.equal(points.length, 2); assert.ok(points[1][0] > 80 && points[1][0] < 100, 'la guida non arriva alla sponda se manca forza'); assert.equal(points[1][1], 50); }); test('comandi, suoni e fisica restano coerenti a frequenze diverse', () => { const states = []; for (const frames of [[1 / 30], [1 / 144], [0.008, 0.042, 0.09]]) { const c = r.newRinquarto(42); assert.ok(r.canAdjust(c)); r.press(c); r.update(c, 0.05); r.release(c); assert.equal(c.step, 'aim'); assert.equal(c.power, 0.75); r.press(c); r.update(c, 0.2); assert.equal(r.aimScreen(c, [VIEW.x + 100, VIEW.y + 100]), false); assert.equal(r.canAdjust(c), false); r.cancel(c); Object.assign(c, c.solution); r.shoot(c); roll(c, frames); assert.ok(c.events.some(e => e.kind === 'biliardo-cue')); assert.ok(c.events.some(e => e.kind === 'biliardo-rail')); assert.ok(c.events.some(e => e.kind === 'biliardo-hit')); states.push({ balls: c.balls, pins: c.pins.map(({ visualDownAt, ...p }) => p), points: c.points, attempt: c.attempt }); } for (const state of states.slice(1)) assert.deepEqual(state, states[0]); }); test('dialogo di Bertovo: si chiacchiera, lui lancia la sfida, poi tre livelli e No, primo piano e battute di uscita; lui sta al tavolo di mezzo del Circolo', () => { assert.ok(MINIGAMES.rinquarto); assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json'); // Solo al Circolo 4 Maggio, e parlandoci si apre il suo dialogo. const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id); assert.deepEqual(where, ['circolo_4_maggio']); const circolo = new Game(story); circolo.go('circolo_4_maggio'); settle(circolo); assert.ok(circolo.people().some(p => p.who === 'bertovo')); circolo.act('talk', 'bertovo'); settle(circolo); assert.ok(circolo.dialogue, 'parlandoci si sceglie il livello'); for (const [index, level] of ['bona', 'seria', 'male', null].entries()) { const g = new Game(story); settle(g); g.run({ dialogue: 'bertovo' }); // Prima si chiacchiera; la sfida la lancia lui, poi si sceglie il livello. assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito'); g.choose(g.options().findIndex(o => o.text === 'Dai, fammi vedere un rinquarto.')); assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?'))); assert.equal(g.options().length, 4); g.choose(index); settle(g); if (!level) { assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare'); continue; } assert.equal(g.closeup.id, 'rinquarto'); assert.equal(g.closeup.level, level); assert.ok(g.closeupPress()); g.update(0.2); assert.ok(g.closeupRelease()); assert.equal(g.closeup.step, 'rolling'); g.closeupClose(); assert.ok(settle(g).some(s => s.text === story.closeups.rinquarto.quit[0].say)); } }); test('il riepilogo resta aperto e uscendo parte la battuta di vittoria o sconfitta', () => { for (const win of [true, false]) { const g = new Game(story); settle(g); g.run({ closeup: 'rinquarto', level: 'bona' }); const c = g.closeup; for (let i = 0; i < c.skill.positions; i++) { play(c, win ? c.solution : { angle: c.angle, power: 1 }); next(c); } g.update(0.1); assert.equal(g.closeup, c); assert.equal(c.result.winner, win ? 'player' : 'cpu'); g.closeupClose(); assert.equal(g.closeup, null); const line = story.closeups.rinquarto[win ? 'win' : 'lose'][0]; assert.ok(settle(g).some(s => s.text === line.say && s.who === 'bertovo')); } }); test('disegno senza sprite, con disturbi e riepilogo, senza modificare lo stato', () => { const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); const previous = globalThis.document; globalThis.document = { createElement: () => ({ getContext: () => ctx }) }; try { for (const level of Object.keys(r.LEVELS)) { const c = r.newRinquarto(42, { level }), bg = {}; const draw = () => { const before = structuredClone(c); drawRinquarto(ctx, c, story.closeups.rinquarto, bg); assert.deepEqual(c, before); }; draw(); r.shoot(c); r.update(c, FIXED_STEP); draw(); roll(c); draw(); c.step = 'result'; c.result = { winner: 'cpu' }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); } } finally { if (previous === undefined) delete globalThis.document; else globalThis.document = previous; } });