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

334 lines
20 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 { SIMPLE, simple, simpleLimit, mustLift, PREAVVISO, SWIPE, setPower, newGara, update, press, presenting, INTRO, release, cancel, shoot, key, steerButton, brakeButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js';
import { piloti } from '../src/story/piloti.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
// Gli eventi sonori di ogni gara si ricordano nella gara stessa (`c.heard`, «tipo» e «tipo:pilota»): un test non
// vede quelli degli altri.
const run = (c, seconds, drive, dt = 1 / 60) => {
c.heard ??= new Set();
for (let t = 0; t < seconds && c.step !== 'result'; t += dt) {
if (drive && c.step === 'race') c.holding = drive(c);
update(c, dt); for (const e of c.events.splice(0)) { c.heard.add(e.kind); c.heard.add(`${e.kind}:${e.who}`); }
}
return c.heard;
};
const skip = c => { while (c.step === 'intro') press(c); return c; };
// Lo sterzo col dito insegue quello voluto: per leggerlo si lascia assestare.
const settled = c => { for (let i = 0; i < 400; i++) steering(c); return steering(c); };
const race = c => { skip(c); assert.equal(c.step, 'start'); run(c, COUNTDOWN + 0.01); assert.equal(c.step, 'race'); return c; };
// Un dito al centro della scena indica la direzione del punto davanti al motorino.
const swipeTo = (c, target) => {
const r = player(c), dx = target.x - r.x, dy = target.y - r.y, length = Math.hypot(dx, dy);
if (c.pointer === null) { aimScreen(c, [320, 180], 'bot'); press(c, 'bot'); }
aimScreen(c, [c.anchor[0] + dx / length * 50, c.anchor[1] + dy / length * 50], 'bot');
};
// Il pilota di giudizio: guarda avanti e ci sterza (scansando buche e chi gli sta davanti), e dà gas finché,
// frenando, arriverebbe a ogni curva un po' sotto il limite. Usa i comandi del giocatore (il trascinamento).
const careful = c => {
const r = player(c), here = ((r.s % TRACK.length) + TRACK.length) % TRACK.length;
const next = PICKUPS.find(p => p.kind !== 'boost' && p.s - here > -8 && p.s - here < 60);
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 26 && Math.abs(o.lat - r.lat) < 7);
const lane = next?.kind === 'buca' ? (next.lane > 0 ? -6 : 6) : slow ? (slow.lat > 0 ? -6 : 6) : next ? next.lane : 0;
swipeTo(c, pointAt(r.s + 14 + r.v * 0.18, lane));
const horizon = r.v * r.v / (2 * BRAKE) + 20;
let brake = false;
for (let d = 0; d <= horizon; d += 4) { const a = limitAt(r.s + d) * 0.85; if (r.v * r.v > a * a + 2 * BRAKE * d) { brake = true; break; } }
key(c, 'ArrowDown', brake);
return c.holding;
};
test('i piloti sono dati: motore da 1 a 6 e testa da 1 a 5 danno velocità, accelerazione e prudenza', () => {
const slow = rider({ motore: 1, testa: 5 }), fast = rider({ motore: 5, testa: 1 });
assert.ok(fast.vmax > slow.vmax && fast.accel > slow.accel && fast.grip < slow.grip);
assert.ok(fast.slip > slow.slip && fast.caution > slow.caution);
assert.deepEqual(rider({ motore: 9, testa: -2 }).motore, 6, 'fuori scala si ferma ai bordi (6, il truccatissimo)');
assert.equal(rider({ motore: 9, testa: -2 }).testa, 1);
// Ogni pilota della storia ha nome, motorino e colore; Andrea c'è una volta sola.
for (const p of piloti) { assert.ok(p.id && p.name && p.bike && /^#[0-9a-f]{6}$/i.test(p.color), p.id); }
assert.equal(piloti.filter(p => p.id === 'andrea').length, 1);
assert.throws(() => newGara(1, { riders: [{ id: 'arcu', name: 'L\'Arcu' }] }), /manca Andrea/);
const c = newGara(1, { riders: piloti });
assert.equal(c.riders.length, piloti.length);
assert.deepEqual(c.riders.map(r => r.s), piloti.map((_, k) => -16 - 14 * Math.floor(k / 2)), 'due per fila, dietro il traguardo');
assert.deepEqual(c.riders.map(r => Math.sign(r.lat)), piloti.map((_, k) => k % 2 ? 1 : -1));
});
test('il semaforo, poi il gas: tenendo premuto si va, lasciando si rallenta, il freno vince sul gas', () => {
const c = newGara(3, { level: 'seria', riders: RIDERS, simple: false });
for (const r of RIDERS) assert.ok(press(c, 7), r.id);
assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita');
assert.equal(c.holding, false, 'senza dare gas');
assert.ok(press(c, 7)); assert.equal(c.holding, true);
const start = player(c).s;
const heard = run(c, COUNTDOWN - 0.1);
assert.equal(c.step, 'start'); assert.equal(player(c).s, start, 'prima del via non si muove nessuno');
run(c, 0.15);
assert.equal(c.step, 'race'); assert.ok(heard.has('gara-semaforo') && heard.has('gara-via'));
run(c, 1);
const v = player(c).v; assert.ok(v > 20);
release(c, 7); run(c, 0.2);
assert.ok(player(c).v < v, 'senza gas rallenta');
assert.match(status(c), /Giro 1 di 3/); assert.equal(button(c), 'Gas');
// Gas e freno insieme: frena (P1 della revisione di Codex).
key(c, 'ArrowUp', true); run(c, 0.5); const fast = player(c).v;
key(c, 'ArrowDown', true); run(c, 0.1);
assert.ok(c.holding && c.braking); assert.ok(player(c).v < fast - 10, 'col gas tenuto il freno frena');
key(c, 'ArrowDown', false); key(c, 'ArrowUp', false);
shoot(c); assert.equal(c.holding, true); assert.equal(button(c), 'Molla');
shoot(c); assert.equal(c.holding, false);
});
test('le fonti del gas sono indipendenti: lasciare un tasto non toglie quello del dito o degli altri tasti', () => {
const c = race(newGara(3, { riders: RIDERS }));
key(c, ' ', true); key(c, 'ArrowUp', true); key(c, 'ArrowUp', false);
assert.equal(c.holding, true, 'Spazio ancora giù');
press(c, 1); key(c, ' ', false);
assert.equal(c.holding, true, 'il dito ancora giù');
assert.equal(press(c, 2), false, 'un secondo dito sulla scena non conta');
release(c, 2); assert.equal(c.holding, true, 'e lasciandolo non toglie il gas al primo');
release(c, 1); assert.equal(c.holding, false);
shoot(c); key(c, ' ', true); key(c, ' ', false); assert.equal(c.holding, true, 'il pulsante Gas resta acceso');
});
test('lo sterzo: frecce, pulsanti e il dito che trascina; il mouse che passa senza premere non sterza', () => {
const c = race(newGara(3, { riders: RIDERS })); setPower(c, 1);
assert.ok(key(c, 'ArrowLeft', true)); assert.equal(settled(c), -1);
key(c, 'ArrowLeft', false); assert.equal(settled(c), 0);
steerButton(c, 1); assert.equal(settled(c), 1); steerButton(c, 0);
// Gas da tastiera e sinistra, il mouse si muove a destra senza premere: lo sterzo resta a sinistra (P1).
key(c, 'ArrowUp', true); key(c, 'ArrowLeft', true);
aimScreen(c, [300, 100], 5); aimScreen(c, [360, 100], 5);
assert.equal(settled(c), -1);
key(c, 'ArrowUp', false); key(c, 'ArrowLeft', false);
// Il dito indica una direzione; un altro puntatore non sposta niente.
player(c).a = -0.5 / SWIPE.hard;
aimScreen(c, [300, 100], 9); press(c, 9);
aimScreen(c, [314, 100], 9); assert.ok(Math.abs(settled(c) - 0.5) < 1e-6);
aimScreen(c, [250, 100], 4); assert.ok(Math.abs(settled(c) - 0.5) < 1e-6);
release(c, 9); assert.equal(settled(c), 0, 'lasciando il dito lo sterzo torna dritto');
const a = player(c).a; key(c, 'ArrowUp', true); key(c, 'ArrowRight', true); run(c, 0.4); assert.notEqual(player(c).a, a, 'sterzando si gira');
assert.equal(key(c, 'x', true), false, 'gli altri tasti non sono suoi');
});
test('lo swipe segue lo schermo: scendendo, destra sterza negativo; avanti e piccoli movimenti vanno dritto', () => {
const c = race(newGara(3)), r = player(c); setPower(c, 1);
r.a = Math.PI / 2;
aimScreen(c, [320, 180], 9); press(c, 9);
aimScreen(c, [370, 180], 9);
assert.equal(settled(c), -1, 'scendendo, la destra dello schermo è a sinistra del motorino');
r.a = 0;
assert.ok(Math.abs(settled(c)) < 1e-6, 'col dito fermo lo sterzo si annulla quando il motorino è allineato');
r.a = Math.PI / 2;
aimScreen(c, [320, 230], 9); assert.ok(Math.abs(settled(c)) < 1e-6, 'swipe in avanti');
aimScreen(c, [323, 184], 9); assert.equal(settled(c), 0, '5 px non fanno tremare lo sterzo');
aimScreen(c, [330, 230], 9);
assert.ok(Math.abs(settled(c) + Math.atan2(10, 50) * SWIPE.hard) < 1e-6, 'sterzo proporzionale all’angolo');
r.a = -Math.PI + 0.1;
aimScreen(c, [270, 180], 9);
assert.ok(Math.abs(settled(c) + 0.1 * SWIPE.hard) < 1e-6, 'si gira dalla parte più breve anche passando da π a -π');
release(c, 9);
assert.equal(c.holding, false); assert.equal(settled(c), 0);
assert.equal(c.anchor, null); assert.equal(c.finger, null);
});
test('l’origine segue gli swipe lunghi e si può correggere; rilascio e annullamento puliscono il dito', () => {
const c = race(newGara(3)); setPower(c, 1);
player(c).a = 0;
aimScreen(c, [100, 100], 9); press(c, 9);
for (const x of [200, 300, 400]) {
aimScreen(c, [x, 100], 9);
assert.deepEqual(c.anchor, [x - 60, 100]);
assert.ok(Math.abs(settled(c)) < 1e-6);
}
aimScreen(c, [400, 160], 9);
assert.ok(Math.abs(Math.hypot(c.finger[0] - c.anchor[0], c.finger[1] - c.anchor[1]) - 60) < 1e-6);
assert.equal(settled(c), 1, 'si corregge verso il basso senza arrivare al bordo');
key(c, 'ArrowLeft', true); shoot(c); brakeButton(c, true);
release(c, 9);
assert.equal(settled(c), -1, 'lasciare il dito conserva lo sterzo della tastiera');
assert.ok(c.holding && c.braking, 'Gas e Frena del pannello restano attivi');
key(c, 'ArrowDown', true); brakeButton(c, false);
assert.ok(c.braking, 'il freno della tastiera resta attivo');
aimScreen(c, [200, 200], 10); press(c, 10);
assert.equal(c.drag, 0, 'il nuovo tocco non eredita lo swipe');
cancel(c);
assert.equal(c.holding, false); assert.equal(c.braking, false); assert.equal(settled(c), 0);
assert.equal(c.anchor, null); assert.equal(c.finger, null);
});
test('alla bona si completano tre giri usando solo un dito, senza tasti o pulsanti', () => {
const c = race(newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false }));
aimScreen(c, [320, 180], 'bot'); press(c, 'bot');
for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) {
swipeTo(c, pointAt(player(c).s + 50));
assert.ok(c.holding && c.pointer === 'bot');
assert.equal(c.keys.gas.size, 0); assert.equal(c.toggle, false);
assert.equal(c.keys.left || c.keys.right || c.braking || !!c.buttons, false);
update(c, 1 / 60);
}
assert.equal(c.step, 'result');
assert.equal(player(c).laps, LAPS);
assert.ok(player(c).finished > 0);
});
test('la presentazione: uno alla volta arrivano in griglia col motorino, poi il semaforo', () => {
const c = newGara(2, { riders: piloti });
assert.equal(c.step, 'intro'); assert.equal(presenting(c).id, piloti[0].id);
assert.ok(c.riders.every(r => !r.shown), 'all\'inizio la griglia è vuota');
assert.match(status(c), new RegExp(`${piloti[0].name} con ${piloti[0].bike}`));
run(c, INTRO.each + INTRO.ride / 2);
assert.ok(c.riders[0].shown && c.riders[1].shown && !c.riders[2].shown);
assert.equal(c.riders[0].s, c.riders[0].grid, 'il primo è già al suo posto');
assert.ok(c.riders[1].s < c.riders[1].grid, 'il secondo sta arrivando');
assert.equal(presenting(c).id, piloti[1].id);
assert.ok(c.heard.has('gara-motore:' + piloti[0].id) && c.heard.has('gara-motore:' + piloti[1].id));
run(c, piloti.length * INTRO.each + INTRO.after - (INTRO.each + INTRO.ride / 2) + 0.05);
assert.equal(c.step, 'start');
assert.ok(c.riders.every(r => r.shown && r.s === r.grid && r.v === 0), 'tutti fermi in griglia');
// Un tocco: chi arriva è subito al suo posto, e tocca al prossimo.
const d = newGara(2, { riders: piloti });
run(d, 0.3); assert.ok(d.riders[0].s < d.riders[0].grid);
assert.ok(press(d)); run(d, 0.01);
assert.equal(presenting(d).id, piloti[1].id); assert.equal(d.riders[0].s, d.riders[0].grid);
assert.equal(d.step, 'intro'); assert.equal(button(d), 'Avanti');
});
test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => {
const wild = newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(wild);
run(wild, COUNTDOWN + 60, () => true);
assert.ok(wild.heard.has('gara-fuori:andrea'));
assert.ok(Math.abs(player(wild).lat) <= VERGE, 'la paglia rimanda in strada');
assert.equal(wild.step, 'race', 'dritto non si arriva');
const heard = new Set();
for (const seed of [1, 2, 3, 4]) {
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(c); setPower(c, 1); // a sensibilità piena: il dito risponde subito
for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k);
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`);
assert.equal(c.result.order[0], 'andrea'); assert.match(status(c), /Primo al GP di Fortezza!/);
assert.equal(Object.keys(c.result.times).length, piloti.filter(p => p.consenso !== null).length);
}
assert.ok(['gara-giro:andrea', 'gara-arrivo:andrea', 'gara-turbo:andrea', 'gara-miscela:andrea'].every(k => heard.has(k)), [...heard].join());
assert.ok(CURVES.length >= 2 && TRACK.length > 600, 'una pista con delle curve vere');
});
test('alla sensibilità di partenza (bassa) lo stesso pilota non finisce mai nella paglia', () => {
for (const seed of [1, 2, 3]) {
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(c); assert.equal(c.sens, SWIPE.sens);
run(c, COUNTDOWN + 120, careful);
assert.equal(c.step, 'result'); assert.equal(c.result.offs, 0, `seme ${seed}`);
}
});
test('le buche: piano si sobbalza, forte si casca; la lattina di miscela dà lo sprint', () => {
const hole = PICKUPS.find(p => p.kind === 'buca'), can = PICKUPS.find(p => p.kind === 'miscela');
for (const [speed, fell] of [[20, false], [80, true]]) {
const c = newGara(1, { riders: RIDERS }), r = player(c);
c.step = 'race'; Object.assign(r, { s: hole.s - 4, idx: Math.floor(hole.s / 2), v: speed, a: hole.heading });
const p = pointAt(hole.s - 4, hole.lane); r.x = p.x; r.y = p.y;
const heard = run(c, 0.2);
assert.equal(r.falls, fell ? 1 : 0, `a ${speed}`);
assert.ok(heard.has(fell ? 'gara-buca:andrea' : 'gara-sobbalzo:andrea'));
}
const c = newGara(1, { riders: RIDERS }), r = player(c);
c.step = 'race'; Object.assign(r, { s: can.s - 3, idx: Math.floor(can.s / 2), v: 40, a: can.heading });
const p = pointAt(can.s - 3, can.lane); r.x = p.x; r.y = p.y;
run(c, 0.2);
assert.ok(r.boost > 0 && c.taken[can.id] !== undefined, 'la miscela dà lo sprint');
});
test('la classifica: chi è arrivato davanti, poi per strada fatta', () => {
const c = newGara(1, { riders: piloti });
c.riders[0].s = 50; c.riders[1].s = 80; c.riders[2].finished = 30;
assert.deepEqual(standings(c).slice(0, 3).map(r => r.id), [c.riders[2].id, c.riders[1].id, c.riders[0].id]);
});
test('nella storia: dal Trappola l\'Arcu scherza sul Betino, poi la sfida; prima del via prende la rincorsa; la vittoria lascia il flag', () => {
const g = new Game(story);
g.go('via_liguri_magelli', [352, 256]); settle(g);
g.act('talk', 'arcu'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('Ma ti ho visto du')));
const joke = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`);
assert.ok(joke.includes('arcu: In salita si spinge.'));
g.choose(g.options().findIndex(o => o.text.startsWith('Si fa una gara coi motorini?'))); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Seria.'));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('prendo la rincorsa')));
assert.equal(g.closeup?.id, 'gara'); assert.equal(g.closeup.level, 'seria');
assert.deepEqual(g.closeup.riders.map(r => r.id), piloti.filter(p => p.consenso !== null).map(p => p.id));
assert.ok(!g.closeup.riders.some(r => r.consenso === null), 'senza consenso non corre');
g.closeup.result = { winner: 'player', place: 1, times: {}, order: ['andrea'], offs: 0 }; g.closeup.step = 'result';
g.closeupClose(); const after = settle(g).map(s => s.text);
assert.ok(g.state.flags.gara_vinta);
assert.ok(after.some(t => t.includes('Primo!')));
// Il Betino, lassù: solo Andrea ne parla.
g.act('use', 'betino'); assert.ok(settle(g).every(s => s.who === 'andrea'));
});
// La guida semplice (Andrea, 29 ottobre: dal telefono lo sterzo era ingiocabile): alla bona e seria si tiene premuto
// per il gas e lasciando si frena, lo sterzo va da solo; troppo forte in curva si scivola fuori. Ginocchio in terra no.
test('guida semplice: alla bona e seria sì, a ginocchio in terra no; lasciando si frena e lo sterzo va da solo', () => {
const riders = piloti.filter(p => p.consenso !== null);
assert.equal(simple(newGara(1, { level: 'bona', riders })), true);
assert.equal(simple(newGara(1, { level: 'seria', riders })), true);
assert.equal(simple(newGara(1, { level: 'male', riders })), false);
const c = race(newGara(1, { level: 'bona', riders }));
run(c, 2, () => true);
const v = player(c).v; assert.ok(v > 40);
run(c, 0.3, () => false);
assert.ok(player(c).v < v - BRAKE * 0.3 + 5, 'lasciando frena, non solo rallenta');
assert.match(status(c), /Freno\./);
// Le frecce non sterzano: lo sterzo segue la strada.
key(c, 'ArrowLeft', true); run(c, 2, () => true); key(c, 'ArrowLeft', false);
assert.ok(Math.abs(player(c).lat) < 9, 'resta in strada');
});
test('guida semplice: sempre a tutto gas si finisce nella paglia; mollando prima delle curve no, e alla bona si vince', () => {
const riders = piloti.filter(p => p.consenso !== null);
// Molla quando, frenando da qui, arriverebbe a una curva sopra il limite della guida semplice.
const wise = c => { const r = player(c), h = r.v * r.v / (2 * BRAKE) + 10; for (let d = 0; d <= h; d += 4) { const a = simpleLimit(r, r.s + d); if (r.v * r.v > a * a + 2 * BRAKE * d) return false; } return true; };
for (const seed of [1, 2]) {
const wild = race(newGara(seed, { level: 'bona', riders }));
run(wild, 120, () => true);
assert.ok(wild.result.offs > 0, 'a tutto gas si esce');
const c = race(newGara(seed, { level: 'bona', riders }));
run(c, 120, wise);
assert.equal(c.result.offs, 0); assert.equal(c.result.falls, 0, 'le buche si scansano');
assert.equal(c.result.winner, 'player', `seme ${seed}`);
}
assert.ok(SIMPLE.slide > 0);
});
test('Seria semplice: chi non rallenta in curva va fuori e perde, chi frena giusto fa le curve pulite e vince; LASCIA! dice quando', () => {
const riders = piloti.filter(p => p.consenso !== null);
const wise = c => { const r = player(c), h = r.v * r.v / (2 * BRAKE) + 10; for (let d = 0; d <= h; d += 4) { const a = simpleLimit(r, r.s + d, c.tune); if (r.v * r.v > a * a + 2 * BRAKE * d) return false; } return true; };
for (const seed of [1, 2]) {
const wild = race(newGara(seed, { level: 'seria', riders }));
run(wild, 120, () => true);
const good = race(newGara(seed, { level: 'seria', riders }));
run(good, 120, wise);
assert.ok(wild.result.offs >= 4, `a tutto gas fuori in quasi ogni curva (${wild.result.offs})`);
assert.ok(wild.result.place >= 3);
assert.equal(good.result.offs, 0); assert.equal(good.result.winner, 'player', `seme ${seed}`);
assert.ok(player(good).cleans >= 5, 'le curve pulite');
assert.ok(wild.result.times.andrea - good.result.times.andrea > 6, 'tutto gas costa più di 6 secondi');
}
// Chi segue LASCIA! guida come chi frena giusto; CURVA! arriva prima.
const c = race(newGara(1, { level: 'seria', riders }));
let early = 0, said = false;
for (let t = 0; t < 20; t += 1 / 60) {
c.holding = !mustLift(c);
if (mustLift(c, PREAVVISO) && !mustLift(c)) early++;
update(c, 1 / 60);
if (c.holding && mustLift(c)) said ||= /Lascia!/.test(status(c));
}
assert.ok(early > 20, `il preavviso CURVA! viene prima di LASCIA! (${early} fotogrammi)`);
assert.ok(said, 'il pannello dice Lascia!');
});