Files

294 lines
15 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP, STRONG } from '../src/engine/biliardo.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); };
const still = c => { for (let i = 0; i < 3000 && !['aim', 'result'].includes(c.step); i++) update(c, 1 / 60); };
const pins = (...which) => castle().filter((_, i) => which.includes(i));
test('il castello: il rosso in mezzo al tavolo, quattro bianchi a croce', () => {
const p = castle();
assert.equal(p.length, 5);
assert.deepEqual(p.filter(x => x.red).map(x => [x.x, x.y]), [[LEN / 2, WID / 2]]);
assert.equal(p.filter(x => !x.red).length, 4);
});
test('il valore dei birilli: bianco 2, rosso 4, rosso da solo 10; e il fallo porta i punti all\'avversario', () => {
assert.equal(value(pins(1)), 2);
assert.equal(value(pins(0, 1)), 6);
assert.equal(value(pins(0)), 10);
assert.equal(value(pins(0, 1, 2, 3, 4)), 12);
const by = (list, who) => list.map(p => ({ ...p, by: who }));
assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: true, pins: by(pins(1, 2), 'cpu') }), { shooter: 'player', to: 'player', points: 7, foul: null });
assert.deepEqual(judge({ shooter: 'player', first: null, pallino: false, pins: [] }), { shooter: 'player', to: 'cpu', points: 2, foul: 'miss' });
assert.deepEqual(judge({ shooter: 'cpu', first: 'pallino', pallino: false, pins: [] }), { shooter: 'cpu', to: 'player', points: 2, foul: 'pallino' });
assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: false, pins: by(pins(0), 'player') }), { shooter: 'player', to: 'cpu', points: 10, foul: 'own' });
});
test('la forza: sale finché si tiene premuto; a metà la biglia fa circa un tavolo, a piena ne fa più di due', () => {
assert.equal(powerAt(0), 0);
assert.equal(powerAt(SWING * 2), 1);
assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`);
assert.ok(reach(speedFor(1)) > LEN * 2);
assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena');
// All'Ing. Tozzi il tiro pieno va più forte (Andrea, 29 ottobre); i rinquarti restano alla forza di prima.
assert.ok(reach(speedFor(1, STRONG)) > reach(speedFor(1)) * 1.3);
assert.equal(newBiliardo(1).maxSpeed, STRONG);
});
test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => {
const c = newBiliardo(3);
const me = c.balls.player, other = c.balls.cpu, before = { ...other };
c.angle = Math.atan2(other.y - me.y, other.x - me.x);
assert.ok(setPower(c, 0.5) && shoot(c));
assert.equal(c.step, 'rolling');
still(c);
assert.ok(other.x !== before.x || other.y !== before.y, 'la biglia avversaria si è mossa');
assert.equal(c.current, 'cpu');
assert.equal(c.last.foul, null);
assert.ok(c.last.points >= 0);
});
test('mancare la biglia avversaria è fallo: due punti all\'altro', () => {
const c = newBiliardo(5);
c.balls.player = { role: 'player', x: 30, y: 20, vx: 0, vy: 0 };
c.balls.cpu = { role: 'cpu', x: 250, y: 120, vx: 0, vy: 0 };
c.balls.pallino = { role: 'pallino', x: 250, y: 30, vx: 0, vy: 0 };
c.angle = 0; // dritto lungo la sponda, lontano da tutto
assert.ok(setPower(c, 0.1) && shoot(c));
still(c);
assert.deepEqual(c.points, { player: 0, cpu: 2 });
assert.equal(c.last.foul, 'miss');
});
test('un birillo abbattuto dalla biglia avversaria vale punti; poi i birilli si rialzano', () => {
const c = newBiliardo(9);
// La mia biglia piazzata dietro a quella avversaria, in linea col castello.
c.balls.cpu = { role: 'cpu', x: LEN / 2 - 30, y: WID / 2, vx: 0, vy: 0 };
c.balls.player = { role: 'player', x: LEN / 2 - 60, y: WID / 2, vx: 0, vy: 0 };
c.balls.pallino = { role: 'pallino', x: 20, y: 20, vx: 0, vy: 0 };
c.angle = 0;
assert.ok(setPower(c, 0.5) && shoot(c));
for (let i = 0; i < 3000 && c.step === 'rolling'; i++) update(c, 1 / 60);
assert.ok(c.pins.some(p => p.down), 'qualche birillo è caduto');
assert.ok(c.pins.filter(p => p.down).every(p => p.by === 'cpu'));
still(c);
assert.equal(c.last.to, 'player');
assert.ok(c.points.player >= 2, `punti: ${c.points.player}`);
assert.ok(c.pins.every(p => !p.down), 'il castello è di nuovo in piedi');
});
test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita finisce a venti', () => {
const c = newBiliardo(42, { target: 6 });
c.current = 'cpu';
let frames = 0;
while (c.step === 'aim' && frames++ < 600) update(c, 1 / 60);
assert.equal(c.step, 'rolling', 'l\'avversario ha tirato');
assert.ok(frames > 20, 'non tira subito: ci pensa');
for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) {
// Una mano comprende anche il tempo in cui la CPU pensa prima di tirare.
for (let i = 0; i < 600 && c.step === 'aim' && c.current === 'cpu'; i++) update(c, 1 / 60);
still(c);
if (c.step === 'aim' && c.current === 'player') {
const me = c.balls.player, other = c.balls.cpu;
c.angle = Math.atan2(other.y - me.y, other.x - me.x);
press(c); run(c, 0.6); release(c);
}
}
assert.equal(c.step, 'result');
assert.ok(['player', 'cpu'].includes(c.result.winner));
assert.ok(c.points[c.result.winner] >= 6);
assert.equal(press(c), false, 'a partita finita non si tira più');
});
const emptyTable = () => {
const c = newBiliardo(123);
c.pins = [];
Object.assign(c.balls.player, { x: 30, y: 30 });
Object.assign(c.balls.cpu, { x: 250, y: 120 });
Object.assign(c.balls.pallino, { x: 20, y: 120 });
return c;
};
const roll = (c, dt = FIXED_STEP) => {
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, dt);
assert.equal(c.step, 'settle', 'tutte le biglie devono fermarsi');
};
const near = (actual, expected, tolerance = 1e-6) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected} (tolleranza ${tolerance})`);
test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique e a diverse forze', () => {
for (const [x, y, angle, power] of [[30, 30, 0, 0.2], [270, 30, 0.65, 0.85], [260, 100, 0.1, 1], [30, 10, -1.2, 0.6]]) {
const c = emptyTable();
Object.assign(c.balls.player, { x, y });
c.angle = angle; c.power = power;
const before = JSON.stringify(c), p = predict(c);
assert.equal(JSON.stringify(c), before, 'la previsione non altera il tavolo né il seme casuale');
assert.equal(p.hit, null);
const end = p.points.at(-1);
shoot(c); roll(c);
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power, c.maxSpeed)), 0.01);
}
});
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
const c = emptyTable();
Object.assign(c.balls.player, { x: 270, y: 30 });
c.angle = Math.PI / 4; c.power = 0.2; // la stessa velocità di 0,3 prima del tiro più forte
const p = predict(c), [start, rail, end] = p.points;
assert.equal(p.points.length, 3);
near(rail[0], LEN - R);
near((rail[1] - start[1]) / (rail[0] - start[0]), 1);
near((end[1] - rail[1]) / (end[0] - rail[0]), -1 / CUSHION);
});
test('i colpi sottili arrivano al punto di contatto corretto anche a piena forza', () => {
for (const offset of [0, R, 2 * R * 0.9, 2 * R - 0.0001, 2 * R + 0.0001]) {
const c = emptyTable();
Object.assign(c.balls.player, { x: 30, y: 50 + offset });
Object.assign(c.balls.cpu, { x: 100, y: 50 });
c.angle = 0; c.power = 1;
const p = predict(c);
shoot(c);
for (let i = 0; i < 1000 && c.balls.player.x < 110 && !c.shot.first; i++) update(c, FIXED_STEP);
if (offset > 2 * R) {
assert.equal(c.shot.first, null, 'un passaggio esterno non diventa un urto');
continue;
}
assert.equal(p.hit, 'cpu');
assert.equal(c.shot.first, 'cpu', 'non deve attraversare la biglia nei colpi radenti');
near(p.ghost.x, 100 - Math.sqrt((2 * R) ** 2 - offset ** 2));
near(p.ghost.y, 50 + offset);
const speed = Math.hypot(c.balls.cpu.vx, c.balls.cpu.vy);
near(c.balls.cpu.vx / speed, p.dir[0]); near(c.balls.cpu.vy / speed, p.dir[1]);
const target = p.outgoing.find(t => t.role === 'cpu').points;
near((target[1][0] - target[0][0]) / Math.hypot(target[1][0] - target[0][0], target[1][1] - target[0][1]), p.dir[0]);
}
});
test('gli urti conservano la quantità di moto e non creano energia; le biglie che si separano non rimbalzano di nuovo', () => {
const a = { x: 40, y: 50, vx: 300, vy: 80 }, b = { x: 40 + 2 * R, y: 50, vx: -20, vy: -15 };
const energy = a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2;
assert.ok(collide(a, b) > 0);
near(a.vx + b.vx, 280); near(a.vy + b.vy, 65);
assert.ok(a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2 < energy);
const after = JSON.stringify([a, b]);
assert.equal(collide(a, b), 0);
assert.equal(JSON.stringify([a, b]), after);
});
test('la mira è bloccata durante la carica, il tocco breve ripristina la forza e le frecce consentono ritocchi fini', () => {
const c = newBiliardo(1), angle = c.angle, power = c.power;
press(c);
assert.equal(aimAt(c, 100, 100), false);
assert.equal(nudge(c, 1), false);
update(c, 0.05); release(c);
assert.equal(c.step, 'aim'); assert.equal(c.angle, angle); assert.equal(c.power, power);
nudge(c, 1); near(c.angle - angle, 0.002);
nudge(c, -1, true); near(c.angle - angle, 0.0015);
const fixed = c.angle;
assert.equal(aimScreen(c, [320, 30]), false, 'il pannello superiore non è una superficie di mira');
assert.equal(c.angle, fixed);
press(c); update(c, 0.2); cancel(c);
assert.equal(c.power, power);
press(c); update(c, 0.2); release(c);
update(c, WINDUP + 1 / 240); // la stecca arriva alla biglia
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
});
test('anteprima, CPU e tiro reale condividono ordine degli urti e punteggio', () => {
for (const [angle, power] of [[0, 1], [0.15, 0.5], [-0.6, 0.8], [2.4, 0.6]]) {
const c = newBiliardo(1);
Object.assign(c.balls.player, { x: 100, y: 71 });
Object.assign(c.balls.cpu, { x: 126, y: 71 });
c.angle = angle; c.power = power;
const p = predict(c), expected = simulate(c, 'player', angle, power);
shoot(c); roll(c);
assert.deepEqual(judge(c.shot), expected);
if (p.hit === 'cpu' || p.hit === 'pallino') assert.equal(c.shot.first, p.hit);
if (angle === 0) assert.ok(c.shot.pins.every(pin => pin.by === 'cpu'));
}
});
test('la fisica resta identica a 30, 60, 144 fps e con fotogrammi irregolari', () => {
const states = [];
for (const frames of [[1 / 30], [1 / 60], [1 / 144], [0.008, 0.042, 0.016, 0.09]]) {
const c = newBiliardo(1);
c.angle += 0.02; c.power = 0.9;
shoot(c);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, frames[i % frames.length]);
assert.equal(c.step, 'settle');
states.push({ balls: c.balls, score: judge(c.shot) });
}
for (const s of states.slice(1)) assert.deepEqual(s, states[0]);
});
test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => {
const c = newBiliardo(1);
const me = c.balls.player;
assert.ok(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + (me.y + 40) * VIEW.k]));
assert.ok(Math.abs(c.angle - Math.PI / 2) < 1e-9, 'verso il basso');
assert.equal(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + me.y * VIEW.k]), false, 'sulla biglia stessa non si mira');
c.balls.cpu = { role: 'cpu', x: me.x + 50, y: me.y, vx: 0, vy: 0 };
c.angle = 0;
const p = predict(c);
assert.equal(p.hit, 'cpu');
assert.ok(Math.abs(p.ghost.x - (me.x + 50 - 2 * R)) < 1, 'la biglia fantasma tocca quella avversaria');
assert.ok(canThrow(c));
});
test('al Circolo 4 Maggio: dal portone in piazza si entra nella sala, e al tavolo si gioca; vincere segna la bandierina', () => {
const g = new Game(story);
settle(g);
g.go('piazza'); settle(g);
assert.ok(g.act('open', 'circolo'), '«Apri» sul portone fa entrare (Andrea, 25 ottobre: «non si apre la porta»)');
settle(g);
assert.equal(g.state.scene, 'circolo_4_maggio');
g.go('piazza'); settle(g);
assert.ok(g.act('walk', 'circolo'));
settle(g);
assert.equal(g.state.scene, 'circolo_4_maggio');
assert.ok(g.act('talk', 'ing_tozzi'));
settle(g);
g.choose(g.options().findIndex(o => o.text === 'Ingegnere, una partita ai birilli?'));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Si va a venti')));
assert.deepEqual(g.options().map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.'], 'prima si sceglie quanto fa sul serio');
g.choose(g.options().findIndex(o => o.text === 'A fassi male.'));
settle(g);
assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'male');
assert.equal(g.act('look', 'stecche'), false, 'durante la partita la scena non risponde');
const c = g.closeup;
assert.ok(g.closeupAim([VIEW.x + c.balls.cpu.x * VIEW.k, VIEW.y + c.balls.cpu.y * VIEW.k]), 'si mira sulla biglia avversaria');
assert.ok(g.closeupPress());
for (let i = 0; i < 30; i++) g.update(1 / 60);
assert.ok(g.closeupRelease());
assert.equal(c.step, 'rolling');
c.step = 'result'; c.result = { winner: 'player', points: { player: 20, cpu: 4 } };
for (let i = 0; i < 10; i++) g.update(0.25);
assert.equal(g.closeup, c, 'il tavolo resta a schermo finché non si esce');
assert.ok(g.closeupClose());
const after = settle(g).map(s => `${s.who}: ${s.text}`);
assert.ok(after.some(t => t.startsWith('ing_tozzi: Venti. Hai vinto te')));
assert.ok(g.state.flags.biliardo_vinto);
// Lasciare a metà non segna niente.
g.act('talk', 'ing_tozzi'); settle(g);
assert.ok(g.options().some(o => o.text === 'La rivincita?'), 'vinto, si chiede la rivincita');
g.choose(g.options().findIndex(o => o.text === 'La rivincita?')); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g);
assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'bona');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?')));
});
test('i livelli dell\'Ingegnere: «Alla bona» prova pochi colpi e trema, «A fassi male» li prova tutti', () => {
const bona = newBiliardo(1, { level: 'bona' }), male = newBiliardo(1, { level: 'male' }), none = newBiliardo(1);
assert.equal(none.level, 'seria', 'senza scelta si gioca sul serio');
assert.ok(bona.skill.wobble > male.skill.wobble * 3);
assert.ok(bona.skill.angles < male.skill.angles);
});