Files
leggenda-del-regolo/tests/browser.mjs
T

688 lines
46 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.
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
// npm run browser: le tre parti (--parte=principale, minigiochi, telefoni) girano insieme come processi separati, ognuna
// col suo server e il suo Chromium; la prova passa quasi tutto il tempo ad aspettare animazioni in tempo reale, e così le
// attese si sovrappongono. Una parte sola: node tests/browser.mjs --parte=minigiochi (--media-only: solo multimedia).
import { chromium } from 'playwright';
import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js';
import { story } from '../src/story/story.js';
import { reelPictures } from '../src/engine/game.js';
import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true });
const PARTI = ['principale', 'minigiochi', 'telefoni'];
const PARTE = process.argv.find(a => a.startsWith('--parte='))?.slice('--parte='.length);
if (PARTE && !PARTI.includes(PARTE)) { console.error(`parte sconosciuta: ${PARTE} (${PARTI.join(', ')})`); process.exit(2); }
if (!PARTE && !process.argv.includes('--media-only')) {
const { spawn } = await import('node:child_process');
const t0 = Date.now();
const esiti = await Promise.all(PARTI.map(parte => new Promise(resolve => {
const p = spawn(process.execPath, [process.argv[1], `--parte=${parte}`], { stdio: ['ignore', 'pipe', 'pipe'] });
let resto = '';
const riga = chunk => { const righe = (resto + chunk).split('\n'); resto = righe.pop(); for (const r of righe) console.log(`[${parte}] ${r}`); };
p.stdout.on('data', riga); p.stderr.on('data', riga);
p.on('close', code => { if (resto) console.log(`[${parte}] ${resto}`); resolve({ parte, code, s: Math.round((Date.now() - t0) / 1000) }); });
})));
for (const e of esiti) console.log(`${e.code === 0 ? 'OK ' : 'FAIL'} ${e.parte} (${e.s} s)`);
process.exit(esiti.every(e => e.code === 0) ? 0 : 1);
}
const server = await staticServer(), PORT = server.port;
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
async function open(viewport, query = '') {
const page = await browser.newPage({ viewport, acceptDownloads: true });
page.on('pageerror', e => errors.push(e.message));
// ERR_NETWORK_CHANGED è il PC che cambia rete a metà prova (Docker che crea o toglie una rete): non è il gioco.
page.on('console', m => m.type() === 'error' && !m.text().includes('ERR_NETWORK_CHANGED') && errors.push(m.text()));
for (let i = 0; i < 50; i++) {
try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
}
return page;
}
// Clic su un punto della scena (coordinate 640×360).
async function clickScene(page, [x, y]) {
const r = await page.locator('#scene').boundingBox();
await page.mouse.click(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
// Aspetta che il gioco sia fermo, saltando le battute con la barra spaziatrice.
async function settle(page) {
for (let i = 0; i < 400; i++) {
const s = await page.evaluate(() => ({ speech: !!game.speech, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.speech) await page.keyboard.press('Space');
else if (s.idle) return;
await page.waitForTimeout(50);
}
throw new Error('il gioco non si ferma');
}
const hotspotCenter = async (page, id) => {
const c = await page.evaluate(id => { const h = game.hotspot(id); return h && [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
if (!c) throw new Error(`non c'è ${id} in ${await page.evaluate(() => game.state.scene)}`);
return c;
};
// Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima).
async function minigiochi() {
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
{
const book = await open({ width: 1280, height: 860 });
await book.click('#splash');
await book.click('#new-game');
await book.click('#rione [data-rione="campomigliaio"]');
await book.click('#skip-intro');
await settle(book);
await book.evaluate(() => game.go('oratorio', [540, 300]));
await settle(book);
await book.click('#verbs [data-verb="take"]');
await clickScene(book, await hotspotCenter(book, 'libro_dimenticato'));
await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 });
await book.waitForTimeout(600);
await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` });
const where = await book.evaluate(() => [game.state.x, game.state.y]);
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro');
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina');
await book.waitForFunction(() => game.closeup?.step === 'end');
assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno');
// Prima la battuta, poi la schermata: con swiftshader una schermata può durare più della battuta.
await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 });
await book.screenshot({ path: `${OUT}/03b-libro-fine.png` });
assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea');
await book.keyboard.press('Space');
await book.keyboard.press('Escape');
assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro');
await book.waitForSelector('#closeup-close', { state: 'hidden' });
await settle(book);
await book.click('#verbs [data-verb="open"]');
await book.click('#inventory button');
await book.waitForFunction(() => game.closeup?.step === 'cover');
assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi');
await book.click('#closeup-close');
assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro');
await book.close();
}
// Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma.
{
const bocce = await open({ width: 1280, height: 860 });
await bocce.click('#splash');
await bocce.click('#new-game');
await bocce.click('#rione [data-rione="campomigliaio"]');
await bocce.click('#skip-intro');
await settle(bocce);
await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310]));
await settle(bocce);
await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
await bocce.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await bocce.locator('#choices li', { hasText: 'Seria.' }).click();
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await bocce.locator('#scene').boundingBox();
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
await bocce.mouse.down();
await bocce.waitForTimeout(500);
assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` });
await bocce.mouse.up();
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
await bocce.keyboard.press('Escape');
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
await bocce.close();
}
// «Fai due tiri» al campo del Circolo: il mouse mira, tenendo premuto la forza sale, lasciando la palla vola; Esc esce.
{
const k = await open({ width: 1280, height: 860 });
await k.click('#splash');
await k.click('#new-game');
await k.click('#rione [data-rione="campomigliaio"]');
await k.click('#skip-intro');
await settle(k);
await k.evaluate(() => game.go('via_cipriani', [340, 316]));
await settle(k);
await k.evaluate(() => game.act('use', 'campo_basket'));
await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await k.locator('#choices li', { hasText: 'Due tiri seri.' }).click();
await k.waitForFunction(() => game.closeup?.id === 'basket' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await k.locator('#scene').boundingBox();
const at = (x, y) => [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
await k.mouse.move(...at(560, 60));
const high = await k.evaluate(() => game.closeup.angle);
await k.mouse.move(...at(600, 200));
assert.ok(await k.evaluate(() => game.closeup.angle) < high, 'la mira segue il mouse');
await k.mouse.down();
await k.waitForTimeout(500);
assert.ok(await k.evaluate(() => game.closeup.power > 0.2 && game.closeup.holding), 'tenendo premuto la forza sale');
await k.screenshot({ path: `${OUT}/03e-basket-carica.png` });
await k.mouse.up();
assert.equal(await k.evaluate(() => game.closeup.step), 'flight', 'lasciando, la palla parte');
await k.waitForTimeout(350);
await k.screenshot({ path: `${OUT}/03f-basket-volo.png` });
await k.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.thrown === 1, null, { timeout: 20000 });
await k.keyboard.press('Escape');
await k.waitForFunction(() => game.speech?.text.startsWith('Lascio il pallone'), null, { timeout: 5000 });
await k.close();
}
// La pesca al lago dell'Anatre: coi ragazzi in Fortezza «S'ha andà a fallo?!?», poi il lancio tenendo premuto e l'attesa.
{
const k = await open({ width: 1280, height: 860 });
await k.click('#splash');
await k.click('#new-game');
await k.click('#rione [data-rione="campomigliaio"]');
await k.click('#skip-intro');
await settle(k);
await k.evaluate(() => game.go('fortezza', [240, 334]));
await settle(k);
await clickScene(k, await hotspotCenter(k, 'pescatori'));
await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 40000 });
await k.locator('#choices li', { hasText: 'S’ha andà a fallo?!?' }).or(k.locator('#choices li', { hasText: "S'ha andà a fallo?!?" })).first().click();
await k.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await k.locator('#choices li', { hasText: 'Seria.' }).click();
await k.waitForFunction(() => game.closeup?.id === 'pesca' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await k.locator('#scene').boundingBox();
await k.mouse.move(r.x + r.width * 0.7, r.y + r.height * 0.7);
assert.ok(await k.evaluate(() => game.closeup.power > 0.5), 'toccando l\'acqua al largo la forza sale');
await k.mouse.down();
await k.waitForTimeout(500);
assert.ok(await k.evaluate(() => game.closeup.holding && game.closeup.power > 0.2), 'tenendo premuto si carica il lancio');
await k.mouse.up();
await k.waitForFunction(() => game.closeup.step === 'wait', null, { timeout: 5000 });
await k.waitForTimeout(600);
await k.screenshot({ path: `${OUT}/03g-pesca-attesa.png` });
assert.equal(await k.textContent('#bocce-throw'), 'Ferra');
await k.keyboard.press('Escape');
await k.waitForFunction(() => game.speech?.text.startsWith('Lascio la canna'), null, { timeout: 5000 });
await k.close();
}
// Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale.
{
const b = await open({ width: 1280, height: 860 });
await b.click('#splash');
await b.click('#new-game');
await b.click('#rione [data-rione="campomigliaio"]');
await b.click('#skip-intro');
await settle(b);
await b.evaluate(() => game.go('circolo_4_maggio'));
await settle(b);
await b.waitForTimeout(1500); // la dissolvenza del cambio scena
await b.screenshot({ path: `${OUT}/03d-circolo.png` });
await clickScene(b, await hotspotCenter(b, 'ing_tozzi'));
await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await b.locator('#choices li', { hasText: 'Seria.' }).click();
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await b.locator('#scene').boundingBox();
const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
await b.mouse.down();
await b.waitForTimeout(500);
assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` });
await b.mouse.up();
assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte');
await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 });
await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` });
await b.keyboard.press('Escape');
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
await b.close();
}
}
async function telefoni() {
// La finestra di Andrea (1040×590: Firefox con le sue barre su un portatile): la scena era larga 604 px con 100 px
// vuoti sotto, ora prende l'altezza; i verbi stanno nei pulsanti; un dialogo a quattro risposte e uno a cinque
// non muovono la scena e stanno nel pannello.
{
const win = await open({ width: 1040, height: 590 });
await win.click('#splash'); await win.click('#new-game'); await win.click('#rione [data-rione="campomigliaio"]'); await win.click('#skip-intro');
await settle(win);
const box = async () => { const r = await win.locator('#stage').boundingBox(); return [r.x, r.y, r.width, r.height].map(Math.round); };
const b0 = await box();
assert.ok(b0[2] >= 820 && b0[1] >= 0, `a 1040×590 la scena è larga ${b0[2]} px e comincia a ${b0[1]}`);
assert.ok(await win.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre a 1040×590');
assert.ok(await win.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1040×590');
for (const id of ['giocatore_carte', 'arcu']) {
await win.evaluate(id => { game.dialogue = { id, node: 'start' }; }, id);
await win.waitForFunction(() => !document.getElementById('choices').hidden);
const n = await win.evaluate(() => game.options().length);
assert.deepEqual(await box(), b0, `la scena si muove col dialogo ${id}`);
assert.ok(await win.evaluate(() => { const c = document.getElementById('choices'); return c.scrollHeight <= c.clientHeight + 1; }), `le ${n} risposte di ${id} non stanno nel pannello`);
if (id === 'arcu') await win.screenshot({ path: `${OUT}/11-finestra-1040-dialogo.png` });
await win.evaluate(() => { game.dialogue = null; });
}
await win.screenshot({ path: `${OUT}/11-finestra-1040.png` });
await win.close();
}
const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.waitForSelector('#title:not([hidden])', { timeout: 5000 }).then(() => true, () => false), 'il clic sul cartello porta al titolo');
await phone.click('#new-game');
await phone.click('#rione [data-rione="campomigliaio"]');
await phone.click('#skip-intro');
await settle(phone);
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
// Ogni verbo si vede tutto: niente pulsanti tagliati dall'inventario.
assert.ok(await phone.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().right <= innerWidth)), 'verbi tagliati sul telefono');
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
// Telefono stretto in orizzontale: niente deve allargare la pagina oltre lo schermo (il browser la rimpicciolirebbe
// e taglierebbe verbi e inventario a destra), e la pausa sta tutta dentro la scena.
const small = await browser.newPage({ viewport: { width: 740, height: 330 }, deviceScaleFactor: 2.4, hasTouch: true, isMobile: true });
small.on('pageerror', e => errors.push(e.message));
await small.goto(`http://127.0.0.1:${PORT}/`);
await small.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
await small.click('#new-game');
await small.click('#rione [data-rione="campomigliaio"]');
await small.click('#skip-intro');
await settle(small);
assert.equal(await small.evaluate(() => innerWidth), 740, 'la pagina è più larga dello schermo del telefono');
await small.click('#pause-button');
assert.ok(await small.evaluate(() => document.getElementById('to-title').getBoundingClientRect().bottom <= document.getElementById('stage').getBoundingClientRect().bottom), 'la pausa esce dalla scena');
await small.screenshot({ path: `${OUT}/08b-telefono-stretto-pausa.png` });
// Telefono in verticale: la Pausa prende tutto lo schermo, e versione e spiegazione non finiscono sopra i verbi.
{
const tall = await browser.newPage({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true });
await tall.goto(`http://127.0.0.1:${PORT}/`);
await tall.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
await tall.click('#new-game');
await tall.click('#rione [data-rione="campomigliaio"]');
await tall.click('#skip-intro');
await settle(tall);
await tall.click('#pause-button');
const fit = await tall.evaluate(() => {
const p = document.getElementById('pause'), v = p.querySelector('.version').getBoundingClientRect();
return { inside: p.scrollHeight <= p.clientHeight + 1, top: document.elementFromPoint(v.x + v.width / 2, v.y + v.height / 2)?.closest('#pause') !== null };
});
assert.deepEqual(fit, { inside: true, top: true }, 'in verticale la pausa esce dallo schermo o finisce sotto i verbi');
await tall.close();
}
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await landscape.click('#rione [data-rione="campomigliaio"]');
await landscape.click('#skip-intro');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
assert.ok(await landscape.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().height >= 26)), 'in orizzontale i verbi si leggono interi e il dito li prende');
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
// Debug (?debug): area calpestabile e hotspot disegnati; Maiusc+clic segna punti senza muovere Andrea, X li azzera.
const dbg = await open({ width: 1280, height: 860 }, '?debug');
await dbg.click('#new-game');
await dbg.click('#rione [data-rione="campomigliaio"]');
await dbg.click('#skip-intro');
await settle(dbg);
const start = await dbg.evaluate(() => [game.state.x, game.state.y]);
await dbg.keyboard.down('Shift');
await clickScene(dbg, [100, 300]);
await clickScene(dbg, [200, 320]);
await dbg.keyboard.up('Shift');
await dbg.mouse.move(700, 400);
await dbg.waitForTimeout(200);
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
await dbg.screenshot({ path: `${OUT}/10-debug.png` });
}
try {
if (process.argv.includes('--media-only')) await checkMultimedia(browser, PORT, OUT, errors);
else if (PARTE === 'minigiochi') { await minigiochi(); console.log('Browser OK: libro, bocce, tiri, pesca, biliardo.'); }
else if (PARTE === 'telefoni') { await checkMultimedia(browser, PORT, OUT, errors); await telefoni(); console.log('Browser OK: telefoni e debug.'); }
else {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
await page.waitForTimeout(2800);
await page.screenshot({ path: `${OUT}/00-cartello.png` });
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
{
const muto = await chromium.launch(), p = await muto.newPage();
await p.goto(`http://127.0.0.1:${PORT}/`);
await p.waitForTimeout(800);
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
await p.waitForTimeout(5500);
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
await p.click('#splash');
assert.ok(await p.isHidden('#splash .splash-hint'));
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
await muto.close();
}
await page.screenshot({ path: `${OUT}/01-titolo.png` });
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#splash');
await slow.waitForTimeout(300);
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close();
}
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
await page.click('#title .open-credits');
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
await page.click('#credits .credit-face >> text=Mariano');
assert.ok(await page.isVisible('#face img'));
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
await page.click('#face');
assert.ok(await page.isHidden('#face'));
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
// foto, un tocco su una foto la mostra grande col nome del posto.
{
const track = page.locator('#credits-reel .track');
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
await page.mouse.move(5, 5);
const x0 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(600);
const x1 = await track.evaluate(t => t.scrollLeft);
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
await track.hover({ position: { x: 20, y: 20 } });
const x2 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(500);
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
await page.click('#credits-reel .arrow.next');
await page.waitForTimeout(800);
const x3 = await track.evaluate(t => t.scrollLeft);
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
const third = track.locator('img').nth(2);
const name = await third.getAttribute('alt');
await third.click();
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
assert.equal(await page.textContent('#face figcaption'), name);
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
assert.ok(await page.isVisible('#credits'));
}
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
await page.click('#new-game');
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
await page.waitForSelector('#rione:not([hidden])');
assert.equal(await page.locator('#rione-buttons button').count(), 4);
await page.screenshot({ path: `${OUT}/01a-rione.png` });
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
await page.click('#rione [data-rione="campomigliaio"]');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
await page.screenshot({ path: `${OUT}/01b-intro.png` });
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
await page.waitForFunction(() => game.speech);
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
await page.screenshot({ path: `${OUT}/02-inizio.png` });
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
await settle(page);
// Doppio clic su un punto della scena: Andrea ci va di corsa.
const r = await page.locator('#scene').boundingBox();
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
await settle(page);
// «?» mostra per qualche secondo cosa si può toccare.
await page.click('#spots-button');
await page.screenshot({ path: `${OUT}/02c-punti.png` });
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
await page.evaluate(() => {
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
});
await page.waitForTimeout(150);
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
// Il gioco prende la finestra: a 1280×860 la scena non si ferma più ai 1084 px di una riserva fissa di 250 px sotto,
// la pagina non scorre e i verbi stanno interi nei pulsanti (la finestra di Andrea, 1040×590, è provata più sotto).
const stage0 = await page.locator('#stage').boundingBox();
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
assert.equal(await page.evaluate(() => document.getElementById('fullscreen').hidden), await page.evaluate(() => !document.fullscreenEnabled), 'il pulsante Schermo intero c\'è se il browser lo permette');
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
const savedFile = `${OUT}/partita.json`;
await download.saveAs(savedFile);
const before = await page.evaluate(() => game.snapshot());
await page.click('#to-title');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
await page.waitForSelector('#load-error:not([hidden])');
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
lost.state.scene = 'luna';
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
assert.ok(await page.isVisible('#title'));
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', savedFile);
await page.waitForSelector('#title', { state: 'hidden' });
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.click('#resume');
assert.ok(await page.isHidden('#pause'));
await page.click('#pause-button');
await page.click('#to-title');
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
await settle(page);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
await page.click('#mute');
await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false);
await page.click('#resume');
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) {
await page.click(`#verbs [data-verb="${verb}"]`);
await page.click(`#inventory [data-item="${item}"]`);
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
else await clickScene(page, await hotspotCenter(page, target));
}
await page.waitForTimeout(80);
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) {
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
}
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
await page.click('#ending .open-credits');
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
await page.click('#close-credits');
assert.ok(await page.isHidden('#credits'));
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const [, exit] of route('piazza', 'trebbio')) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
await page.click('#verbs [data-verb="talk"]');
await clickScene(page, await hotspotCenter(page, 'torre'));
await page.locator('#choices li', { hasText: 'bloccato' }).click();
await page.waitForFunction(() => game.speech?.who === 'castello');
await page.waitForTimeout(100);
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
await settle(page);
await page.locator('#choices li', { hasText: 'pace' }).click();
await settle(page);
await page.reload();
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
await settle(page);
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
await page.keyboard.press('n');
assert.ok(await page.isVisible('#notebook'));
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#notebook'));
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
await page.click('#map-button');
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
await page.fill('#map-search', 'xyz');
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
await page.fill('#map-search', '');
await page.click('#map-search');
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
assert.ok(await page.isHidden('#map-none'));
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
assert.deepEqual(shown, ['piazza']);
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
await page.keyboard.press('Enter');
assert.ok(await page.isHidden('#map'));
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
await page.setViewportSize({ width: 560, height: 340 });
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
await page.click('#map-button');
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
await page.fill('#map-search', 'campo');
const found = await page.evaluate(() => {
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
});
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
await page.click('#close-map');
await page.setViewportSize({ width: 1280, height: 860 });
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio. Schermate in ${OUT}`);
}
assert.deepEqual(errors, [], 'errori nella console');
} finally {
await browser.close();
server.kill();
}