import test from 'node:test'; import assert from 'node:assert/strict'; import { story } from '../src/story/story.js'; import { solution } from '../src/story/solution.js'; import { Game, VERBS } from '../src/engine/game.js'; import { inside } from '../src/engine/geometry.js'; import { existsSync, readFileSync } from 'node:fs'; import { settle } from './helpers.mjs'; const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end']; // Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`. function* effectLists() { 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.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}`); } } } }); 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 => n.trim()), `nomi vuoti in ${c.role}`); } const all = story.credits.flatMap(c => c.names); for (const name of ['Yuri Baldi', 'Francesco Bacci']) assert.ok(all.includes(name), `manca ${name}`); }); 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); } assert.ok(g.state.ended, 'la partita non è finita'); assert.ok(g.state.flags.indizio_regolo); 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`); 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: «La storia infinita» ha una pagina sola', () => { const g = new Game(story); g.go('oratorio', [320, 345]); settle(g); assert.ok(g.hotspotAt([570, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate'); assert.ok(g.act('take', 'libro_dimenticato')); settle(g); assert.ok(g.state.inventory.includes('libro')); assert.equal(g.hotspot('libro_dimenticato'), undefined, 'preso, il libro non resta sul tavolo'); assert.ok(g.act('open', 'libro')); const said = settle(g).map(s => s.text); assert.ok(said.some(t => t.includes('FINE')), 'aperto, dice 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)) { 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('Sette.'); assert.ok(g.dialogue && !g.state.inventory.includes('chiave_oratorio'), 'risposta sbagliata: niente chiave, si riprova'); answer('Nove.'); 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 (id !== 'libro') 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 of Object.keys(story.scenes)) assert.ok(seen.has(id), `${id} non si raggiunge`); }); 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('Un ghiacciolo al limone.'); answer('Una cedrata, da portare via.'); answer('Niente, grazie Tosca.'); 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')); });