Musica, vecchietti con sprite, nuove scene del paese e generatore degli sfondi

Lavoro della sessione precedente: colonna sonora (titolo, paese, ricordi) con muto e volume,
vecchietta e vecchietto sulla panchina, scene di Pieve, oratorio, Via de' Medici, Villa Adami,
salita, campeggio, Bosco ai Frati e viale dei cipressi, panorama dell'introduzione,
tools/genera.py per gli sfondi dalle foto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
This commit is contained in:
2026-10-23 12:59:57 +02:00
co-authored by Claude Opus 5.5
parent 5eeb634ceb
commit 33e2598718
45 changed files with 1556 additions and 78 deletions
+15 -2
View File
@@ -47,12 +47,23 @@ try {
await page.waitForSelector('#title:not([hidden])');
await page.screenshot({ path: `${OUT}/01-titolo.png` });
await page.click('#new-game');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
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);
await page.screenshot({ path: `${OUT}/02-inizio.png` });
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);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
assert.ok((await page.evaluate(() => music.current?.src)).endsWith('paese.mp3'), 'musica del paese');
await page.click('#mute');
await page.locator('#volume').fill('30');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
@@ -68,7 +79,7 @@ try {
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
if (target === 'vicolo') await page.screenshot({ path: `${OUT}/05-fortezza.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` });
@@ -78,7 +89,7 @@ try {
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const exit of ['vicolo', 'sentiero_trebbio']) {
for (const exit of ['verso_villa_adami', 'su_villa_adami', 'salita', 'su_fortezza', 'sentiero_trebbio', 'al_trebbio']) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
@@ -98,12 +109,14 @@ try {
const phone = await open({ width: 390, height: 844 });
await phone.click('#new-game');
await phone.click('#skip-intro');
await settle(phone);
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await landscape.click('#skip-intro');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
+2 -2
View File
@@ -15,7 +15,7 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
const { g, c, at } = setup();
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
assert.equal(c.sentence('vicolo'), 'Vai a vicolo verso la Fortezza');
assert.equal(c.sentence('verso_villa_adami'), 'Vai a Via de\' Medici, verso Villa Adami');
assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g);
assert.equal(g.dialogue?.id, 'vecchietti');
@@ -59,7 +59,7 @@ test('oggetto dell\'inventario: usa con, dai a, guarda', () => {
c.clickItem('squama');
assert.equal(c.sentence('vecchietti'), 'Dai squama a vecchietti');
c.clickScene(at('vecchietti'));
assert.deepEqual(settle(g).map(s => s.who), ['andrea', 'vecchioA', 'vecchioB', 'andrea']);
assert.deepEqual(settle(g).map(s => s.who), ['andrea', 'vecchietta', 'vecchietto', 'andrea']);
assert.deepEqual(g.state.inventory, ['squama'], 'nessun vicolo cieco: la squama resta');
c.clickItem('squama', { look: true });
assert.match(settle(g)[0].text, /cantina/);
+19
View File
@@ -163,3 +163,22 @@ test('il fotogramma segue quello che fa Andrea', () => {
for (let i = 0; i < 10; i++) { g.time += 0.1; poses.add(g.pose()); }
assert.deepEqual([...poses].sort(), ['stand', 'talk']);
});
test('i personaggi della scena: posa da fermi, battito di ciglia, parlato e posa della battuta', () => {
const story = mini();
story.scenes.a.people = [{ who: 'tizio', at: [50, 30], talk: ['talk', 'explain'], blink: 'blink', every: 2 }];
const g = new Game(story);
g.go('a');
const p = g.people()[0];
g.time = 0.5;
assert.equal(g.personPose(p), 'sit');
g.time = 1.9;
assert.equal(g.personPose(p), 'blink');
g.speech = { who: 'tizio', text: '...', left: 1 };
g.time = 0.1;
assert.equal(g.personPose(p), 'talk');
g.time = 0.4;
assert.equal(g.personPose(p), 'explain');
g.speech.pose = 'point';
assert.equal(g.personPose(p), 'point');
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { trackFor, Music } from '../src/engine/music.js';
import { story } from '../src/story/story.js';
test('il brano giusto per titolo, fine e scena', () => {
const s = { music: { title: 't.mp3', game: 'g.mp3', ending: 'e.mp3' }, scenes: { a: {}, b: { music: 'b.mp3' } } };
assert.equal(trackFor(s, { screen: 'title', scene: 'a' }), 't.mp3');
assert.equal(trackFor(s, { screen: 'intro', scene: 'a' }), 't.mp3');
assert.equal(trackFor(s, { screen: 'ending', scene: 'a' }), 'e.mp3');
assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3');
assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3');
assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null);
});
test('volume e muto', () => {
const m = new Music({ volume: 0.5, muted: true });
assert.equal(m.setVolume(1.4), 1);
assert.equal(m.muted, false, 'alzare il volume toglie il muto');
assert.equal(m.toggle(), true);
assert.equal(m.setVolume(-1), 0);
assert.deepEqual(m.settings(), { volume: 0, muted: true });
});
test('i brani della storia esistono', () => {
const files = [...Object.values(story.music ?? {}), ...Object.values(story.scenes).map(s => s.music).filter(Boolean)];
assert.ok(files.length);
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
});
+10 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readSave, writeSave, clearSave } from '../src/engine/storage.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio } from '../src/engine/storage.js';
import { memoryStorage } from './helpers.mjs';
test('scrive, rilegge e cancella il salvataggio', () => {
@@ -21,3 +21,12 @@ test('un archivio rotto o bloccato non fa cadere il gioco', () => {
garbage.setItem('leggenda-regolo-save', '{non è json');
assert.equal(readSave(garbage), null);
});
test('volume e muto della musica si ricordano', () => {
const s = memoryStorage();
assert.equal(readAudio(s), null);
assert.ok(writeAudio(s, { volume: 0.3, muted: true }));
assert.deepEqual(readAudio(s), { volume: 0.3, muted: true });
s.setItem('leggenda-regolo-audio', '{rotto');
assert.equal(readAudio(s), null);
});
+18 -2
View File
@@ -47,6 +47,8 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
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`);
@@ -96,9 +98,23 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
assert.ok(saves.at(-1).ended, 'la fine non è salvata');
});
test('i fogli dei personaggi hanno tutti i fotogrammi e l\'immagine', () => {
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) continue;
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}`);
+179
View File
@@ -0,0 +1,179 @@
"""Offline tests: every API interaction uses a fake client."""
import base64
from contextlib import redirect_stderr, redirect_stdout
import io
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import genera
class ConnectionErrorFake(Exception):
pass
class StatusErrorFake(Exception):
def __init__(self, status_code):
self.status_code = status_code
def png(size=genera.SIZE):
stream = io.BytesIO()
Image.new('RGB', size, '#ad8c63').save(stream, format='PNG')
return stream.getvalue()
class GeneratorTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.input = self.root / 'foto'
self.output = self.root / 'generate'
self.prompts = self.root / 'prompt'
self.input.mkdir()
self.prompts.mkdir()
self.photo('a.jpg')
self.photo('b.WEBP')
(self.input / 'da-fare').mkdir()
self.photo('da-fare/oratorio.png')
self.response = SimpleNamespace(data=[SimpleNamespace(b64_json=base64.b64encode(png()).decode())],
_request_id='fake-request', usage=None)
self.client = Mock()
self.client.images.edit.return_value = self.response
self.sdk = SimpleNamespace(OpenAI=Mock(return_value=self.client),
APIConnectionError=ConnectionErrorFake, APIStatusError=StatusErrorFake)
def photo(self, name):
Image.new('RGB', (48, 36), 'brown').save(self.input / name)
def run_cli(self, *args, answer='GENERA', eof=False):
with patch.dict(sys.modules, {'openai': self.sdk}), \
patch.dict('os.environ', {'OPENAI_API_KEY': 'offline-test-only'}), \
patch('builtins.input', return_value=answer, side_effect=EOFError if eof else None), \
redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
return genera.main(['--input-dir', str(self.input), '--output-dir', str(self.output),
'--prompt-dir', str(self.prompts), *args])
def records(self):
return json.loads((self.output / 'registro.json').read_text())
def test_dry_run_does_not_write_or_instantiate_sdk(self):
self.assertEqual(self.run_cli('--dry-run'), 0)
self.assertFalse(self.output.exists())
self.sdk.OpenAI.assert_not_called()
self.assertEqual([p.name for p in genera.photos_in(self.input)], ['a.jpg', 'b.WEBP'])
def test_confirmation_declined_or_eof_never_calls_api(self):
self.assertEqual(self.run_cli(answer='no'), 0)
self.sdk.OpenAI.assert_not_called()
self.assertFalse(self.output.exists())
self.assertEqual(self.run_cli(eof=True), 0)
self.client.images.edit.assert_not_called()
def test_missing_key_blocks_before_client(self):
args = SimpleNamespace(input_dir=self.input, output_dir=self.output, prompt_dir=self.prompts,
gallery_only=False, dry_run=False, test=False, limit=None,
model='gpt-image-2', quality='high')
with patch.dict('os.environ', {}, clear=True), redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, 'OPENAI_API_KEY'):
genera.run(args)
self.sdk.OpenAI.assert_not_called()
def test_generate_then_resume_and_limit(self):
(self.prompts / 'a.txt').write_text('Preserve the lion.')
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(self.client.images.edit.call_count, 1)
kwargs = self.client.images.edit.call_args.kwargs
self.assertEqual(kwargs['model'], 'gpt-image-2')
self.assertEqual(kwargs['size'], '1536x864')
self.assertIn('Preserve the lion.', kwargs['prompt'])
self.assertNotIn('input_fidelity', kwargs)
self.assertEqual(self.records()['a.jpg']['status'], 'completed')
self.assertEqual(self.run_cli('--limit', '1'), 0)
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.run_cli(), 0)
self.assertEqual(self.client.images.edit.call_count, 2)
genera.validate_image(self.output / 'a.png', genera.SIZE)
genera.validate_image(self.output / 'b.png', genera.SIZE)
def test_completed_file_skipped_without_log(self):
self.output.mkdir()
(self.output / 'a.png').write_bytes(png())
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(self.client.images.edit.call_count, 1)
self.assertIn('b.WEBP', self.records())
self.assertNotIn('a.jpg', self.records())
def test_corrupt_existing_output_blocks_paid_requests(self):
self.output.mkdir()
(self.output / 'a.png').write_bytes(b'not-an-image')
self.assertEqual(self.run_cli(), 1)
self.client.images.edit.assert_not_called()
self.assertEqual((self.output / 'a.png').read_bytes(), b'not-an-image')
def test_cached_response_requires_no_api_or_confirmation(self):
cache = self.output / '.responses'
cache.mkdir(parents=True)
(cache / 'a.png').write_bytes(png())
self.assertEqual(self.run_cli('--test', answer='no'), 0)
self.sdk.OpenAI.assert_not_called()
genera.validate_image(self.output / 'a.png', genera.SIZE)
def test_duplicate_stems_rejected_before_api(self):
self.photo('a.PNG')
self.assertEqual(self.run_cli('--dry-run'), 1)
self.sdk.OpenAI.assert_not_called()
def test_transient_errors_retry_with_bounded_attempts(self):
self.client.images.edit.side_effect = [ConnectionErrorFake(), StatusErrorFake(429), self.response]
with patch('genera.time.sleep') as sleep:
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(sleep.call_count, 2)
self.assertEqual(self.records()['a.jpg']['attempts'], 3)
self.sdk.OpenAI.assert_called_once_with(timeout=180, max_retries=0)
def test_exhausted_timeouts_are_logged(self):
self.client.images.edit.side_effect = ConnectionErrorFake()
with patch('genera.time.sleep'):
self.assertEqual(self.run_cli('--test', '--attempts', '2'), 1)
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.records()['a.jpg']['status'], 'error')
def test_permanent_error_not_retried_and_next_photo_processed(self):
self.client.images.edit.side_effect = [StatusErrorFake(400), self.response]
with patch('genera.time.sleep') as sleep:
self.assertEqual(self.run_cli(), 1)
sleep.assert_not_called()
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.records()['a.jpg']['status'], 'error')
self.assertEqual(self.records()['b.WEBP']['status'], 'completed')
def test_bad_aspect_ratio_preserved_without_repeated_charge(self):
self.response.data[0].b64_json = base64.b64encode(png((100, 100))).decode()
self.assertEqual(self.run_cli('--test'), 1)
self.assertFalse((self.output / 'a.png').exists())
self.assertTrue((self.output / '.responses/a.png').exists())
self.assertEqual(self.run_cli('--test'), 1)
self.assertEqual(self.client.images.edit.call_count, 1)
def test_gallery_escapes_names_and_encodes_links(self):
self.photo('chiesa & "piazza".png')
self.assertEqual(self.run_cli('--gallery-only'), 0)
page = (self.output / 'index.html').read_text()
self.assertIn('chiesa &amp; &quot;piazza&quot;', page)
self.assertIn('chiesa%20%26%20%22piazza%22.png', page)
self.assertIn('Non ancora generata', page)
self.sdk.OpenAI.assert_not_called()
if __name__ == '__main__':
unittest.main()