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!'); });