import test from 'node:test'; import assert from 'node:assert/strict'; import * as r from '../src/engine/rinquarto.js'; import { FIXED_STEP, LONG_PRESS } from '../src/engine/biliardo.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'; // Tira (Andrea col tiro dato, o Trappizzio Berti da sé) e aspetta l'esito. function roll(c) { for (let i = 0; i < 20000 && c.step !== 'review' && c.step !== 'result'; i++) r.update(c, 1 / 60); assert.ok(['review', 'result'].includes(c.step), 'il tiro si deve fermare'); } function andrea(c, angle = c.solution.angle) { c.angle = angle; assert.ok(r.shoot(c)); roll(c); return c.history.at(-1); } const samePlace = (a, b) => JSON.stringify(Object.values(a.balls).map(x => [x.role, x.x, x.y])) === JSON.stringify(Object.values(b.balls).map(x => [x.role, x.x, x.y])); test('stesso seme e livello: stesse posizioni e soluzioni', () => { const a = r.newRinquarto(11, { level: 'seria' }), b = r.newRinquarto(11, { level: 'seria' }); assert.ok(samePlace(a.start, b.start)); assert.deepEqual(a.solution, b.solution); assert.notDeepEqual(r.newRinquarto(12, { level: 'seria' }).solution, a.solution); }); test('ogni posizione: diretta coperta, una finestra buona larga quanto chiede il livello, e la soluzione fa punto davvero', () => { for (const level of ['bona', 'seria', 'male']) { const c = r.newRinquarto(3, { level }), widths = []; for (let p = 1; p <= 3; p++) { assert.equal(c.position, p); assert.ok(r.directCovered(c), `${level} ${p}: diretta coperta`); const shot = r.tryShot(c, c.solution.angle); assert.ok(shot.point, `${level} ${p}: la soluzione fa punto`); assert.ok(shot.cushions >= c.skill.cushions); if (level === 'bona') assert.equal(c.solution.cushions, 1, 'alla bona c\'è sempre un rinquarto a una sponda'); widths.push(c.solution.width); andrea(c); r.advance(c); roll(c); r.advance(c); } assert.ok(widths.every(w => w >= c.skill.width[1]), `${level}: finestre ${widths}`); } }); test('la diretta coperta guarda il primo impatto, non solo la vicinanza alla linea (rilievo 8 di Codex)', () => { const at = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 }); const free = { balls: { player: at('player', 50, 50), cpu: at('cpu', 100, 50), blocker: at('blocker', 99, 56.05) }, pins: [] }; assert.equal(r.directCovered(null, free), false, 'il bersaglio si tocca prima'); const covered = { balls: { player: at('player', 50, 50), cpu: at('cpu', 100, 50), blocker: at('blocker', 75, 52) }, pins: [] }; assert.equal(r.directCovered(null, covered), true); }); test('la diretta tocca il disturbo; meno sponde del richiesto non valgono', () => { const c = r.newRinquarto(5, { level: 'seria' }); const direct = r.tryShot(c, Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x)); assert.equal(direct.point, false); assert.equal(direct.reason, 'disturbo toccato'); const bona = r.newRinquarto(5, { level: 'bona' }), one = r.tryShot(bona, bona.solution.angle); assert.ok(one.point); assert.equal(r.tryShot({ ...bona, skill: r.LEVELS.male }, bona.solution.angle).point, one.cushions >= 3); }); test('a turno: prima Andrea, poi il tavolo torna com\'era e tira Trappizzio Berti; l\'esito resta finché non si va avanti', () => { const c = r.newRinquarto(8, { level: 'bona' }); assert.equal(r.shooter(c), 'andrea'); assert.ok(r.canThrow(c)); assert.equal(r.button(c), 'Tira'); const mine = andrea(c); assert.equal(c.step, 'review'); assert.equal(mine.who, 'andrea'); assert.ok(mine.point); assert.equal(c.score.andrea, 1); assert.ok(c.trail.length > 5, 'la traiettoria resta'); assert.equal(mine.marks.length, mine.cushions, 'le sponde hanno il loro punto'); assert.ok(mine.hit); for (let i = 0; i < 300; i++) r.update(c, 1 / 60); assert.equal(c.step, 'review', 'non va avanti da sé'); assert.equal(r.button(c), 'Prossima'); assert.ok(r.press(c), 'un tocco va avanti'); assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think'); assert.ok(samePlace(c, c.start), 'il tavolo è tornato com\'era'); assert.equal(r.canThrow(c), false, 'mentre tira lui i comandi sono fermi'); assert.equal(r.shoot(c), false); assert.match(r.status(c), /Tira Trappizzio Berti/); roll(c); assert.equal(c.history.at(-1).who, 'bertovo'); assert.equal(c.score.bertovo, Number(c.history.at(-1).point)); r.advance(c); assert.equal(c.position, 2); assert.equal(r.shooter(c), 'andrea'); assert.equal(c.step, 'aim'); }); test('alla fine vince chi ha più punti; può finire pari', () => { const c = r.newRinquarto(8, { level: 'bona' }); for (let p = 0; p < c.skill.positions; p++) { andrea(c, c.solution.angle + Math.PI); r.advance(c); roll(c); r.advance(c); } assert.equal(c.step, 'result'); assert.equal(c.score.andrea, 0); assert.equal(c.result.winner, c.score.bertovo > 0 ? 'cpu' : 'draw'); assert.match(r.status(c), c.score.bertovo > 0 ? /Ha vinto Trappizzio Berti/ : /Pari/); for (const [a, b, w] of [[3, 2, 'player'], [2, 3, 'cpu'], [2, 2, 'draw']]) { const d = { ...c, step: 'review', last: true, round: 1, position: c.skill.positions, score: { andrea: a, bertovo: b } }; r.advance(d); assert.equal(d.result.winner, w); } }); test('l\'allenamento: tira prima Trappizzio Berti, sempre giusto, poi Andrea; niente punti, finisce pari', () => { const c = r.newRinquarto(2, { level: 'allenamento' }); assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think'); for (let p = 0; p < c.skill.positions; p++) { roll(c); assert.ok(c.history.at(-1).point, 'Trappizzio Berti fa vedere il rinquarto'); r.advance(c); assert.equal(r.shooter(c), 'andrea'); andrea(c); r.advance(c); } assert.deepEqual(c.score, { andrea: 0, bertovo: 0 }); assert.equal(c.result.winner, 'draw'); assert.ok(c.result.training); }); test('Trappizzio Berti è più preciso salendo di livello', () => { const spread = level => { const c = r.newRinquarto(4, { level: 'bona' }); c.skill = r.LEVELS[level]; const errors = Array.from({ length: 400 }, () => r.bertovoPlan(c).angle - c.solution.angle); return Math.sqrt(errors.reduce((a, e) => a + e * e, 0) / errors.length); }; const [bona, seria, male] = ['bona', 'seria', 'male'].map(spread); assert.ok(bona > seria && seria > male, `${bona} ${seria} ${male}`); assert.equal(spread('allenamento'), 0); }); test('i comandi: un tocco mira e basta, tenendo premuto si tira; il secondo dito non tira; le frecce girano finché tenute', () => { const c = r.newRinquarto(6, { level: 'bona' }); r.aimAt(c, c.balls.cpu.x, c.balls.cpu.y + 20); assert.ok(r.press(c, 1)); r.update(c, 0.15); r.release(c, 1); assert.equal(c.step, 'aim', 'un tocco normale non brucia la posizione'); assert.ok(r.press(c, 1)); r.update(c, 0.1); assert.equal(r.release(c, 2), false, 'il secondo dito non lascia il tiro del primo'); assert.equal(c.step, 'aim'); for (let t = 0; t < LONG_PRESS; t += 0.1) r.update(c, 0.1); assert.ok(c.charging, 'tenendo premuto la stecca si carica'); r.release(c, 1); assert.equal(c.step, 'rolling'); assert.equal(c.power, 1, 'forza sempre piena'); const d = r.newRinquarto(6, { level: 'bona' }), a0 = d.angle; assert.ok(r.key(d, 'ArrowRight', true)); r.update(d, 0.5); const a1 = d.angle; assert.ok(a1 > a0 + 0.2, 'tenendo la freccia gira, e accelera'); r.key(d, 'ArrowRight', false); r.update(d, 0.5); assert.equal(d.angle, a1, 'lasciandola si ferma'); r.key(d, 'ArrowLeft', true, true); r.update(d, 0.5); r.key(d, 'ArrowLeft', false); assert.ok(a1 - d.angle < 0.05, 'con Maiusc i passi sono fini'); assert.equal(r.setPower(d, 0.2), false); assert.equal(d.power, 1); }); test('dialogo di Trappizzio Berti: si chiacchiera, la sfida o l\'allenamento; lui sta al tavolo di mezzo del Circolo', () => { assert.ok(MINIGAMES.rinquarto); assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json'); const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id); assert.deepEqual(where, ['circolo_4_maggio']); const g = new Game(story); g.go('circolo_4_maggio'); settle(g); assert.ok(g.people().some(p => p.who === 'bertovo')); g.act('talk', 'bertovo'); settle(g); assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito'); g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?')); assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?'))); assert.equal(g.options().length, 4); g.choose(g.options().findIndex(o => o.text.startsWith('No'))); settle(g); assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare'); g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?')); settle(g); g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g); assert.equal(g.closeup?.id, 'rinquarto'); assert.equal(g.closeup.level, 'seria'); g.closeup.step = 'result'; g.closeup.result = { winner: 'player', points: { andrea: 4, bertovo: 2 } }; g.closeupClose(); settle(g); assert.ok(g.state.flags.rinquarti_vinti); const h = new Game(story); h.go('circolo_4_maggio'); settle(h); h.act('talk', 'bertovo'); settle(h); h.choose(h.options().findIndex(o => o.text === 'Dai, fammi vedere un rinquarto.')); settle(h); assert.equal(h.closeup?.level, 'allenamento'); }); test('il disegno: traiettoria con le sponde numerate, riepilogo, e lo stato non cambia', () => { 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 { const c = r.newRinquarto(8, { level: 'bona' }), bg = {}; const draw = () => { const before = structuredClone(c); drawRinquarto(ctx, c, story.closeups.rinquarto, bg); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); andrea(c); calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === '1'), 'la prima sponda è numerata'); assert.ok(calls.some(([op, t]) => op === 'fillText' && /punto!/.test(t))); c.step = 'result'; c.result = { winner: 'cpu', points: { andrea: 1, bertovo: 3 } }; calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO TRAPPIZZIO BERTI')); } finally { if (previous === undefined) delete globalThis.document; else globalThis.document = previous; } });