import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { newBiliardo, update, shoot, predict, simulate, judge, LEN, WID, VIEW, FIXED_STEP } from '../src/engine/biliardo.js'; import { drawBiliardo, visualFrame, pinFall, tozziPose, tablePatches, TOZZI, SHOT_DELAY } from '../src/engine/biliardo-render.js'; import { story } from '../src/story/story.js'; test('il panno generato coincide con VIEW, senza buchi fra i ritagli', () => { const png = readFileSync(new URL('../' + story.closeups.biliardo.picture, import.meta.url)); const patches = tablePatches(); assert.equal(png.readUInt32BE(16), 1672); assert.equal(png.readUInt32BE(20), 941); for (const [i, expected] of [VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k].entries()) assert.ok(Math.abs(patches[12][i + 4] - expected) < 1e-10); let area = 0; for (const [i, p] of patches.entries()) { const [x, y, w, h, dx, dy, dw, dh] = p; assert.ok(w > 0 && h > 0 && dw > 0 && dh > 0); assert.ok(x >= 0 && y >= 0 && x + w <= 1672 && y + h <= 941); assert.ok(dx >= 0 && dy >= 0 && dx + dw <= 640 && dy + dh <= 360); if (i % 5 < 4) assert.equal(dx + dw, patches[i + 1][4]); if (i < 20) assert.equal(dy + dh, patches[i + 5][5]); area += dw * dh; } assert.ok(Math.abs(area - 640 * 360) < 1e-8); }); test('la steccata trattiene solo il disegno: velocità immediata e traiettoria invariata', () => { const c = newBiliardo(42), start = { ...c.balls.player }; const expected = simulate(c, 'player', c.angle, c.power); shoot(c); assert.ok(Math.hypot(c.balls.player.vx, c.balls.player.vy) > 0); update(c, SHOT_DELAY / 2); assert.notEqual(c.balls.player.x, start.x, 'la fisica è già partita'); assert.equal(visualFrame(c).balls.player.x, start.x, 'il disegno aspetta la punta della stecca'); update(c, SHOT_DELAY); const before = structuredClone(c); const shown = visualFrame(c); assert.ok(shown.balls.player.x > start.x); assert.ok(shown.balls.player.x < c.balls.player.x); assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato'); for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP); assert.deepEqual(judge(c.shot), expected); assert.ok(c.visual.frames.length < 110, 'la cronologia non cresce per tutta la partita'); while (c.step === 'settle') update(c, FIXED_STEP); assert.equal(visualFrame(c).balls, c.balls, 'al nuovo turno torna la posizione corrente'); }); test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => { const c = newBiliardo(9); Object.assign(c.balls.player, { x: LEN / 2 - 60, y: WID / 2 }); Object.assign(c.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 }); Object.assign(c.balls.pallino, { x: 20, y: 20 }); c.angle = 0; c.power = 0.5; const expected = simulate(c, 'player', c.angle, c.power), before = structuredClone(c); predict(c); assert.deepEqual(c, before, 'la previsione non fa partire animazioni'); shoot(c); for (let i = 0; i < 1000 && !c.pins.some(p => p.down); i++) update(c, FIXED_STEP); const p = c.pins.find(p => p.down); assert.ok(p && Number.isFinite(p.visualDownAt)); assert.equal(pinFall(p, p.visualDownAt), 0); assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1); assert.equal(pinFall(p, p.visualDownAt + 0.3), 1); assert.equal(visualFrame(c).pins.some(p => p.down), false, 'anche la caduta segue il tempo delle biglie visibili'); for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP); assert.deepEqual(judge(c.shot), expected); while (c.step === 'settle') update(c, FIXED_STEP); assert.ok(c.pins.every(p => !p.down && p.visualDownAt === undefined)); }); test('Tozzi pensa, esulta e sbaglia; tutte le pose restano fuori da tavolo e comandi', () => { const c = newBiliardo(3); assert.equal(tozziPose(c), 'stand'); c.current = 'cpu'; assert.equal(tozziPose(c), 'chalk'); for (const [last, pose] of [ [{ shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }, 'cheer'], [{ shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }, 'shrug'], [{ shooter: 'cpu', to: 'cpu', points: 0, foul: null, age: 0 }, 'shrug'], [{ shooter: 'player', to: 'cpu', points: 2, foul: 'own', age: 0 }, 'cheer'], ]) { c.last = last; assert.equal(tozziPose(c), pose); } c.last.age = 3; assert.equal(tozziPose(c), 'chalk'); const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url))); for (const pose of ['stand', 'chalk', 'cheer', 'shrug']) { const f = atlas.frames[pose], left = Math.round(TOZZI.x - f.ax * TOZZI.scale); assert.ok(left >= 0 && left + Math.round(f.w * TOZZI.scale) < VIEW.x - 16, pose); assert.ok(TOZZI.y - f.ay * TOZZI.scale > 72 && TOZZI.y < 360); } }); test('il disegno usa immagine e atlas, funziona durante il caricamento e non modifica la partita', () => { const calls = [], ctx = new Proxy({}, { get: (o, key) => key in o ? o[key] : (...args) => calls.push([key, ...args]) }); const previous = globalThis.document; globalThis.document = { createElement: () => ({ getContext: () => ctx }) }; const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url))); const bg = {}, sheet = { atlas, img: {} }, c = newBiliardo(42); try { const render = (background, sprite) => { const before = structuredClone(c); drawBiliardo(ctx, c, story.closeups.biliardo, background, sprite); assert.deepEqual(c, before); }; render(); // Il file può non essere ancora arrivato dalla rete. calls.length = 0; render(bg, sheet); assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === bg)); assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === sheet.img)); assert.ok(!calls.some(([op]) => op === 'createRadialGradient'), 'biglie senza gradienti lisci'); shoot(c); update(c, 0.1); render(bg, sheet); update(c, 0.07); render(bg, sheet); while (c.step === 'rolling') update(c, 1 / 60); render(bg, sheet); c.step = 'result'; c.result = { winner: 'cpu' }; render(bg, sheet); } finally { if (previous === undefined) delete globalThis.document; else globalThis.document = previous; } });