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

296 lines
14 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 r from '../src/engine/rinquarto.js';
import { R, PIN, LEN, WID, predict, FIXED_STEP, VIEW, simulate, reach, speedFor } from '../src/engine/biliardo.js';
import { pinFall } from '../src/engine/biliardo-render.js';
import { drawRinquarto } from '../src/engine/rinquarto-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
function roll(c, frames = [1 / 60]) {
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) r.update(c, frames[i % frames.length]);
assert.ok(['settle', 'result'].includes(c.step), 'il tiro si deve fermare');
}
function next(c) {
for (let i = 0; i < 20 && c.step === 'settle'; i++) r.update(c, 0.25);
assert.ok(['aim', 'result'].includes(c.step));
}
function play(c, shot = c.solution) {
Object.assign(c, shot);
assert.ok(r.shoot(c));
assert.equal(r.shoot(c), false, 'un solo tiro per posizione');
roll(c);
}
test('stesso seme e livello riproducono tutte le sei posizioni e le soluzioni', () => {
for (const level of Object.keys(r.LEVELS)) {
const a = r.newRinquarto(123, { level }), b = r.newRinquarto(123, { level });
for (let i = 1; i <= a.skill.positions; i++) {
assert.equal(a.position, i);
assert.deepEqual(a, b);
assert.match(r.status(a), /Bertovo: «Oh tirami questo rinquarto!\?»/);
play(a); play(b); next(a); next(b);
}
assert.deepEqual(a, b);
}
assert.notDeepEqual(r.newRinquarto(12).balls, r.newRinquarto(13).balls);
});
test('ogni posizione è coperta geometricamente e il solver trova un tiro che fa punto davvero', () => {
for (const level of Object.keys(r.LEVELS)) for (const seed of [0, 1, 42, 0xffffffff]) {
const c = r.newRinquarto(seed, { level });
for (let i = 0; i < c.skill.positions; i++) {
assert.ok(r.directCovered(c));
// Verifica indipendente: il disturbo sul segmento sta fra i due centri.
const a = c.balls.player, b = c.balls.cpu, d = c.balls.blocker;
const distance = Math.hypot(b.x - a.x, b.y - a.y);
const area = Math.abs((b.x - a.x) * (d.y - a.y) - (b.y - a.y) * (d.x - a.x));
assert.ok(area / distance < 2 * R);
assert.ok(Math.hypot(d.x - a.x, d.y - a.y) + Math.hypot(b.x - d.x, b.y - d.y) <= distance + 1e-6);
for (const ball of Object.values(c.balls)) {
assert.ok(ball.x >= R && ball.x <= LEN - R && ball.y >= R && ball.y <= WID - R);
for (const other of Object.values(c.balls)) if (other !== ball)
assert.ok(Math.hypot(ball.x - other.x, ball.y - other.y) > 2 * R);
}
const before = structuredClone(c), solution = r.solve(c);
assert.ok(solution);
assert.deepEqual(c, before, 'la ricerca non cambia lo stato né il seme');
const expected = r.tryShot(c, solution.angle, solution.power);
assert.ok(expected.point && expected.cushions >= c.skill.cushions);
assert.equal(r.tryShot(c, c.angle, 1).point, false, 'il diretto è coperto');
const points = c.points;
play(c, solution);
assert.deepEqual(c.attempt, expected);
assert.equal(c.points, points + 1);
next(c);
}
}
});
const clear = level => {
const c = r.newRinquarto(42, { level });
c.balls = {
player: { role: 'player', x: 80, y: 50, vx: 0, vy: 0 },
cpu: { role: 'cpu', x: 80, y: 100, vx: 0, vy: 0 },
};
c.pins = [];
return c;
};
test('un diretto libero non vale; una sponda vale solo alla bona, senza recuperare dopo il primo contatto', () => {
assert.deepEqual(Object.values(r.LEVELS).map(k => k.cushions), [1, 2, 3]);
for (const level of Object.keys(r.LEVELS)) {
const c = clear(level);
assert.equal(r.directCovered(c), false);
const direct = r.tryShot(c, Math.PI / 2, 1);
assert.equal(direct.cushions, 0);
assert.equal(direct.point, false);
assert.equal(direct.reason, 'poche sponde');
const bank = r.tryShot(c, -Math.PI / 2, 0.7);
assert.equal(bank.cushions, 1);
assert.equal(bank.point, level === 'bona');
play(c, { angle: -Math.PI / 2, power: 0.7 });
assert.equal(c.points, Number(level === 'bona'));
assert.deepEqual(c.attempt, bank);
}
});
test('biglie e birilli di disturbo prima del bersaglio annullano il tiro', () => {
for (const kind of ['ball', 'pin']) {
const c = clear('bona');
if (kind === 'ball') c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 };
else c.pins.push({ x: 80, y: 25, down: false });
const result = r.tryShot(c, -Math.PI / 2, 1);
assert.equal(result.point, false);
assert.equal(result.reason, 'disturbo toccato');
play(c, { angle: -Math.PI / 2, power: 1 });
assert.equal(c.points, 0);
assert.equal(c.attempt.reason, result.reason);
}
});
test('un disturbo dopo il bersaglio non annulla un punto già fatto', () => {
const c = clear('bona');
// Dopo la sponda alta la bianca colpisce la gialla, che abbatte il birillo.
c.pins = [{ x: 80, y: 120, down: false }];
const events = [];
simulate(c, 'player', -Math.PI / 2, 1, { onContact: e => events.push({ kind: e.kind, role: e.a.role }) });
assert.ok(events.findIndex(e => e.kind === 'pin') > events.findIndex(e => e.kind === 'ball'));
play(c, { angle: -Math.PI / 2, power: 1 });
assert.ok(c.pins[0].down, 'il contatto successivo avviene davvero');
assert.equal(c.points, 1);
assert.equal(c.attempt.reason, null);
});
test('durante l’esito continua la caduta dei birilli, senza assegnare altri punti', () => {
const c = clear('bona');
play(c, { angle: -Math.PI / 2, power: 0.7 });
const p = { down: true, visualDownAt: c.visual.time };
c.pins.push(p);
const history = structuredClone(c.history), points = c.points;
r.update(c, 0.12);
assert.ok(Math.abs(pinFall(p, c.visual.time) - 0.5) < 1e-10);
r.update(c, 0.12);
assert.ok(Math.abs(pinFall(p, c.visual.time) - 1) < 1e-10);
assert.equal(c.step, 'settle');
assert.equal(c.points, points);
assert.deepEqual(c.history, history);
});
test('un birillo colpito appena prima dell’arresto finisce di cadere anche nel riepilogo finale', () => {
const c = clear('bona');
c.position = c.skill.positions;
// Urto reale negli ultimi centimetri di un tiro debole: il riepilogo arriva durante la caduta.
c.pins = [{ x: 80 + reach(speedFor(0)) + R + PIN - 0.01, y: 50, down: false }];
play(c, { angle: 0, power: 0 });
assert.equal(c.step, 'result');
const p = c.pins[0], before = structuredClone(c);
assert.ok(p.down && pinFall(p, c.visual.time) < 1);
r.update(c, 0.25);
assert.equal(pinFall(p, c.visual.time), 1);
before.visual.time = c.visual.time;
assert.deepEqual(c, before, 'avanza solo l’animazione, senza nuovi tiri o punti');
for (const dt of [0, -1, NaN, Infinity]) r.update(c, dt);
assert.deepEqual(c, before, 'tempi non validi non cambiano il riepilogo');
});
test('gloria alla soglia di LEVELS, sconfitta un punto sotto: la sfida dura sempre tutte le posizioni', () => {
for (const level of Object.keys(r.LEVELS)) for (const win of [true, false]) {
const c = r.newRinquarto(17, { level }), target = c.skill.glory - Number(!win);
for (let i = 0; i < c.skill.positions; i++) {
play(c, i < target ? c.solution : { angle: c.angle, power: 1 });
if (i < c.skill.positions - 1) assert.equal(c.result, undefined);
next(c);
}
assert.equal(c.history.length, c.skill.positions);
assert.equal(c.points, target);
assert.equal(c.result.winner, win ? 'player' : 'cpu');
assert.match(r.status(c), win ? /Gloria!/ : /Sfida persa/);
const final = structuredClone(c);
r.update(c, 10);
final.visual.time = c.visual.time;
assert.deepEqual(c, final, 'il riepilogo rimane visibile');
assert.equal(r.press(c), false);
}
});
test('guida lunga fino alla seconda sponda, corta fino a 65 cm, assente a fassi male', () => {
const c = clear('bona');
c.balls.cpu.x = 200; c.angle = -Math.PI / 2; c.power = 1;
assert.deepEqual(r.guide(c), predict(c).points.slice(0, 3));
assert.equal(r.guide(c).length, 3);
c.skill = r.LEVELS.seria;
const short = r.guide(c);
assert.ok(short.length <= 2);
assert.ok(Math.hypot(short[1][0] - short[0][0], short[1][1] - short[0][1]) <= 65);
c.skill = r.LEVELS.male;
assert.deepEqual(r.guide(c), []);
});
test('la guida si ferma al primo disturbo o all’arresto della biglia', () => {
const c = clear('bona');
c.angle = -Math.PI / 2; c.power = 1;
c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 };
let points = r.guide(c);
assert.equal(points.length, 2);
assert.ok(Math.abs(points[1][1] - (25 + 2 * R)) < 1e-7);
delete c.balls.extra;
c.angle = 0; c.power = 0;
points = r.guide(c);
assert.equal(points.length, 2);
assert.ok(points[1][0] > 80 && points[1][0] < 100, 'la guida non arriva alla sponda se manca forza');
assert.equal(points[1][1], 50);
});
test('comandi, suoni e fisica restano coerenti a frequenze diverse', () => {
const states = [];
for (const frames of [[1 / 30], [1 / 144], [0.008, 0.042, 0.09]]) {
const c = r.newRinquarto(42);
assert.ok(r.canAdjust(c));
r.press(c); r.update(c, 0.05); r.release(c);
assert.equal(c.step, 'aim'); assert.equal(c.power, 0.75);
r.press(c); r.update(c, 0.2);
assert.equal(r.aimScreen(c, [VIEW.x + 100, VIEW.y + 100]), false);
assert.equal(r.canAdjust(c), false);
r.cancel(c);
Object.assign(c, c.solution); r.shoot(c); roll(c, frames);
assert.ok(c.events.some(e => e.kind === 'biliardo-cue'));
assert.ok(c.events.some(e => e.kind === 'biliardo-rail'));
assert.ok(c.events.some(e => e.kind === 'biliardo-hit'));
states.push({ balls: c.balls, pins: c.pins.map(({ visualDownAt, ...p }) => p), points: c.points, attempt: c.attempt });
}
for (const state of states.slice(1)) assert.deepEqual(state, states[0]);
});
test('dialogo di Bertovo: si chiacchiera, lui lancia la sfida, poi tre livelli e No, primo piano e battute di uscita; lui sta al tavolo di mezzo del Circolo', () => {
assert.ok(MINIGAMES.rinquarto);
assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json');
// Solo al Circolo 4 Maggio, e parlandoci si apre il suo dialogo.
const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id);
assert.deepEqual(where, ['circolo_4_maggio']);
const circolo = new Game(story); circolo.go('circolo_4_maggio'); settle(circolo);
assert.ok(circolo.people().some(p => p.who === 'bertovo'));
circolo.act('talk', 'bertovo'); settle(circolo);
assert.ok(circolo.dialogue, 'parlandoci si sceglie il livello');
for (const [index, level] of ['bona', 'seria', 'male', null].entries()) {
const g = new Game(story); settle(g);
g.run({ dialogue: 'bertovo' });
// Prima si chiacchiera; la sfida la lancia lui, poi si sceglie il livello.
assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito');
g.choose(g.options().findIndex(o => o.text === 'Dai, fammi vedere un rinquarto.'));
assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?')));
assert.equal(g.options().length, 4);
g.choose(index); settle(g);
if (!level) { assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare'); continue; }
assert.equal(g.closeup.id, 'rinquarto');
assert.equal(g.closeup.level, level);
assert.ok(g.closeupPress());
g.update(0.2); assert.ok(g.closeupRelease());
assert.equal(g.closeup.step, 'rolling');
g.closeupClose();
assert.ok(settle(g).some(s => s.text === story.closeups.rinquarto.quit[0].say));
}
});
test('il riepilogo resta aperto e uscendo parte la battuta di vittoria o sconfitta', () => {
for (const win of [true, false]) {
const g = new Game(story); settle(g);
g.run({ closeup: 'rinquarto', level: 'bona' });
const c = g.closeup;
for (let i = 0; i < c.skill.positions; i++) {
play(c, win ? c.solution : { angle: c.angle, power: 1 });
next(c);
}
g.update(0.1);
assert.equal(g.closeup, c);
assert.equal(c.result.winner, win ? 'player' : 'cpu');
g.closeupClose();
assert.equal(g.closeup, null);
const line = story.closeups.rinquarto[win ? 'win' : 'lose'][0];
assert.ok(settle(g).some(s => s.text === line.say && s.who === 'bertovo'));
}
});
test('disegno senza sprite, con disturbi e riepilogo, senza modificare lo stato', () => {
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const previous = globalThis.document;
globalThis.document = { createElement: () => ({ getContext: () => ctx }) };
try {
for (const level of Object.keys(r.LEVELS)) {
const c = r.newRinquarto(42, { level }), bg = {};
const draw = () => {
const before = structuredClone(c);
drawRinquarto(ctx, c, story.closeups.rinquarto, bg);
assert.deepEqual(c, before);
};
draw();
r.shoot(c); r.update(c, FIXED_STEP); draw(); roll(c); draw();
c.step = 'result'; c.result = { winner: 'cpu' }; draw();
assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
}
} finally {
if (previous === undefined) delete globalThis.document; else globalThis.document = previous;
}
});