Files
leggenda-del-regolo/tests/voices.test.mjs
T
manzoloandClaude Opus 5.5 c5953f04c0 Si comincia svegliandosi sulla panchina dei vecchietti dopo una notte brava: gli occhi che si aprono a fatica, la piazza sfocata e doppia; e dal Barone il telefonino col sito di San Piero, che si apre a tutto schermo, e il Muro del Sanpierino vero coi messaggi del 2007, «Aggiorna» compreso (Andrea)
- Il risveglio (src/engine/risveglio.js): primo piano sopra la scena, con le battute mentre le palpebre si aprono e
  i vecchietti che danno la notizia del palio; «Salta» lo salta.
- Il telefonino del Barone (src/engine/telefono.js): sale dal basso come il libro, la home di sanpiero50037.it dalla
  Wayback Machine nello schermo, il mattone del muro apre il muro.
- Il muro (assets/muro, tools/muro.py): la pagina vera rifatta statica dal muro riesumato, coi 24 messaggi del dump
  (senza email né IP) e quelli del gioco, che arrivano con la storia; «Scrivi» spento.
- Il gioco sgancia i service worker di altre app rimasti sul suo indirizzo (la Bussola su localhost:8080).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 09:52:34 +01:00

134 lines
7.3 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { story } from '../src/story/story.js';
import { Game, VERBS } from '../src/engine/game.js';
import { solution } from '../src/story/solution.js';
import { voiceLines, voiceKey, Voices } from '../src/engine/voices.js';
import { settle } from './helpers.mjs';
const index = JSON.parse(readFileSync(new URL('../assets/voices/index.json', import.meta.url), 'utf8'));
const voci = JSON.parse(readFileSync(new URL('../tools/voci.json', import.meta.url), 'utf8'));
test('le battute si trovano ovunque: introduzione, scene, oggetti, dialoghi e rami if', () => {
const lines = voiceLines(story), has = (who, part) => lines.some(l => l.who === who && l.text.includes(part));
assert.ok(has('andrea', 'Fine maggio 2007'), 'introduzione');
assert.ok(has('vecchietto', 'Il palio è sparito!'), 'primo piano del risveglio, a occhi aperti');
assert.ok(has('andrea', 'che botta'), 'primo piano del risveglio, mentre gli occhi si aprono');
assert.ok(has('vecchietta', 'cadde dal cielo'), 'dialogo, con chi parla');
assert.ok(has('castello', 'vicino alle sue impronte'), 'ramo else annidato');
assert.ok(has('andrea', 'pagina sola'), 'azione di un oggetto');
assert.ok(!lines.some(l => /^[.…\s]+$/.test(l.text)), 'i silenzi («...») non hanno voce');
});
test('ogni battuta ha la sua voce (sennò: make voci) e ogni personaggio una voce in tools/voci.json', () => {
for (const { who, text } of voiceLines(story)) {
assert.ok(voci[who]?.speaker, `manca la voce di ${who} in tools/voci.json`);
const entry = index[voiceKey(who, text)];
assert.ok(entry, `senza voce: ${who}: ${text}`);
assert.ok(existsSync(new URL(`../assets/voices/${entry.file}`, import.meta.url)), `manca il file ${entry.file}`);
assert.ok(entry.sec > 0.3 && entry.sec < 30, `durata strana: ${entry.sec} s per «${text}»`);
}
});
// Quello che si sente davvero giocando: la soluzione completa, e ogni verbo su ogni cosa di ogni scena (anche le
// risposte che il motore dà da sé, e le scelte dei dialoghi ripetute da Andrea). Nessuna battuta resta muta.
test('giocando, ogni battuta pronunciata ha la sua voce', () => {
const mute = new Set(), listen = said => { for (const s of said) if (s.text.trim() && !/^[.…!?\s]+$/.test(s.text) && !index[voiceKey(s.who, s.text)]) mute.add(`${s.who}: ${s.text}`); };
const g = new Game(story);
g.go(story.start.scene, story.start.at);
listen(settle(g));
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);
listen(settle(g));
}
for (const id of Object.keys(story.scenes)) {
const fresh = () => { const t = new Game(story); t.go(id); settle(t); return t; };
for (const h of fresh().hotspots()) for (const verb of VERBS) {
if (h.exit && verb === 'walk') continue;
const t = fresh();
t.act(verb, h.id);
listen(settle(t));
if (t.dialogue) for (let i = 0; i < 6 && t.dialogue && t.options().length; i++) { t.choose(0); listen(settle(t)); }
}
}
assert.deepEqual([...mute], [], 'battute senza voce (make voci)');
});
test('il castello e il leone hanno voci diverse da tutti gli altri', () => {
for (const special of ['castello', 'leone'])
for (const [who, v] of Object.entries(voci))
if (who !== special && !who.startsWith('_')) assert.notEqual(v.speaker, voci[special].speaker, `${special} e ${who}`);
});
test('il fumetto resta a schermo almeno quanto la voce', () => {
// La prima battuta della partita è quella del risveglio, mentre gli occhi si aprono.
const g = new Game(story, { voiceTime: (who, text) => (text.startsWith('Mmmh') ? 9 : undefined) });
g.go(story.start.scene, story.start.at);
for (let i = 0; i < 40 && !g.speech; i++) g.update(0.05);
assert.ok(g.speech.text.startsWith('Mmmh'));
assert.ok(g.speech.left > 9, 'la voce lunga allunga il fumetto');
settle(g);
const quiet = new Game(story);
quiet.go(story.start.scene, story.start.at);
for (let i = 0; i < 40 && !quiet.speech; i++) quiet.update(0.05);
assert.ok(quiet.speech.left < 9, 'senza voce vale la durata di lettura');
});
test('le voci si cercano per chi parla e testo; senza indice il gioco resta muto', () => {
const v = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } });
assert.equal(v.seconds('leone', 'Grr'), 1.5);
assert.equal(v.seconds('andrea', 'Grr'), undefined);
assert.equal(new Voices().play('andrea', 'Ciao'), false);
assert.equal(v.playing, false);
});
test('le voci hanno il loro volume, più basso della musica finché non lo si cambia', () => {
const v = new Voices();
assert.equal(v.volume, 0.5);
assert.equal(v.setVolume(1.7), 1);
assert.equal(v.setVolume(-1), 0);
const quiet = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }, 'x/', 0);
assert.equal(quiet.play('leone', 'Grr'), false, 'a volume zero non parte niente');
assert.equal(new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }).play('leone', 'Grr', true), false, 'col muto non parte niente');
});
test('il leone e il castello parlano dalla loro posizione, non da quella di Andrea', () => {
const g = new Game(story);
g.go('salita_villa_adami', [340, 352]);
assert.deepEqual(g.speakerPoint('leone'), story.scenes.salita_villa_adami.speakers.leone);
g.go('trebbio', [110, 345]);
assert.deepEqual(g.speakerPoint('castello'), story.scenes.trebbio.speakers.castello);
});
test('la pausa ferma la voce e riprendendo continua; stop la toglie', () => {
globalThis.Audio = class { constructor(src) { this.src = src; this.paused = true; this.ended = false; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
try {
const v = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }, 'x/', 0.4);
assert.ok(v.play('leone', 'Grr'));
assert.equal(v.audio.volume, 0.4);
assert.ok(v.playing);
v.pause();
assert.ok(!v.playing && v.audio, 'in pausa la voce resta, ferma');
v.resume();
assert.ok(v.playing, 'riprende la stessa battuta');
v.setVolume(0.8);
assert.equal(v.audio.volume, 0.8, 'il cursore cambia anche la voce che sta parlando');
v.stop();
assert.ok(!v.playing && !v.audio);
v.resume();
assert.ok(!v.playing, 'dopo stop non riparte niente');
} finally {
delete globalThis.Audio;
}
});
test('le parole che XTTS legge male si riscrivono solo per la voce, intere e con la maiuscola', async () => {
const { spoken, rules } = await import('../tools/pronuncia.mjs');
assert.equal(spoken('Qui si scambiavano le figurine: celo, celo, manca.'), 'Qui si scambiavano le figurine: ce l\'ho, ce l\'ho, manca.');
assert.equal(spoken('Celo!'), 'Ce l\'ho!');
assert.equal(spoken('Guarda il cielo e il celofan.'), 'Guarda il cielo e il celofan.', 'non i pezzi di altre parole');
assert.equal(spoken('Il Regolo', { regolo: 'Règolo' }), 'Il Règolo');
for (const [w, s] of Object.entries(rules)) if (!w.startsWith('_')) assert.ok(typeof s === 'string' && s.trim(), `pronuncia vuota per ${w}`);
});