import test from 'node:test'; import assert from 'node:assert/strict'; import * as m from '../src/engine/flipper.js'; import { drawFlipper } from '../src/engine/flipper-render.js'; import { Game } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); }; const put = (c, ball) => Object.assign(c.ball, ball); // Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento. function autoplay(c, secs = 600) { for (let i = 0; i < secs * 60 && c.step === 'play'; i++) { if (m.waiting(c)) m.launch(c, 0.75); const b = c.ball; for (const side of ['left', 'right']) { const [px, py] = m.PIVOT[side], dir = side === 'left' ? 1 : -1, rel = (b ? b.x - px : 0) * dir; const near = b && !b.waiting && b.vy > 0 && b.y > py - 34 && b.y < py + 14 && rel > -6 && rel < 44; if (near !== c.flippers[side].held.includes('ai')) m.hold(c, side, 'ai', near); } m.update(c, 1 / 60); } } test('la tavola: tutto sta dentro il campo, le corsie coprono tutta la larghezza sotto l\'arco, i funghi sui dischi del disegno', () => { const c = m.newFlipper(1); for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`); // I tre dischi dipinti da Codex (misurati sull'immagine): i funghi ci stanno sopra, a meno di 3 pixel. for (const [b, [x, y]] of c.bumpers.map((b, i) => [b, [[281, 120], [360, 120], [320, 153]][i]])) assert.ok(Math.hypot(b.x - x, b.y - y) < 3); assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI); assert.equal(c.lanes[0].x0, m.FIELD.x0); assert.equal(c.lanes[3].x1, m.LANE.x0); for (let i = 1; i < 4; i++) assert.equal(c.lanes[i].x0, c.lanes[i - 1].x1, 'corsie contigue'); assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS); assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla'); }); test('il lancio: anche la molla più fiacca arriva in campo, quella forte fa il giro dell\'arco; la ricaduta si rilancia', () => { for (const level of ['bona', 'male']) for (const power of [0, 0.5, 1]) { const c = m.newFlipper(2, { level }); assert.ok(m.launch(c, power)); assert.ok(!m.waiting(c)); let top = 999, out = false; for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) out = true; } assert.ok(top < 50, `${level} forza ${power}: in cima (${top.toFixed(0)})`); assert.ok(out, `${level} forza ${power}: entrata in campo`); } // Una pallina ricaduta nella corsia si assesta e si rilancia, senza perderla. const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0.5); put(d, { x: 440, y: 200, vx: 0, vy: 50 }); run(d, 3); assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa'); }); test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola e non dipende dal fotogramma', () => { const c = m.newFlipper(4, { level: 'seria' }); for (let n = 3; n >= 1; n--) { assert.equal(c.balls, n); m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto put(c, { x: 320, y: 320, vx: 0, vy: 200 }); run(c, 1); } assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c)); assert.match(m.status(c), /Ne servivano 120000/); const d = m.newFlipper(5, { level: 'bona' }); m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla'); assert.match(d.line ?? '', /SALVATA/); m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.equal(d.balls, 2, 'la seconda volta è persa davvero'); // Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex). for (const dts of [[1 / 240, 60], [1 / 60, 15], [0.25, 1]]) { const e = m.newFlipper(6, { level: 'seria' }); m.launch(e, 0.8); e.sim = 2.9; e.saveUntil = 3; put(e, { x: 320, y: 344, vx: 0, vy: 100 }); for (let i = 0; i < dts[1]; i++) m.update(e, dts[0]); assert.equal(e.balls, 3, `salvata con passi da ${dts[0]}`); } }); test('il flipper alzato rimanda su la pallina, anche a tutta velocità (niente attraversamenti); abbassato la lascia scendere', () => { const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9); put(up, { x: 300, y: 300, vx: 0, vy: 150 }); m.hold(up, 'left', 'test', true); let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); } assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`); assert.ok(up.events.some(e => e.kind === 'flipper-flip')); // Il caso riprodotto da Codex: pallina a 980 px/s sulla punta del flipper che si alza. const fast = m.newFlipper(6, { level: 'seria' }); m.launch(fast, 0.9); put(fast, { x: 313.438, y: 323.71, vx: -469.837, vy: 860.031 }); m.hold(fast, 'left', 'test', true); m.update(fast, m.STEP); assert.ok(fast.ball.vy < 0 && fast.ball.y < 324, `respinta, non passata sotto (y ${fast.ball.y.toFixed(1)}, vy ${fast.ball.vy.toFixed(0)})`); const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9); put(down, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; run(down, 2); assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve'); }); test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le quattro corsie insieme il Palio', () => { const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9); const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta'); assert.ok(c.events.some(e => e.kind === 'flipper-fungo')); // La pallina appoggiata sul Regolo (Codex: 240.000 punti in un secondo): non conta. c.score = 0; put(c, { x: c.regolo.x, y: c.regolo.y - c.regolo.r - m.R, vx: 0, vy: 0 }); for (let i = 0; i < 240; i++) m.update(c, m.STEP); assert.equal(c.score, 0, 'il contatto che dura non fa punti'); put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.equal(c.score, m.SCORE.regolo); c.score = 0; for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; put(c, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); } assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank); assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => t.down), 'la fila resta giù un attimo'); put(c, { x: 260, y: 230, vx: 0, vy: 0 }); run(c, 1); assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'poi si rialza'); assert.ok([c.line, ...c.lines].some(l => /PIEVE/.test(l ?? '')), 'la scritta della Pieve (in coda dietro al Regolo)'); // Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva). c.score = 0; for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) { put(c, { x, y: c.lanes[i].y - 3, vx: 0, vy: 150 }); run(c, 0.1); assert.ok(c.lanes[i].lit || c.lanes.every(k => !k.lit), c.lanes[i].rione); } assert.ok(c.score >= 4 * m.SCORE.lane + m.SCORE.palio, `corsie e Palio (${c.score}; in più, al massimo, un fungo sfiorato)`); assert.ok([c.line, ...c.lines].some(l => /PALIO/.test(l ?? ''))); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro'); // In salita non conta. put(c, { x: 287, y: c.lanes[1].y + 6, vx: 0, vy: -150 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(!c.lanes[1].lit); }); test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => { const c = m.newFlipper(8, { level: 'bona' }); m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6); assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`); assert.ok(!m.release(c, 2), 'un altro dito non lancia'); m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio'); m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c)); m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3)); m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4)); assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4)); m.cancel(c); assert.ok(!c.flippers.right.held.length, 'annullare tutto (pausa) lascia i flipper'); // I pulsanti e le frecce; il pulsante da tastiera dà un colpo breve che la fisica vede. assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); assert.ok(m.stopTurn(c, 'button')); assert.ok(!c.flippers.left.held.length); m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click')); const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length); assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length); assert.ok(!m.key(c, 'x', true)); assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale'); // Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi. const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d)); const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…'); e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true)); }); test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => { const c = m.newFlipper(11, { level: 'bona' }); m.launch(c, 0.9); c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.result && c.result.winner === 'player' && c.step === 'play', 'traguardo fatto, si continua'); assert.match(m.status(c), /record/); const g = new Game(story); g.go('via_provinciale'); settle(g); g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g); g.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 }; g.closeupClose(); settle(g); assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto'); // La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore. const h = m.newFlipper(12, { level: 'bona' }); m.launch(h, 0.9); m.hold(h, 'left', 't', true); run(h, 0.5); put(h, { x: 296, y: 300, vx: 0, vy: 0 }); run(h, 4); assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`); }); test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => { const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; put(c, { x: 247, y: 250, vx: 0, vy: -470 }); let onRamp = 0, top = 999, exit = null; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } else if (onRamp && !exit) exit = { x: c.ball.x, y: c.ball.y }; } assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola'); assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`); assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0); assert.ok(c.events.some(e => e.kind === 'flipper-rampa')); // Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati). const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 247, y: 250, vx: 0, vy: -470 }); run(f, 0.3); assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1); assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`); // Il secondo ponte subito dopo: i PONTI, col bonus. put(c, { x: 393, y: 250, vx: 0, vy: -470, ramp: null }); run(c, 2.5); assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`); assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? ''))); assert.equal(c.lastRamp, null, 'la combo è spesa'); // Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti. const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0; put(d, { x: 247, y: 250, vx: 0, vy: -335 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; } assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte'); // Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no. const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0; put(e, { x: 247, y: 236.1, vx: 500, vy: -30 }); run(e, 0.5); assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita'); // La combo scade dopo dodici secondi, e si azzera perdendo la pallina. const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 }); run(g, 13); assert.equal(g.lastRamp, null, 'scaduta'); g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 330, vx: 0, vy: 300 }); run(g, 1); assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa'); }); test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { let won = 0; for (let seed = 1; seed <= 8; seed++) { const c = m.newFlipper(seed * 7919, { level: 'bona' }); autoplay(c); assert.equal(c.step, 'result', `seme ${seed}: finita`); if (c.result.winner === 'player') won++; } assert.ok(won >= 5, `vinte ${won} su 8`); }); test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giusto e le cifre corrono', async () => { const calls = [], gradient = { addColorStop() {} }; const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); const c = m.newFlipper(11, { level: 'bona' }), bg = {}; const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER')); m.launch(c, 0.8); run(c, 1); calls.length = 0; draw(); assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi'); c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!')); // Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti). const l = m.newFlipper(12, { level: 'bona' }); l.step = 'result'; l.ball = null; l.result = { winner: 'cpu', score: 0, goal: 60000 }; calls.length = 0; drawFlipper(ctx, l, story.closeups.flipper, bg); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FINE PARTITA')); // Le cifre che corrono: in mezzo secondo da dove erano a dove devono arrivare, a scatti di dieci. const { shownScore } = await import('../src/engine/flipper-render.js'); const d = { from: 1000, to: 51000, t0: 10 }; assert.equal(shownScore(d, 10), 1000); assert.equal(shownScore(d, 10.25) % 10, 0); assert.ok(shownScore(d, 10.25) > 20000 && shownScore(d, 10.25) < 30000); assert.equal(shownScore(d, 10.5), 51000); }); test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => { const g = new Game(story); g.go('via_provinciale'); settle(g); assert.equal(g.hotspot('river').default, 'open'); const [bx, by, bw, bh] = g.hotspot('river').box; assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'river', 'il River si clicca'); g.act('use', 'river'); settle(g); assert.ok(g.options().some(o => o.text === 'Una partita seria.')); g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g); assert.equal(g.closeup?.id, 'flipper'); assert.equal(g.closeup.level, 'seria'); g.closeupClose(); assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say); settle(g); g.act('open', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); assert.equal(g.closeup?.level, 'male'); });