Files
leggenda-del-regolo/tests/flipper.test.mjs
T

596 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { existsSync } from 'node:fs';
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);
// Osserva il percorso fino all'uscita, senza includere il tiro successivo sulla tavola.
function ride(c, initial) {
put(c, initial);
let frames = 0, top = Infinity;
for (let i = 0; i < 4 / m.STEP; i++) {
m.update(c, m.STEP);
if (c.ball?.ramp) { frames++; top = Math.min(top, c.ball.y); }
else if (frames) return { frames, top, exit: { ...c.ball } };
}
assert.fail('la pallina non ha completato o abbandonato la rampa entro quattro secondi');
}
// 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 al centro e si lancia lasciando, ai lati le palette anche prima del lancio; annullare non lancia; due dita, pulsanti e tasti', () => {
const c = m.newFlipper(8, { level: 'bona' });
// A pallina ferma i lati alzano le palette, il centro carica la molla.
m.aimScreen(c, [120, 300]); assert.ok(m.press(c, 5)); assert.ok(c.flippers.left.held.includes(5) && !c.holding, 'a sinistra, la paletta');
m.aimScreen(c, [520, 300]); assert.ok(m.press(c, 6)); assert.ok(c.flippers.right.held.includes(6) && !c.holding, 'a destra, la paletta');
m.release(c, 5); m.release(c, 6); assert.ok(m.waiting(c));
m.aimScreen(c, [320, 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, [300, 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, 'ShiftLeft', true)); assert.ok(c.flippers.left.held.includes('key-ShiftLeft') && !c.flippers.right.held.length);
m.key(c, 'ShiftRight', true); assert.ok(c.flippers.right.held.includes('key-ShiftRight'));
m.key(c, 'ShiftLeft', false); m.key(c, 'ShiftRight', false); assert.ok(!c.flippers.left.held.length && !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 g = m.newFlipper(11); assert.ok(m.key(g, 'ArrowDown', true)); run(g, 0.3); assert.ok(g.holding, 'anche la freccia giù carica la molla'); m.key(g, 'ArrowDown', false); assert.ok(!m.waiting(g));
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('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) {
const c = m.newFlipper(85, { level }); m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - missing;
const currentBall = c.ball, b = c.bumpers[0];
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`);
assert.equal(c.ball, currentBall, 'il tiro in corso prosegue');
assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player');
assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1');
assert.match(m.status(c), /pallina premio vinta/);
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi');
c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1);
assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio');
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1);
assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
}
});
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;
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
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: 273, y: 240, 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.
ride(c, { x: 367, y: 240, vx: 0, vy: -470, ramp: null });
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: 273, y: 240, vx: 0, vy: -280 }); 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 > 226, '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: 273, y: 226.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: 342, vx: 0, vy: 300, waiting: false }); run(g, 1);
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
});
test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) {
const c = m.newFlipper(81, { level }); m.launch(c, 0.8);
ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 });
assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`);
assert.ok(c.ramps[index].lit > 0);
for (const offset of [-12, 12]) {
const d = m.newFlipper(82, { level }); m.launch(d, 0.8);
const r = d.ramps[index];
ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 });
assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`);
}
const e = m.newFlipper(83, { level }); m.launch(e, 0.8);
const r = e.ramps[index];
put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05);
assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata');
}
});
test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => {
for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) {
const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y: 250, vx: 0, vy: 100 });
let lower = null;
for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) {
m.update(c, m.STEP);
if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x };
}
assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`);
if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`);
else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); }
}
});
test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => {
const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9);
const kinds = () => c.fx.map(f => f.kind);
const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('ring') && kinds().includes('pop') && kinds().includes('spark'), `fungo: ${kinds()}`);
assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000');
c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`);
c.fx.length = 0; ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`);
assert.ok(c.trail.length > 5, 'la cometa');
const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball;
assert.equal(c.fx.length, 0, 'spariti');
// Disegnarli non cambia niente.
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; } });
put(c, { x: 273, y: 240, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before);
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
function rampShot(c, id) {
if (m.waiting(c)) m.launch(c, 0.8);
const r = c.ramps.find(r => r.id === id);
return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false });
}
function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); }
test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => {
for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) {
const c = m.newFlipper(21, { level }); m.launch(c, 0.9);
const superRamp = c.ramps.find(r => r.id === 'super');
assert.ok(!m.rampOpen(c, superRamp));
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1);
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2);
rampShot(c, ids[0]);
assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]);
assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3);
const lockedBall = c.locks[0].ball;
rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock');
const score = c.score; rampShot(c, ids[1]);
assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock');
assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1);
const afterSpecial = c.score; rampShot(c, ids[1]);
assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica');
rampShot(c, ids[1]);
assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp));
assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
const before = c.score, reserve = c.balls, hadGoal = !!c.result;
rampShot(c, 'super');
assert.equal(c.score - before, m.SCORE.super);
assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato');
assert.equal(c.locks.length, 0); assert.equal(c.special, null);
assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c));
assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte');
assert.ok(c.events.some(e => e.kind === 'flipper-super'));
assert.ok(c.transform, 'la SUPER avvia la trasformazione');
run(c, m.FUTURE.duration);
assert.equal(c.table, 'future');
const lives = c.balls;
for (const b of m.activeBalls(c)) b.releaseAt = 0;
// Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto.
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 });
c.saveUntil = 0; m.update(c, m.STEP);
assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c));
assert.equal(c.table, 'classic', 'persa l’ultima si torna al River');
}
});
test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => {
const c = m.newFlipper(22); m.launch(c, 0.8);
put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02);
assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica');
rampShot(c, 'sinistra'); rampShot(c, 'sinistra');
c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1);
assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1);
rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1);
c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.rampRun.count, 0);
});
test('la trasformazione protegge le tre palline, poi le libera sfalsate verso le palette', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
const before = m.activeBalls(c).map(b => [b.x, b.y]), score = c.score, lives = c.balls;
run(c, 0.8); assert.deepEqual(m.activeBalls(c).map(b => [b.x, b.y]), before);
assert.equal(c.score, score); assert.equal(c.balls, lives);
run(c, m.FUTURE.duration - 0.8);
assert.equal(c.table, 'future'); assert.equal(c.transform, null);
const main = c.ball, delayed = c.extraBalls.at(-1), position = [delayed.x, delayed.y];
assert.ok(delayed.releaseAt > c.sim);
run(c, 0.2); assert.deepEqual([delayed.x, delayed.y], position);
// Isola la prima pallina per verificare il recupero dalla paletta, senza alterare la geometria.
const d = structuredClone(c); d.extraBalls = [];
let recovered = false;
for (let i = 0; i < 480; i++) {
if (d.ball.x > 284 && d.ball.y > 300) m.hold(d, 'left', 'test', true);
m.update(d, m.STEP);
if (d.ball.vy < -250) { recovered = true; break; }
}
assert.ok(recovered, `${level}: prima pallina ripresa dalla paletta`);
run(c, 1); assert.notDeepEqual([delayed.x, delayed.y], position);
assert.ok(Number.isFinite(main.x));
}
});
test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => {
const c = m.newFlipper(23, { level: 'male' }); doubleLock(c);
const lives = c.balls;
for (const remaining of [1, 0]) {
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c));
assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null);
}
assert.equal(c.special, null); assert.equal(c.specialPaid, false);
});
test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => {
const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration);
for (const b of m.activeBalls(c)) b.releaseAt = 0;
const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true);
m.update(single, m.STEP); m.update(c, m.STEP);
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0];
spare.waiting = true;
Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] });
Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] });
const score = c.score; m.update(c, m.STEP);
assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente');
Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 });
Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 });
m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono');
const lives = c.balls;
Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2);
});
test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => {
const c = m.newFlipper(25); m.launch(c, 0.8);
for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) {
const t = c.targets.find(t => t.bank === bank && t.i === i), dir = bank === 'pieve' ? -1 : 1;
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, ramp: null }); run(c, 0.02);
}
assert.equal(c.jackpotX, 3); doubleLock(c);
const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3);
for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) {
const d = m.newFlipper(26); m.launch(d, 0.8);
put(d, { x, y, vx: 0, vy: 240, inPlay: true });
let enteredShooter = false;
for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; }
assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`);
}
});
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.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
for (const r of c.ramps.filter(r => r.id !== 'super')) {
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
}
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso');
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco');
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio');
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');
});
test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita sinistra (Andrea: «a sinistra esce troppo facile»)', () => {
const c = m.newFlipper(31); m.launch(c, 0.8); c.saveUntil = 0;
// Una pallina che scivola lungo la parete sinistra, sopra la Pieve: viene rimandata in campo, non nel canale.
put(c, { x: 205, y: 120, vx: 0, vy: 150, ramp: null });
let inChannel = false;
for (let i = 0; i < 120; i++) { m.update(c, 1 / 60); if (c.ball.x < 210 && c.ball.y > 146 && c.ball.y < 198) inChannel = true; }
assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli');
assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`);
});
function inFuture(level = 'seria') {
const c = m.newFlipper(96, { level }); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration);
// Gli altri tiri restano parcheggiati mentre si misura un percorso alla volta.
for (const b of c.extraBalls) b.releaseAt = c.sim + 1000;
c.ball.releaseAt = 0; return c;
}
test('il futuro è un altro campo: tre giri reali, quote, punti e sigilli solo a percorso completo', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = inFuture(level), old = m.layout();
assert.notDeepEqual(c.walls, old.walls); assert.notDeepEqual(c.bumpers, old.bumpers);
assert.equal(c.targets.length, 0); assert.equal(c.lanes.length, 0);
for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= 200 && x <= 448 && y >= 5 && y <= 336);
for (const r of c.ramps) {
const score = c.score;
for (let n = 1; n <= 3; n++) {
const result = rampShot(c, r.id);
assert.ok(result.frames > 50); assert.ok(result.top < r.mouth[0][1] - 40);
assert.equal(c.score - score, r.points * n); assert.equal(c.future.hits[r.id], Math.min(n, 2));
assert.ok(result.exit.x === 229 || result.exit.x === 411, 'ritorno nelle corsie interne');
}
// Un ingresso debole sulla salita ricade e non carica il sigillo.
const d = inFuture(level), rd = d.ramps.find(k => k.id === r.id), points = d.score;
const result = ride(d, { x: rd.path[0][0], y: rd.mouth[0][1] + 1, vx: 0, vy: -270, ramp: null });
assert.equal(d.future.hits[r.id], 0); assert.equal(d.score, points); assert.ok(result.exit.y >= rd.mouth[0][1]);
}
const score = c.score, lives = c.balls;
const nextX = 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24;
put(c, { x: nextX, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] });
m.update(c, m.STEP);
assert.equal(c.table, 'classic'); assert.equal(c.score, score + m.FUTURE.finale);
assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
assert.deepEqual(c.ramps.map(r => r.id), ['sinistra', 'destra', 'super']);
doubleLock(c); rampShot(c, 'super'); assert.ok(c.transform, 'il viaggio è ripetibile');
}
});
test('nel futuro il Regolo si muove, gli urti e la molla funzionano, niente finale prima dei sigilli', () => {
const c = inFuture(), x = c.regolo.x;
c.ball.waiting = true; run(c, 0.5); assert.notEqual(c.regolo.x, x);
assert.ok(m.key(c, ' ', true)); run(c, 0.4); m.key(c, ' ', false); assert.ok(!m.waiting(c));
// Un respingente devia davvero la pallina.
const b = c.bumpers[0], score = c.score;
put(c, { x: b.x, y: b.y + b.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP);
assert.ok(c.ball.vy > 0); assert.equal(c.score, score + m.SCORE.bumper);
put(c, { x: c.regolo.x, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP);
assert.equal(c.table, 'future'); assert.equal(c.score, score + m.SCORE.bumper + m.SCORE.regolo);
});
test('futuro e trasformazione sono serializzabili; annullare e disegnare non fanno avanzare la partita', () => {
for (const transforming of [false, true]) {
const c = inFuture(); if (transforming) { doubleLockAfterReturn(c); }
m.hold(c, 'left', 'touch', true); m.cancel(c);
const saved = JSON.parse(JSON.stringify(c));
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; } });
for (let i = 0; i < 10; i++) drawFlipper(ctx, c, story.closeups.flipper, {});
assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'disegno e pausa non consumano tempo');
assert.ok(!c.flippers.left.held.length);
run(c, 0.5); run(saved, 0.5);
assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'ripresa identica dopo il passaggio JSON');
}
});
function doubleLockAfterReturn(c) {
for (const b of m.activeBalls(c)) Object.assign(b, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 });
m.update(c, m.STEP); doubleLock(c); rampShot(c, 'super');
}
test('il ball save nel futuro salva una sola volta, conserva campo e sigilli, poi torna al classico', () => {
const c = inFuture(); c.extraBalls = []; c.future.hits.ponte = 1;
c.saveUntil = c.sim + 2; const lives = c.balls;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.table, 'future'); assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.future.hits.ponte, 1);
m.launch(c, 0.7); assert.equal(m.saveLeft(c), 0);
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.table, 'classic'); assert.equal(c.balls, lives - 1);
});
test('i tre percorsi del futuro sono raggiungibili con il colpo fisico della paletta', () => {
for (const [id, x, y] of [['orbita', 288, 300], ['ponte', 290, 306], ['spira', 308, 320]]) {
const c = m.newFlipper(9);
Object.assign(c, m.futureLayout(), { table: 'future', future: { hits: { orbita: 0, ponte: 0, spira: 0 } } });
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y, vx: 0, vy: 180, inPlay: true }); m.hold(c, 'left', 'test', true);
let entered = null;
for (let i = 0; i < 160; i++) { m.update(c, m.STEP); if (c.ball?.ramp) { entered = c.ball.ramp.id; break; } }
assert.equal(entered, id, 'il tiro parte dalla paletta, non dall’imbocco');
}
});
test('salvare l’avventura durante il futuro conserva il checkpoint, come negli altri minigiochi', () => {
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);
const checkpoint = structuredClone(g.checkpoint);
Object.assign(g.closeup, inFuture('bona'));
const before = structuredClone(g.closeup), save = JSON.parse(JSON.stringify(g.savable()));
assert.deepEqual(save, checkpoint); assert.deepEqual(g.closeup, before, 'salvare non consuma sigilli o palline');
const loaded = new Game(story, { state: save });
assert.equal(loaded.state.scene, 'via_provinciale'); assert.equal(loaded.closeup, null);
g.closeupCancel(); assert.equal(g.closeup.table, 'future');
g.closeupClose(); settle(g); assert.ok(g.state.flags.flipper_vinto, 'il traguardo resta acquisito uscendo dal futuro');
});
test('per provare, openSuper fa davvero i due LOCK e apre la SUPER SPECIAL (main.js, ?debug, tasto S)', () => {
const c = m.newFlipper(73, { level: 'bona' });
assert.ok(m.openSuper(c));
assert.equal(c.special, 'super'); assert.equal(c.locks.length, 2); assert.equal(c.table, 'classic');
});