Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
484 lines
27 KiB
JavaScript
484 lines
27 KiB
JavaScript
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 } 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}`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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);
|
|
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);
|
|
const ids = story.phases.map(p => p.id), 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);
|
|
if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id);
|
|
}
|
|
assert.deepEqual(seen, ids);
|
|
});
|
|
|
|
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);
|
|
}
|
|
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`);
|
|
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('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 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('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();
|
|
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).map(s => s.background).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}`); };
|
|
assert.deepEqual(talk('campomigliaio_circolo', 'arcu'), ['andrea: Arcu! Ma che è sempre là, Borgo?', 'arcu: E chi lo sposta???']);
|
|
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
|
|
const baron = talk('polisportiva', 'barondeduke');
|
|
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.');
|
|
});
|