import test from 'node:test'; import assert from 'node:assert/strict'; import * as m from '../src/engine/micro.js'; import { drawMicro } from '../src/engine/micro-render.js'; import { Game, MINIGAMES } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; // Fa andare la spinta in corso (e quella del Chiaru, se tocca a lui) finché non si torna a mirare o è finita. function run(c, limit = 20000) { for (let i = 0; i < limit && !(m.canThrow(c) || c.step === 'result'); i++) m.update(c, 1 / 60); } // Andrea «prudente»: mira a un punto della linea di mezzo `ahead` pixel più avanti, con la forza giusta per arrivarci. function flick(c, ahead = 60) { const car = c.cars.player, i = (car.idx + Math.round(ahead / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; c.angle = Math.atan2(y - car.y, x - car.x); c.power = m.powerFor(Math.hypot(x - car.x, y - car.y)); assert.ok(m.shoot(c)); } // Andrea alla bona: la spinta più lunga che la guida dà in pista, puntando avanti lungo la linea di mezzo. function guided(c) { for (let ahead = 190; ahead >= 20; ahead -= 10) { flickAim(c, ahead); if (!m.guide(c).out) break; } assert.ok(m.shoot(c)); } function flickAim(c, ahead) { const car = c.cars.player, i = (car.idx + Math.round(ahead / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; c.angle = Math.atan2(y - car.y, x - car.x); c.power = m.powerFor(Math.hypot(x - car.x, y - car.y)); } test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prima del traguardo', () => { const t = m.TRACK; assert.ok(t.length > 1500 && t.length < 2500); for (let i = 0; i < t.n; i++) { const [x, y] = t.pts[i]; assert.ok(x - m.HALF > 0 && x + m.HALF < 640 && y - m.HALF > 22 && y + m.HALF < 360, `la pista sta nel quadro (${x}, ${y})`); } // I tratti lontani lungo la pista non si toccano: fra i due gessi c'è sempre asfalto. for (let i = 0; i < t.n; i += 3) for (let j = i + 3; j < t.n; j += 3) { const along = Math.min(j - i, t.n - j + i) * t.step; if (along < 120) continue; const d = Math.hypot(t.pts[i][0] - t.pts[j][0], t.pts[i][1] - t.pts[j][1]); assert.ok(d > 2 * m.HALF + 2 * m.CAR.r, `tratti troppo vicini: ${i} e ${j} (${d.toFixed(1)} px)`); } const c = m.newMicro(1); for (const car of Object.values(c.cars)) { assert.ok(!m.isOut(m.project(t, car.x, car.y).d)); assert.ok(car.progress < 0 && car.progress > -30, 'prima del traguardo'); } }); test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', () => { const a = m.newMicro(4, { level: 'male' }), b = m.newMicro(4, { level: 'male' }); for (const c of [a, b]) { flick(c); run(c); } assert.deepEqual(a.cars, b.cars); const c = m.newMicro(2, { level: 'bona' }), start = { ...c.cars.player }; c.power = 0.6; const g = m.guide(c); assert.ok(m.shoot(c)); run(c); // Il Chiaru ha già spinto: la macchinina di Andrea sta dove diceva la guida (se lui non l'ha urtata). assert.ok(Math.hypot(c.cars.player.x - g.x, c.cars.player.y - g.y) < 3, 'arriva dove diceva la guida'); assert.ok(Math.abs(Math.hypot(g.x - start.x, g.y - start.y) - m.reach(m.speedFor(0.6))) < 0.01); }); test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva', () => { const c = m.newMicro(3, { level: 'bona' }), start = { x: c.cars.player.x, y: c.cars.player.y }; c.angle = -Math.PI / 2; c.power = 1; // dritto verso l'erba in alto assert.equal(m.guide(c).out, true); assert.ok(m.shoot(c)); for (let i = 0; i < 2000 && c.step !== 'settle'; i++) m.update(c, 1 / 60); assert.equal(c.cars.player.fault, 'fuori'); for (let i = 0; i < 200 && c.current === 'player'; i++) m.update(c, 1 / 60); assert.deepEqual([c.cars.player.x, c.cars.player.y], [start.x, start.y]); assert.match(c.line, /^Fuori! Andrea torna/); assert.equal(c.returns.length, 1); // Nel tornante non si salta dall'altra parte: si esce lo stesso. assert.ok(c.events.some(e => e.kind === 'micro-fuori')); }); test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si torna indietro', () => { // Tagliè: dal tratto di mezzo della esse, dritti in su attraverso l'erba verso il tratto di sopra. const t = m.newMicro(1, { level: 'bona' }), [sx, sy] = t.track.pts[Math.round(t.track.n * 0.48)]; const p = m.project(t.track, sx, sy); Object.assign(t.cars.player, { x: sx, y: sy, idx: p.i, s: p.s, progress: p.s }); t.angle = -Math.PI / 2; t.power = 0.6; assert.ok(m.shoot(t)); for (let i = 0; i < 2000 && t.step !== 'settle'; i++) m.update(t, 1 / 60); for (let i = 0; i < 200 && t.current === 'player'; i++) m.update(t, 1 / 60); assert.match(t.line, /^Tagliè! Andrea torna dov'era\./); assert.deepEqual([t.cars.player.x, t.cars.player.y], [sx, sy]); // Cappottè: Andrea a tutta forza contro il Chiaru fermo davanti. const k = m.newMicro(2, { level: 'bona' }), me = k.cars.player, him = k.cars.cpu, before = { x: him.x, y: him.y }; k.angle = Math.atan2(him.y - me.y, him.x - me.x); k.power = 1; assert.ok(m.shoot(k)); for (let i = 0; i < 2000 && k.step !== 'settle'; i++) m.update(k, 1 / 60); assert.equal(him.fault, 'cappotte'); for (let i = 0; i < 200 && k.current === 'player'; i++) m.update(k, 1 / 60); assert.match(k.line, /Cappottè! Il Chiaru torna dov'era\./); // Torna al suo posto; se lì c'è ora Andrea, si scosta appena quanto basta. assert.ok(Math.hypot(him.x - before.x, him.y - before.y) <= 2 * m.CAR.r + 1e-9); // Accompagnè: tenuto premuto ben oltre la carica piena. const a = m.newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y }; assert.ok(m.press(a)); for (let i = 0; i < 120; i++) m.update(a, 1 / 60); assert.ok(m.overHeld(a)); assert.ok(m.release(a)); for (let i = 0; i < 2000 && a.current === 'player'; i++) m.update(a, 1 / 60); assert.match(a.line, /^Accompagnè! Andrea torna dov'era\./); assert.deepEqual([a.cars.player.x, a.cars.player.y], [start.x, start.y]); // Lasciando in tempo, la spinta piena vale. const ok = m.newMicro(3); m.press(ok); for (let i = 0; i < Math.round((m.SWING + 0.2) * 60); i++) m.update(ok, 1 / 60); m.release(ok); assert.equal(ok.cars.player.fault, null); }); test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chiaru alla bona perde contro chi usa la guida', () => { const wins = { player: 0, cpu: 0, draw: 0 }; for (const seed of [1, 2, 3]) { const c = m.newMicro(seed, { level: 'bona' }); for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) guided(c); run(c); } assert.equal(c.step, 'result'); assert.equal(c.result.flicks.player, c.result.flicks.cpu); assert.ok(m.finished(c, c.cars.player) || m.finished(c, c.cars.cpu)); wins[c.result.winner]++; } assert.ok(wins.player >= 2, `alla bona Andrea, con la guida, deve vincere quasi sempre (${JSON.stringify(wins)})`); // A fassi male il Chiaru fa il giro con meno spinte che alla bona. const flicks = level => { const c = m.newMicro(7, { level }); for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) { c.angle = c.cars.player.heading; c.power = 0; m.shoot(c); } run(c); } return c.flicks.cpu; }; assert.ok(flicks('male') < flicks('bona')); }); test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; la mira si gira con le frecce', () => { const c = m.newMicro(5); assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.ok(m.release(c, 1) === true && c.step === 'aim', 'il tocco breve non spinge'); assert.ok(m.press(c, 1)); for (let i = 0; i < 60; i++) m.update(c, 1 / 60); assert.ok(c.charging); const before = c.cars.player.x; assert.ok(m.release(c, 1)); assert.equal(c.step, 'rolling'); m.update(c, 0.2); assert.notEqual(c.cars.player.x, before); assert.equal(m.press(c, 2), false, 'mentre corre non si spinge'); const d = m.newMicro(5), a0 = d.angle; m.key(d, 'ArrowRight', true); m.update(d, 0.5); m.key(d, 'ArrowRight', false); assert.ok(d.angle > a0); assert.ok(m.aimScreen(d, [320, 200])); assert.equal(m.aimScreen(d, [320, 10]), false, 'il tabellone non si mira'); assert.ok(m.setPower(d, 0.25)); assert.equal(d.power, 0.25); }); test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => { const c = m.newMicro(6, { level: 'male' }); flick(c); let worst = 0; for (let i = 0; i < 600 && !m.canThrow(c); i++) { const t = performance.now(); m.update(c, 1 / 60); worst = Math.max(worst, performance.now() - t); } assert.ok(worst < 40, `fotogramma da ${worst.toFixed(1)} ms`); }); test('il disegno: gesso, macchinine, guida e risultato; lo stato non cambia', () => { const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); const c = m.newMicro(8, { level: 'bona' }), bg = {}, cars = { [story.closeups.micro.cars.player]: { car: 'player' } }; const draw = () => { const before = structuredClone(c); drawMicro(ctx, c, story.closeups.micro, bg, src => cars[src]); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); assert.ok(calls.filter(([op, img]) => op === 'drawImage' && img?.car === 'player').length >= 2, 'la macchinina e il suo fantasma'); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'Chiaru')); c.step = 'result'; c.result = { winner: 'cpu', flicks: { player: 12, cpu: 12 } }; calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO IL CHIARU')); }); test('il Chiaru al parcheggio di Cardetole: si chiacchiera, si sceglie il livello e si corre', () => { assert.ok(MINIGAMES.micro); const g = new Game(story); g.go('cardetole'); settle(g); // Il clic su tutto il Chiaru (testa, pancia, gambe) è suo, anche dove sta sopra al parcheggio. for (const y of [222, 260, 300, 314]) assert.equal(g.hotspotAt([520, y])?.id, 'chiaru', `clic a y=${y}`); g.act('talk', 'chiaru'); 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 corre?')); settle(g); g.choose(g.options().findIndex(o => o.text === 'No, aspetta.')); settle(g); assert.equal(g.closeup, null); g.choose(g.options().findIndex(o => o.text === 'Si corre?')); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); assert.equal(g.closeup?.id, 'micro'); assert.equal(g.closeup.level, 'male'); g.closeup.step = 'result'; g.closeup.result = { winner: 'player', flicks: { player: 11, cpu: 11 } }; g.closeupClose(); settle(g); assert.ok(g.state.flags.micro_vinto); });