import test from 'node:test'; import assert from 'node:assert/strict'; import { backgroundOrder } from '../src/engine/render.js'; import { story } from '../src/story/story.js'; import { solution } from '../src/story/solution.js'; import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, POSTER_READY } from '../src/engine/game.js'; import { inside } from '../src/engine/geometry.js'; import { existsSync, readFileSync } from 'node:fs'; import { settle, exploreTorch } from './helpers.mjs'; const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup']; // Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`. function* effectLists() { for (const [id, c] of Object.entries(story.closeups ?? {})) { for (const k of ['comment', 'closing', 'complete', 'win', 'lose', 'draw', 'quit']) yield [`${id}.${k}`, c[k] ?? []]; for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []]; } for (const [id, scene] of Object.entries(story.scenes)) { yield [`${id}.enter`, scene.enter ?? []]; for (const h of scene.hotspots ?? []) for (const table of ['actions', 'with', 'give']) for (const [k, list] of Object.entries(h[table] ?? {})) yield [`${id}.${h.id}.${table}.${k}`, list]; } for (const [id, item] of Object.entries(story.items)) for (const table of ['actions', 'with']) for (const [k, list] of Object.entries(item[table] ?? {})) yield [`${id}.${table}.${k}`, list]; for (const [id, d] of Object.entries(story.dialogues)) for (const [n, node] of Object.entries(d.nodes)) for (const o of node.options) yield [`${id}.${n}.${o.text}`, o.then ?? []]; } function* flatten(list) { for (const e of list) { yield e; if (e.if !== undefined) { yield* flatten(e.then ?? []); yield* flatten(e.else ?? []); } } } test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esistono', () => { assert.ok(story.scenes[story.start.scene] && inside(story.scenes[story.start.scene].walk, story.start.at)); for (const [id, scene] of Object.entries(story.scenes)) { assert.ok(inside(scene.walk, scene.entry), `${id}: ingresso fuori dall'area`); if (scene.background) assert.ok(existsSync(new URL('../' + scene.background, import.meta.url)), `${id}: manca ${scene.background}`); const ids = new Set(); for (const h of scene.hotspots ?? []) { assert.ok(!ids.has(h.id), `${id}.${h.id} doppio`); ids.add(h.id); assert.ok(!story.items[h.id], `${id}.${h.id}: stesso nome di un oggetto dell'inventario`); assert.ok(h.name && h.box?.length === 4, `${id}.${h.id}: nome o riquadro mancante`); if (h.at) assert.ok(inside(scene.walk, h.at), `${id}.${h.id}: punto d'arrivo fuori dall'area`); if (h.default) assert.ok(VERBS.includes(h.default)); for (const k of Object.keys(h.actions ?? {})) assert.ok(VERBS.includes(k), `${id}.${h.id}: verbo ${k}`); for (const k of [...Object.keys(h.with ?? {}), ...Object.keys(h.give ?? {})]) assert.ok(story.items[k], `${id}.${h.id}: oggetto ${k}`); if (h.exit) { // Le uscite devono essere facili da prendere, anche col dito sul telefono. assert.ok(Math.min(h.box[2], h.box[3]) >= 20 && h.box[2] * h.box[3] >= 1500, `${id}.${h.id}: uscita troppo piccola`); const to = story.scenes[h.exit.scene]; assert.ok(to, `${id}.${h.id}: scena ${h.exit.scene}`); assert.ok(inside(to.walk, h.exit.at ?? to.entry), `${id}.${h.id}: arrivo fuori dall'area`); } } } for (const [where, list] of effectLists()) for (const e of flatten(list)) { const kind = EFFECTS.find(k => e[k] !== undefined); assert.ok(kind, `${where}: effetto sconosciuto ${JSON.stringify(e)}`); if (e.give || e.drop) assert.ok(story.items[e.give ?? e.drop], `${where}: oggetto ${e.give ?? e.drop}`); if (e.dialogue) assert.ok(story.dialogues[e.dialogue], `${where}: dialogo ${e.dialogue}`); if (e.go) assert.ok(story.scenes[e.go], `${where}: scena ${e.go}`); if (e.closeup) { const c = story.closeups?.[e.closeup]; assert.ok(c, `${where}: primo piano ${e.closeup}`); for (const src of c.picture ? [c.picture] : c.closed ? [c.closed, c.open] : []) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`); } if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`); } for (const [id, d] of Object.entries(story.dialogues)) { assert.ok(d.nodes.start, `${id}: manca il nodo start`); for (const node of Object.values(d.nodes)) { assert.ok(node.options.some(o => o.next === 'end' && !o.when && !o.once), `${id}: ogni nodo deve avere un'uscita sempre disponibile`); for (const o of node.options) { if (o.once) assert.ok(o.id, `${id}: opzione una tantum senza id`); if (o.next && o.next !== 'end') assert.ok(d.nodes[o.next], `${id}: nodo ${o.next}`); } } } }); const nameOf = n => typeof n === 'string' ? n : n.name; test('i crediti: ogni voce ha un ruolo e almeno un nome', () => { assert.ok(story.credits.length > 0); for (const c of story.credits) { assert.ok(c.role?.trim(), 'ruolo vuoto'); assert.ok(c.names?.length && c.names.every(n => nameOf(n).trim()), `nomi vuoti in ${c.role}`); for (const n of c.names) if (n.face) assert.ok(existsSync(new URL('../' + n.face, import.meta.url)), `manca il ritratto ${n.face}`); } const all = story.credits.flatMap(c => c.names.map(nameOf)); for (const name of ['Baldoria (Yuri Baldi)', 'l\'Arcu (Marco Arcuri)', 'Francesco Bacci']) assert.ok(all.includes(name), `manca ${name}`); }); test('i crediti: i nomi di ogni voce escono in ordine casuale, sempre tutti', () => { const idee = story.credits.find(c => c.names.length >= 3).role; const orders = new Set(); for (let seed = 1; seed <= 30; seed++) { let x = seed; const random = () => ((x = (x * 16807) % 2147483647) / 2147483647); const out = shuffledCredits(story.credits, random); assert.deepEqual(out.map(c => c.role), story.credits.map(c => c.role), 'le voci restano nel loro ordine'); out.forEach((c, i) => assert.deepEqual(c.names.map(nameOf).sort(), story.credits[i].names.map(nameOf).sort())); orders.add(out.find(c => c.role === idee).names.map(nameOf).join('|')); } assert.ok(orders.size > 3, `${idee}: l'ordine non cambia`); }); test('il taccuino: lungo la soluzione le tappe si spuntano in ordine, una alla volta, e alla fine sono tutte fatte', () => { const g = new Game(story); g.go(story.start.scene, story.start.at); settle(g); assert.equal(g.notebook().done.length, 0); assert.match(g.notebook().now, /vecchietti/); let last = 0; for (const [verb, target, item] of solution) { if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); settle(g, { explore: true }); const n = g.notebook().done.length; assert.ok(n === last || n === last + 1, `tappe saltate dopo ${verb} ${target}`); last = n; } assert.equal(g.notebook().now, null); assert.equal(g.notebook().done.length, story.notebook.length); }); test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera, senza tornare indietro', () => { const g = new Game(story); g.go(story.start.scene, story.start.at); settle(g); // La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa. const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id]; for (const [verb, target, item] of solution) { if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); settle(g, { explore: true }); if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id); } assert.deepEqual(seen, ids); }); test('il carosello dei Ringraziamenti: ogni sfondo una volta sola col nome del posto, tutti i ritratti, mai due ritratti vicini', () => { const reel = reelPictures(story); const backgrounds = new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean)); assert.deepEqual(reel.filter(p => p.kind === 'scene').map(p => p.src).sort(), [...backgrounds].sort(), 'tutti gli sfondi, senza doppioni'); for (const p of reel) { assert.ok(p.name, `${p.src} senza nome`); assert.ok(existsSync(new URL('../' + p.src, import.meta.url)), `manca ${p.src}`); } const faces = story.credits.flatMap(c => c.names).filter(n => n.face); assert.deepEqual(reel.filter(p => p.kind !== 'scene').map(p => p.src).sort(), faces.map(n => n.face).sort(), 'tutti i ritratti, una volta'); assert.ok(reel.some(p => p.kind === 'palio'), 'il palio ha la sua cornice'); reel.forEach((p, i) => { if (i && p.kind !== 'scene') assert.equal(reel[i - 1].kind, 'scene', `due ritratti di fila: ${reel[i - 1].name}, ${p.name}`); }); // Poche scene: i ritratti che non trovano posto chiudono comunque la fila. const few = reelPictures({ scenes: { a: { name: 'A', background: 'x.png' } }, credits: story.credits }); assert.equal(few.length, 1 + faces.length); }); test('la ricerca della mappa: pezzi di parola, senza maiuscole, accenti e apostrofi', () => { assert.ok(matchPlace('Campomigliaio', 'camp')); assert.ok(matchPlace('Via de\' Medici', 'de medici')); assert.ok(matchPlace('Via de\' Medici', 'MEDICI via'), 'le parole in qualsiasi ordine'); assert.ok(matchPlace('La Pieve di San Pietro', 'pieve ')); assert.ok(matchPlace('Piazza', ''), 'casella vuota: tutti'); assert.ok(!matchPlace('Piazza', 'pieve')); const names = Object.values(story.scenes).filter(s => s.map).map(s => s.name); assert.ok(names.filter(n => matchPlace(n, 'provinciale')).length >= 2, 'più posti sulla Provinciale'); }); test('la soluzione si percorre tutta dall\'inizio alla fine', () => { const saves = []; const g = new Game(story, { onSave: s => saves.push(s) }); g.go(story.start.scene, story.start.at); settle(g); for (const [verb, target, item] of solution) { if (verb === 'choose') { const i = g.options().findIndex(o => o.text === target); assert.ok(i >= 0, `opzione non disponibile: ${target}`); g.choose(i); } else { if (!story.items[target]) assert.ok(g.hotspot(target), `non c'è ${target} in ${g.state.scene}`); if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`); assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`); } settle(g, { explore: true }); } assert.ok(g.state.ended, 'la partita non è finita'); assert.ok(g.state.flags.indizio_regolo && g.state.flags.palio_trovato, 'il palio visto nella fessura'); assert.ok(saves.at(-1).ended, 'la fine non è salvata'); }); test('i personaggi in scena hanno il foglio e tutte le pose che usano', () => { const frames = id => JSON.parse(readFileSync(new URL('../' + story.characters[id].sprite, import.meta.url), 'utf8')).frames; for (const [id, scene] of Object.entries(story.scenes)) for (const p of scene.people ?? []) { assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`); if ((p.talk ?? ['talk']).length) assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`); const f = frames(p.who); for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`); } for (const [where, list] of effectLists()) for (const e of flatten(list)) if (e.pose) assert.ok(frames(e.who ?? 'andrea')[e.pose], `${where}: ${e.who} non ha la posa ${e.pose}`); }); test('il foglio di Andrea ha tutti i fotogrammi e l\'immagine', () => { for (const [id, c] of Object.entries(story.characters)) { if (!c.sprite || id !== 'andrea') continue; // le pose degli altri dipendono dalla scena (test sopra) const url = new URL('../' + c.sprite, import.meta.url); const atlas = JSON.parse(readFileSync(url, 'utf8')); assert.ok(existsSync(new URL(atlas.image, url)), `${id}: manca ${atlas.image}`); for (const f of ['walk_1', 'walk_2', 'walk_3', 'walk_4', 'stand', 'talk', 'take', 'back']) assert.ok(atlas.frames[f], `${id}: manca ${f}`); } }); test('il libro dell\'oratorio: il primo piano si sfoglia, l\'ultima pagina è «FINE» e nessuno la dice prima', () => { const g = new Game(story); g.go('oratorio', [320, 345]); settle(g); assert.ok(g.hotspotAt([522, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate'); assert.ok(g.hotspotAt([598, 215])?.id === 'giocatore_carte', 'il giocatore di carte resta cliccabile, lontano dal libro'); assert.ok(g.act('take', 'libro_dimenticato')); const before = settle(g).map(s => s.text); assert.deepEqual(before, ['Ricordo di aver visto il film da piccolo... era stupendo!']); assert.ok(g.state.inventory.includes('libro')); assert.equal(g.hotspot('libro_dimenticato'), undefined, 'preso, il libro non resta sul tavolo'); assert.equal(g.closeup?.step, 'cover', 'preso in mano, il primo piano sulla copertina'); // Durante il primo piano gli oggetti dell'oratorio non rispondono. assert.equal(g.act('take', 'gomma_terra'), false); assert.equal(g.walkTo([300, 340]), false); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'title'); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'turn'); assert.equal(g.closeupTap(), false, 'una pagina sola: mentre gira non se ne gira un\'altra'); for (let i = 0; i < 20; i++) g.update(0.05); assert.equal(g.closeup.step, 'end'); assert.equal(g.speech, null, '«FINE» si scopre guardando: nessuna battuta subito'); for (let i = 0; i < 40 && !g.speech; i++) g.update(0.05); assert.equal(g.speech?.text, 'Mi ricordavo un finale diverso.'); assert.deepEqual(g.speakerPoint('andrea'), [320, 34], 'nel primo piano Andrea parla da sopra il libro'); for (let i = 0; i < 200; i++) g.update(0.05); assert.equal(g.closeup?.step, 'end', '«FINE» resta finché non si chiude'); assert.equal(g.closeupTap(), false); assert.ok(g.closeupClose()); assert.deepEqual(settle(g).map(s => s.text), ['Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!']); assert.equal(g.closeup, null); // Dall'inventario si riapre; chiuso prima della fine, nessun commento. assert.ok(g.act('open', 'libro')); settle(g); assert.equal(g.closeup?.step, 'cover'); assert.ok(g.closeupClose()); assert.deepEqual(settle(g), []); // Nessuna battuta racconta la gag: «FINE» c'è solo nel disegno. const lines = JSON.stringify([story.items.libro, story.scenes.oratorio]); assert.ok(!lines.includes('FINE'), 'nessuna battuta anticipa «FINE»'); }); test('la mappa: ogni scena ci sta, i posti compaiono solo visti, il viaggio rispetta i dialoghi', () => { for (const [id, sc] of Object.entries(story.scenes)) { // Le scene del viaggio nel tempo (`viaggio`) non sono sulla mappa: ci si va solo con la macchina. if (sc.viaggio) { assert.ok(!sc.map, `${id}: nel passato la mappa non porta`); continue; } assert.ok(Array.isArray(sc.map), `${id} non è sulla mappa`); assert.ok(sc.map.every(v => v >= 0 && v <= 100), `${id} fuori dalla pianta`); } const g = new Game(story); g.go('piazza', story.start.at); settle(g); assert.deepEqual(g.mapPlaces().map(p => p.id), ['piazza']); assert.equal(g.travel('trebbio'), false, 'non si va dove non si è mai stati'); g.go('via_calimara', story.scenes.via_calimara.entry); settle(g); assert.deepEqual(g.mapRoads(), [['piazza', 'via_calimara']], 'la strada fra due posti visti'); assert.ok(g.mapPlaces().find(p => p.id === 'via_calimara').here); assert.ok(g.travel('piazza')); settle(g); assert.equal(g.state.scene, 'piazza'); assert.equal(g.travel('piazza'), false, 'già qui'); g.act('talk', 'vecchietti'); settle(g); assert.ok(g.dialogue); assert.equal(g.travel('via_calimara'), false, 'non si scappa da un dialogo'); }); test('l\'indovinello di San Pietro dà la chiave; l\'oratorio, aperto, resta aperto e la chiave sparisce', () => { const g = new Game(story); g.go('pieve', [345, 238]); settle(g); assert.ok(!g.hotspot('oratorio') && g.hotspot('porta_oratorio'), 'all\'inizio la porta è chiusa'); g.act('open', 'porta_oratorio'); settle(g); assert.equal(g.state.scene, 'pieve', 'senza chiave non si entra'); g.act('talk', 'statua'); settle(g); const answer = text => { g.choose(g.options().findIndex(o => o.text === text)); return settle(g); }; answer('Luca della Robbia.'); assert.ok(g.dialogue && !g.state.inventory.includes('chiave_oratorio'), 'risposta sbagliata: niente chiave, si riprova'); answer('Giovanni della Robbia.'); assert.ok(!g.dialogue && g.state.inventory.includes('chiave_oratorio'), 'risposta giusta: la chiave'); g.act('drop', 'chiave_oratorio'); settle(g); assert.ok(g.state.inventory.includes('chiave_oratorio'), 'la chiave serve: non si butta'); g.act('use', 'porta_oratorio', 'chiave_oratorio'); settle(g); assert.equal(g.state.scene, 'oratorio'); assert.ok(!g.state.inventory.includes('chiave_oratorio'), 'la chiave resta nella serratura'); g.go('pieve', [345, 238]); assert.ok(g.hotspot('oratorio')?.exit && !g.hotspot('porta_oratorio'), 'da ora la porta è aperta'); g.act('talk', 'statua'); settle(g); assert.ok(!g.dialogue, 'San Pietro non ridà la chiave'); }); test('si buttano solo gli oggetti che non servono', () => { const g = new Game(story); g.go('oratorio'); settle(g); g.state.inventory.push('libro', 'squama'); g.act('drop', 'libro'); settle(g); g.act('drop', 'squama'); settle(g); assert.deepEqual(g.state.inventory, ['squama']); for (const [id, item] of Object.entries(story.items)) if (!['libro', 'fanta', 'coca', 'spuma'].includes(id)) assert.ok(!item.droppable, `${id} serve alla storia: non deve potersi buttare`); }); test('ogni oggetto ha la sua icona a pixel', () => { for (const [id, item] of Object.entries(story.items)) { assert.ok(item.icon?.src, `${id}: manca l'icona`); const svg = readFileSync(new URL('../' + item.icon.src, import.meta.url), 'utf8'); assert.match(svg, /viewBox="0 0 16 16"/, `${id}: l'icona deve essere 16x16`); } }); test('le strade vanno e tornano: niente sensi unici, e ogni posto si raggiunge dalla partenza', () => { const exits = {}; for (const [id, sc] of Object.entries(story.scenes)) for (const h of sc.hotspots ?? []) if (h.exit) (exits[id] ??= new Set()).add(h.exit.scene); for (const [a, to] of Object.entries(exits)) for (const b of to) assert.ok(exits[b]?.has(a), `da ${a} si va a ${b}, ma da ${b} non si torna a ${a}`); const seen = new Set([story.start.scene]), queue = [story.start.scene]; while (queue.length) for (const b of exits[queue.shift()] ?? []) if (!seen.has(b)) { seen.add(b); queue.push(b); } for (const [id, sc] of Object.entries(story.scenes)) { if (!sc.viaggio) { assert.ok(seen.has(id), `${id} non si raggiunge`); continue; } // Una scena del viaggio nel tempo: ci porta un `go` (la fine del viaggio), e un `go` riporta in una scena vera. const text = JSON.stringify(story); assert.ok(text.includes(`"go":"${id}"`), `${id}: nessun viaggio ci porta`); const back = JSON.stringify(sc.hotspots).match(/"go":"([a-z_0-9]+)"/g)?.map(m => m.slice(6, -1)) ?? []; // Si torna in una scena vera, o in un'altra del viaggio (il tendone della Smarronata riporta alla festa). assert.ok(back.some(b => seen.has(b) || story.scenes[b]?.viaggio), `${id}: non si torna indietro`); } }); test('la catena del ghiacciolo: Tosca, cedrata, stecco, gomma, griglia, chiavetta, casetta, torcia', () => { const g = new Game(story); const say = () => settle(g); const answer = text => { const i = g.options().findIndex(o => o.text === text); assert.ok(i >= 0, `manca «${text}»`); g.choose(i); say(); }; g.go('tennis_chiesa'); say(); g.act('talk', 'chiosco'); say(); answer('Una Coca.'); assert.deepEqual(g.state.inventory, ['coca'], 'la Coca finisce nell\'inventario'); answer('Un ghiacciolo al limone.'); answer('Una cedrata, da portare via.'); answer('Niente, grazie Tosca.'); g.act('use', 'coca'); say(); assert.deepEqual(g.state.inventory, ['ghiacciolo', 'cedrata']); g.act('use', 'cedrata', 'ghiacciolo'); say(); assert.deepEqual(g.state.inventory, ['stecco'], 'inzuppato e mangiato: resta lo stecco'); g.act('talk', 'chiosco'); say(); assert.ok(!g.options().some(o => o.text.startsWith('Un ghiacciolo')), 'il ghiacciolo era l\'ultimo'); answer('Niente, grazie Tosca.'); g.go('ponticino'); say(); g.act('use', 'griglia', 'stecco'); say(); assert.ok(!g.state.inventory.includes('chiavetta'), 'senza gomma non si attacca'); g.go('oratorio'); say(); g.act('take', 'gomma_terra'); say(); g.act('use', 'gomma', 'stecco'); say(); assert.deepEqual(g.state.inventory, ['stecco_gomma']); g.go('ponticino'); say(); g.act('use', 'griglia', 'stecco_gomma'); say(); assert.ok(g.state.inventory.includes('chiavetta')); g.go('area_feste'); say(); g.act('open', 'casetta'); say(); assert.ok(g.state.flags.casetta_aperta && g.state.inventory.includes('torcia') && !g.state.inventory.includes('chiavetta')); }); // Uscire dal gioco in un istante qualsiasi di un passo deve riportare a prima del passo o a dopo, mai a metà: // si fa il passo fotogramma per fotogramma e a ogni fotogramma si guarda cosa verrebbe salvato. const essence = s => s && JSON.stringify({ ...s, x: 0, y: 0, facing: 0 }); function stepChecked(g, label, step) { const before = essence(g.checkpoint), seen = []; step(); for (let i = 0; i < 20000; i++) { seen.push(essence(g.savable())); if (g.speech) g.skip(); exploreTorch(g); if (!g.busy && !g.walking && !g.queue.length && !g.task) break; g.update(0.05); } g.update(0); const after = essence(g.savable()); for (const s of seen) assert.ok(s === before || s === after, `${label}: interrompendo a metà si salverebbe uno stato a metà`); assert.equal(essence(g.checkpoint), after, `${label}: a passo finito il punto fermo è aggiornato`); } test('interrompere la soluzione in qualsiasi istante non lascia enigmi risolti a metà', () => { const saves = []; const g = new Game(story, { onSave: s => saves.push(s) }); stepChecked(g, 'inizio', () => g.go(story.start.scene, story.start.at)); for (const [verb, target, item] of solution) stepChecked(g, `${verb} ${target}`, () => verb === 'choose' ? g.choose(g.options().findIndex(o => o.text === target)) : g.act(verb, target, item)); assert.ok(saves.at(-1).ended); }); test('la squama presa resta salvata anche senza cambiare scena', () => { const saves = []; const g = new Game(story, { onSave: s => saves.push(s) }); g.go(story.start.scene, story.start.at); settle(g); for (const [verb, target, item] of solution.slice(0, 5)) { if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); settle(g); } assert.ok(g.state.inventory.includes('squama')); assert.ok(saves.at(-1).inventory.includes('squama'), 'il salvataggio ha la squama'); const again = new Game(story, { state: JSON.parse(JSON.stringify(saves.at(-1))) }); assert.ok(again.state.inventory.includes('squama')); }); test('interrompere la catena del ghiacciolo non perde la torcia né la chiavetta', () => { const g = new Game(story); const steps = [ ['go tennis', () => g.go('tennis_chiesa')], ['parla con Tosca', () => g.act('talk', 'chiosco')], ...['Una Coca.', 'Un ghiacciolo al limone.', 'Una cedrata, da portare via.', 'Niente, grazie Tosca.'] .map(t => [t, () => g.choose(g.options().findIndex(o => o.text === t))]), ['bevi la Coca', () => g.act('use', 'coca')], ['cedrata e ghiacciolo', () => g.act('use', 'cedrata', 'ghiacciolo')], ['go oratorio', () => g.go('oratorio')], ['prendi la gomma', () => g.act('take', 'gomma_terra')], ['gomma e stecco', () => g.act('use', 'gomma', 'stecco')], ['go ponticino', () => g.go('ponticino')], ['griglia', () => g.act('use', 'griglia', 'stecco_gomma')], ['go area feste', () => g.go('area_feste')], ['apri la casetta', () => g.act('open', 'casetta')], ]; for (const [label, step] of steps) stepChecked(g, label, step); assert.ok(g.checkpoint.inventory.includes('torcia') && g.checkpoint.flags.casetta_aperta); }); test('gli sfondi si caricano dalla scena di partenza verso le più lontane, tutti e una volta sola', () => { const order = backgroundOrder(story, story.start.scene); const all = new Set(Object.values(story.scenes).flatMap(s => [s.background, ...(s.backdrops ?? []).map(b => b.src)]).filter(Boolean)); assert.equal(order.length, all.size); assert.deepEqual(new Set(order), all); assert.equal(order[0], story.scenes[story.start.scene].background, 'prima la piazza'); const near = story.scenes[story.start.scene].hotspots.filter(h => h.exit).map(h => story.scenes[h.exit.scene].background); for (const bg of near) assert.ok(order.indexOf(bg) <= near.length, `${bg} è vicina e deve venire presto`); }); test('col dito la squama si prende anche toccando la panchina tutt\'intorno; col mouse vale il punto preciso', () => { const g = new Game(story); g.go('piazza'); settle(g); g.state.flags.luccichio = true; const sq = g.hotspot('cosa_luccicante'); assert.ok(sq, 'la squama è in scena'); const [x, y, w, h] = sq.box, cx = x + w / 2, cy = y + h / 2, reach = 14; for (const p of [[x - 8, cy], [x + w + 8, cy], [cx, y - 8], [cx, y + h + 3]]) assert.equal(g.hotspotAt(p, reach)?.id, 'cosa_luccicante', `col dito in ${p}`); assert.notEqual(g.hotspotAt([x - 8, cy])?.id, 'cosa_luccicante', 'col mouse fuori dalla squama non la prende'); assert.equal(g.hotspotAt([cx, cy])?.id, 'cosa_luccicante'); }); test('un salvataggio da file si accetta solo se torna in tutto con la storia', () => { const g = new Game(story); g.go(story.start.scene, story.start.at); settle(g); const good = JSON.parse(JSON.stringify(g.snapshot())); assert.equal(checkSave(story, good), null); const bad = change => checkSave(story, { ...good, ...change }); assert.match(bad({ scene: 'luna' }), /scena sconosciuta/); assert.match(bad({ scene: 'toString' }), /scena sconosciuta/); assert.ok(bad({ x: 'qui' })); assert.ok(bad({ facing: 0 })); assert.ok(bad({ inventory: ['astronave'] }), 'oggetto che non esiste'); assert.ok(bad({ inventory: 'squama' })); assert.ok(bad({ flags: { a: 1 } })); assert.ok(bad({ used: null })); assert.ok(bad({ ended: 'no' })); assert.ok(checkSave(story, null)); }); test('il Trebbio ha un indizio per ogni tappa della torcia, e la torcia usata troppo presto non chiude niente', () => { const hint = (inventory, flags) => { const g = new Game(story, { state: { scene: 'trebbio', x: 330, y: 312, facing: 1, inventory, flags: { luccichio: true, squama_presa: true, indizio_regolo: true, ...flags }, used: {}, ended: false } }); g.act('talk', 'torre'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Sono bloccato'))); return settle(g).map(s => s.text).at(-1); }; const stages = [ hint([], {}), hint(['ghiacciolo'], {}), hint(['ghiacciolo', 'cedrata'], {}), hint(['stecco'], {}), hint(['stecco', 'chiave_oratorio'], { chiave_data: true }), hint(['stecco'], { chiave_data: true, oratorio_aperto: true }), hint(['stecco', 'gomma'], { oratorio_aperto: true }), hint(['stecco_gomma'], {}), hint(['chiavetta'], {}), hint(['torcia'], { casetta_aperta: true }), ]; assert.equal(new Set(stages).size, stages.length, 'ogni tappa il suo indizio:\n' + stages.join('\n')); assert.match(stages[0], /Tosca/); assert.match(stages.at(-1), /luce/); // Senza le impronte la fessura non si nota: non c'è niente su cui usare la torcia. const early = new Game(story, { state: { scene: 'fortezza', x: 462, y: 310, facing: 1, inventory: ['torcia'], flags: {}, used: {}, ended: false } }); assert.ok(!early.hotspot('fessura'), 'la fessura compare solo dopo le impronte'); early.state.flags.indizio_regolo = true; assert.ok(early.hotspot('fessura'), 'dopo le impronte la fessura si vede'); }); test('ogni posto della mappa ha la sua miniatura (make miniature)', () => { for (const p of new Game(story, { state: { scene: 'piazza', x: 250, y: 320, facing: 1, inventory: [], used: {}, ended: false, flags: Object.fromEntries(Object.keys(story.scenes).map(id => ['visto:' + id, true])) } }).mapPlaces()) { assert.ok(p.mini, `${p.id} senza sfondo`); assert.ok(existsSync(new URL('../' + p.mini, import.meta.url)), `manca ${p.mini}: make miniature`); } }); test('i quattro rioni: ognuno ha la sua riga nell\'introduzione, e le battute sul rione di Andrea lo seguono', () => { assert.deepEqual(Object.keys(story.rioni), ['piazza', 'pieve', 'cardetole', 'campomigliaio']); assert.deepEqual(Object.keys(story.rioni).filter(id => !story.rioni[id].locked), ['campomigliaio'], 'per ora si corre solo per Campomigliaio'); const line = story.intro.find(l => typeof l !== 'string'); assert.deepEqual(Object.keys(line.rione).sort(), Object.keys(story.rioni).sort(), 'una riga per rione'); for (const [id, r] of Object.entries(story.rioni)) assert.ok(introText(line, id).includes(r.colors.replace('i ', '')), `${id}: ${introText(line, id)}`); const look = (rione, scene, target) => { const flags = rione ? { ['rione:' + rione]: true } : {}; const g = new Game(story, { state: { scene, x: 0, y: 0, facing: 1, inventory: [], flags, used: {}, ended: false } }); g.act('look', target); return settle(g).map(s => s.text).join(' '); }; assert.match(look('pieve', 'pieve', 'chiesa'), /Il mio rione/); assert.match(look('cardetole', 'pieve', 'chiesa'), /quelli in bianco/); assert.match(look('piazza', 'piazza', 'colonna'), /noi rossi/); assert.doesNotMatch(look('pieve', 'piazza', 'colonna'), /noi rossi/); assert.match(look(null, 'pieve', 'chiesa'), /quelli in bianco/, 'un salvataggio di prima dei rioni funziona'); }); test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis, il BaronDeDuke alla polisportiva', () => { const g = new Game(story); settle(g); const talk = (scene, who) => { g.go(scene); settle(g); g.act('talk', who); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); }; talk('campomigliaio_circolo', 'arcu'); const ask = text => { g.choose(g.options().findIndex(o => o.text === text)); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); }; assert.deepEqual(ask('Ma che è sempre là, Borgo?'), ['andrea: Ma che è sempre là, Borgo?', 'arcu: E chi lo sposta???']); assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']); ask('Ci si becca da i\' Trappola, ai\' Barrettino!'); assert.equal(g.dialogue, null); assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.')); talk('polisportiva', 'barondeduke'); const baron = ask('Barone! Che ci fai qui?'); assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».')); assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.'); }); test('il Muro del Sanpierino sul telefonino del Barone: sale, il sito a tutto schermo, il link apre il muro', async () => { const { RISE, ZOOM, linkBox } = await import('../src/engine/telefono.js'); const g = new Game(story); settle(g); g.go('polisportiva'); settle(g); g.act('talk', 'barondeduke'); settle(g); g.choose(g.options().findIndex(o => o.text.includes('muro del Sanpierino'))); settle(g); assert.equal(g.closeup?.id, 'muro_sanpierino'); const def = story.closeups.muro_sanpierino; assert.ok(!g.closeupTap(), 'mentre sale, un tocco non conta'); g.update(RISE); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'zoom'); for (let i = 0; i < 20; i++) g.update(ZOOM / 10); assert.equal(g.closeup.step, 'site'); g.closeupAim([600, 340]); assert.ok(!g.closeupTap(), 'fuori dal link non succede niente'); const [x, y, w, h] = linkBox(def, def.siteSize); g.closeupAim([x + w / 2, y + h / 2]); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'wall'); const said = []; for (let i = 0; i < 400 && (g.speech || !g.closeup.lines || g.closeup.lines.length); i++) { if (g.speech) { said.push(g.speech.text); g.skip(); } g.update(0.05); } assert.ok(said[0].includes('Firmato: il Regolo')); g.closeupClose(); assert.ok(settle(g).some(s => s.text.includes('il sito è tuo')), 'chiudendo, il commento'); }); test('i tentativi sbagliati hanno una risposta loro, non quella generica', () => { const g = new Game(story); settle(g); g.state.inventory.push('squama', 'ghiacciolo', 'libro', 'torcia', 'stecco_gomma'); const generic = ['Non funziona.', 'Non credo che lo voglia.']; for (const [scene, verb, target, item, who] of [ ['piazza', 'give', 'vecchietti', 'ghiacciolo', 'vecchietta'], ['piazza', 'give', 'vecchietti', 'libro', 'vecchietto'], ['salita_villa_adami', 'use', 'leone', 'torcia', 'leone'], ['salita_villa_adami', 'give', 'leone', 'squama', 'leone'], ['pieve', 'give', 'statua', 'squama', 'san_pietro'], ['tennis_chiesa', 'give', 'chiosco', 'squama', 'tosca'], ['trebbio', 'use', 'torre', 'torcia', 'castello'], ['campomigliaio_circolo', 'give', 'arcu', 'libro', 'arcu'], ['carza', 'give', 'tozzino', 'ghiacciolo', 'tozzino'], ['polisportiva', 'give', 'barondeduke', 'squama', 'barondeduke'], ['area_feste', 'use', 'casetta', 'stecco_gomma', 'andrea'], ['fortezza', 'use', 'impronte', 'torcia', 'andrea'], ]) { g.go(scene); settle(g); assert.ok(g.act(verb, target, item), `${scene}: ${verb} ${target} con ${item}`); const lines = settle(g); assert.ok(lines.length && !lines.some(l => generic.includes(l.text)), `${scene}: ${target} con ${item} risponde con la frase generica`); assert.ok(lines.some(l => (l.who ?? 'andrea') === who), `${scene}: ${target} con ${item}: manca la risposta di ${who}`); } }); test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => { const g = new Game(story); settle(g); const texts = () => g.options().map(o => o.text); const talk = (scene, who) => { g.go(scene); settle(g); g.act('talk', who); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); }; const leave = () => { g.choose(g.options().findIndex(o => o.next === 'end')); settle(g); }; // All'inizio niente notizie. talk('piazza', 'vecchietti'); assert.ok(!texts().some(t => t.startsWith('Ho trovato una squama')), 'senza squama non c\'è la notizia'); leave(); // Trovata la squama: una notizia nuova, una volta sola. g.state.flags.squama_presa = true; talk('piazza', 'vecchietti'); assert.ok(texts().includes('Ho trovato una squama, sotto la panchina.')); g.choose(texts().indexOf('Ho trovato una squama, sotto la panchina.')); assert.ok(settle(g).some(s => s.who === 'vecchietto' && s.text.includes('muta'))); assert.ok(!texts().includes('Ho trovato una squama, sotto la panchina.'), 'detta una volta, basta'); leave(); // Le impronte: prima di parlare, i vecchietti sparlano di Andrea (una volta sola); poi la notizia delle impronte. g.state.flags.indizio_regolo = true; let said = talk('piazza', 'vecchietti'); assert.ok(said.includes('vecchietto: Dice che Andrea ha trovato un serpente lungo dodici metri.')); assert.ok(said.includes('andrea: Sono qui, eh. Vi sento.')); assert.ok(texts().some(t => t.startsWith('Sotto la Fortezza ci sono impronte'))); leave(); said = talk('piazza', 'vecchietti'); assert.ok(!said.some(t => t.includes('dodici metri')), 'le chiacchiere si origliano una volta'); leave(); // L'Arcu e la Tosca seguono le stesse tappe. talk('campomigliaio_circolo', 'arcu'); assert.ok(texts().includes('Sotto la Fortezza ci sono le sue impronte.') && texts().includes('Ho trovato una squama del Regolo.')); assert.ok(!texts().includes('Ho la torcia. Stasera entro nella fessura.'), 'senza torcia non si annuncia la fessura'); g.choose(texts().indexOf('Cosa stavamo dicendo???')); assert.deepEqual(settle(g).map(s => s.text).slice(-2), ['Ma ti si è bruciata la memoria, fratello???!?', '...I pompieri per cosa?']); g.choose(texts().indexOf('S\'ha andà a fallo!?!?')); assert.deepEqual(settle(g).map(s => s.text).slice(-1), ['Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!']); leave(); talk('tennis_chiesa', 'chiosco'); assert.ok(texts().includes('Tosca, cosa si dice in giro?')); leave(); g.state.flags.casetta_aperta = true; talk('campomigliaio_circolo', 'arcu'); assert.ok(texts().includes('Ho la torcia. Stasera entro nella fessura.')); leave(); }); test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua strada', () => { const g = new Game(story); settle(g); g.go('trebbio'); settle(g); g.act('talk', 'torre'); settle(g); g.choose(g.options().findIndex(o => o.text === 'Lo sai che quassù ci vengo sempre a piedi?')); const said = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); assert.ok(said.includes('andrea: Da casa sono quattro chilometri. E due sono di salita, di quella bella.')); assert.deepEqual(said.slice(-2), ['andrea: Ho trovato la mia strada!', 'andrea: ...Io, che ancora non so cosa fare da grande.']); g.choose(g.options().findIndex(o => o.text === 'E te, da grande, cosa volevi fare?')); assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.')); }); // Il favicon c'era in pagina ma non nell'immagine Docker (404 online, 26 ottobre): ogni cartella di assets che il gioco // usa deve essere fra quelle copiate, e ogni video «Come si gioca» deve esistere. test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', async () => { const { readdirSync } = await import('node:fs'); const root = new URL('../', import.meta.url); const copied = new Set(readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY assets\/(\S+)\s/gm).map(l => l.split(' ')[1].slice(7))); const files = ['index.html', 'style.css', ...readdirSync(new URL('src/', root), { recursive: true }).filter(f => f.endsWith('.js')).map(f => 'src/' + f)]; const used = new Set(files.flatMap(f => [...readFileSync(new URL(f, root), 'utf8').matchAll(/assets\/([a-z0-9_-]+)\//g)].map(m => m[1]))); // raw e generated sono cartelle di lavoro, citate solo nei commenti. assert.deepEqual([...used].filter(d => !copied.has(d) && !['raw', 'generated'].includes(d)).sort(), []); for (const t of Object.values(story.tutorials)) for (const f of [t.src, t.poster]) assert.ok(existsSync(new URL(f, root)), f); }); // La foto del palio e i video arrivavano al browser come application/octet-stream (26 ottobre): ogni estensione // servita deve avere il suo tipo in nginx.conf, sennò nginx (con i soli tipi dichiarati) non sa cos'è. test('ogni estensione dei file serviti ha il suo tipo MIME in nginx.conf', async () => { const { readdirSync } = await import('node:fs'); const root = new URL('../', import.meta.url); const types = readFileSync(new URL('nginx.conf', root), 'utf8').match(/types \{([^}]*)\}/)[1]; const known = new Set(types.split(';').flatMap(l => l.trim().split(/\s+/).slice(1))); const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]); const ext = new Set(['html', 'css']); for (const d of dirs) for (const f of readdirSync(new URL(d + '/', root), { recursive: true })) if (/\.\w+$/.test(f)) ext.add(f.split('.').pop()); assert.deepEqual([...ext].filter(e => !known.has(e) && e !== 'txt').sort(), [], 'estensioni senza tipo in nginx.conf'); }); test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => { const { readdirSync, statSync } = await import('node:fs'); const root = new URL('../', import.meta.url); const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]); const closed = []; const walk = dir => { for (const e of readdirSync(new URL(dir + '/', root), { withFileTypes: true })) { const path = `${dir}/${e.name}`; if (e.isDirectory()) walk(path); else if (!(statSync(new URL(path, root)).mode & 0o004)) closed.push(path); } }; dirs.forEach(walk); assert.deepEqual(closed, [], 'file non leggibili da nginx (chmod 644)'); }); test('la Smarronata del 1993: lo spiazzo sotto il ponte fa comparire la macchina, il viaggio, la festa, il tendone e il ritorno', async () => { const g = new Game(story); settle(g); g.go('ponte_sieve', [250, 320]); settle(g); assert.ok(!g.hotspot('macchina_ponte'), 'prima del ricordo la macchina non c\'è'); // Il vecchio cartello sul lampione si clicca (davanti alla Sieve, che gli sta dietro): si apre ingrandito, e // toccandolo arriva la macchina del tempo. const [bx, by, bw, bh] = g.hotspot('cartello_smarronata').box; assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'cartello_smarronata'); g.act('look', 'cartello_smarronata'); settle(g); assert.equal(g.closeup?.id, 'manifesto_smarronata'); g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g); assert.ok(g.state.flags.macchina_ponte && g.hotspot('macchina_ponte')); g.act('use', 'macchina_ponte'); settle(g); assert.equal(g.closeup?.id, 'viaggio_1993'); // Il cruscotto, poi la partenza vista da fuori: due primi piani di fila. for (let i = 0; i < 4000 && (g.closeup || g.queue.length || g.speech); i++) { g.skip(); g.update(0.05); } settle(g); assert.equal(g.state.scene, 'smarronata_1993'); // La musica: il liscio fuori (più forte vicino alle coppie), la mazurca nel tendone. const { trackFor } = await import('../src/engine/music.js'); const fuori = trackFor(story, { screen: 'game', scene: 'smarronata_1993', at: [430, 300], ok: () => true }); assert.equal(fuori[0].src, 'assets/audio/liscio.mp3'); assert.equal(fuori[0].gain, 1); assert.equal(trackFor(story, { screen: 'game', scene: 'smarronata_tendone' }), 'assets/audio/mazurka.mp3'); assert.ok(!g.travel('piazza'), 'dal 1993 la mappa non porta via: si torna con la macchina'); g.act('walk', 'tendone'); settle(g); assert.equal(g.state.scene, 'smarronata_tendone'); // I cartelli dipinti della polenta e del vino gratis, sulla tela in fondo: si aprono ingranditi. const [cx, cy, cw, ch] = g.hotspot('cartelli_tendone').box; assert.equal(g.hotspotAt([cx + cw / 2, cy + ch / 2])?.id, 'cartelli_tendone'); g.act('look', 'cartelli_tendone'); settle(g); assert.equal(g.closeup?.id, 'cartelli_smarronata'); g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g); g.act('walk', 'fuori_tendone'); settle(g); assert.equal(g.state.scene, 'smarronata_1993'); assert.ok(g.goBack()); for (let i = 0; i < 4000 && (g.closeup || g.queue.length || g.speech); i++) { g.skip(); g.update(0.05); } settle(g); assert.equal(g.state.scene, 'ponte_sieve'); assert.ok(!g.state.flags.macchina_ponte, 'tornati nel 2007 la macchina sparisce'); });