// 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 import { chromium } from 'playwright'; import { spawn } from 'node:child_process'; import { mkdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { solution } from '../src/story/solution.js'; const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900); mkdirSync(OUT, { recursive: true }); const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' }); const browser = await chromium.launch({ args: ['--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)); page.on('console', m => m.type() === 'error' && 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; }; try { 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'); assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3$/, '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 }); await page.screenshot({ path: `${OUT}/01-titolo.png` }); // I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude. await page.click('#title .open-credits'); assert.match(await page.textContent('#credits'), /Crediti\s*Al Lele/, 'la dedica al Lele viene per prima'); assert.match(await page.textContent('#credits'), /Yuri Baldi/); assert.match(await page.textContent('#credits'), /Francesco Bacci/); 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$/); 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]; }); // 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` }); // 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 }); 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.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.state.flags.occhi_regolo, null, { timeout: 30000 }); await page.screenshot({ path: `${OUT}/05b-occhi.png` }); } 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 ['verso_villa_adami', 'su_villa_adami', 'salita', 'su_fortezza', 'sentiero_trebbio', 'ai_cipressi', 'al_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'); // La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono. await settle(page); 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('piaz'); 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'); const phone = await open({ width: 390, height: 844 }); await phone.click('#splash'); // un clic salta il cartello assert.ok(await phone.isVisible('#title'), '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` }); 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` }); assert.deepEqual(errors, [], 'errori nella console'); console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); } finally { await browser.close(); server.kill(); }