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
This commit is contained in:
2026-10-31 09:52:34 +01:00
co-authored by Claude Opus 5.5
parent 1ea78161d1
commit c5953f04c0
93 changed files with 1112 additions and 68 deletions
+2 -1
View File
@@ -7,7 +7,8 @@ export function settle(game, { maxSteps = 20000, explore = false } = {}) {
game.skip();
}
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test.
if (game.closeup && !game.speech && game.reach <= 0) {
// Il risveglio dell'inizio no: va avanti da solo, come una battuta.
if (game.closeup && !game.speech && game.reach <= 0 && game.story.closeups[game.closeup.id].type !== 'risveglio') {
if (!explore || game.story.closeups[game.closeup.id].type !== 'torch') return said;
exploreTorch(game);
}
+30
View File
@@ -0,0 +1,30 @@
// Il risveglio sulla panchina nel browser vero: Nuova partita, l'introduzione, gli occhi che si aprono annebbiati.
// npm run browser:risveglio — schermate in /tmp/regolo-risveglio.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-risveglio';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.waitForFunction(() => game.closeup?.id === 'risveglio', null, { timeout: 10000 });
await page.waitForFunction(() => document.getElementById('closeup-close').textContent === 'Salta', null, { timeout: 5000 });
// Le battute si saltano solo per far correre il tempo fino ai momenti da fotografare.
for (const [t, name] of [[0.5, '0-buio'], [2.1, '1-primo-tentativo'], [3.8, '2-secondo'], [5.6, '3-quasi'], [7.2, '4-aperti']]) {
await page.waitForFunction(at => { if (game.speech && game.closeup) game.skip(); return !game.closeup || game.closeup.t >= at; }, t, { timeout: 30000, polling: 50 });
await page.screenshot({ path: `${out}/${name}.png` });
}
await page.waitForFunction(() => !game.closeup && game.state.flags.intro, null, { timeout: 15000 });
await page.screenshot({ path: `${out}/5-vecchietti.png` });
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openness, haze, END } from '../src/engine/risveglio.js';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
test('il risveglio: gli occhi chiusi, due tentativi che ricascano, poi aperti; la nebbia si dirada', () => {
assert.equal(openness(0), 0);
assert.ok(openness(2.1) > 0.15 && openness(2.7) < 0.1, 'il primo tentativo ricasca');
assert.ok(openness(3.8) > openness(4.15), 'il secondo pure');
assert.equal(openness(END), 1);
assert.equal(haze(0), 1);
assert.equal(haze(END), 0);
});
test('la partita comincia sulla panchina: le battute a occhi semichiusi, poi i vecchietti col palio sparito', () => {
const g = new Game(story);
g.go(story.start.scene, story.start.at, story.start.face);
g.update(0);
assert.equal(g.closeup?.id, 'risveglio');
const said = [];
for (let i = 0; i < 2000 && (g.closeup || g.speech || g.queue.length); i++) {
if (g.speech) { said.push([g.speech.who, g.closeup ? openness(g.closeup.t) : 1]); g.skip(); }
g.update(0.05);
}
assert.deepEqual(said.slice(0, 3).map(s => s[0]), ['andrea', 'andrea', 'andrea']);
assert.ok(said[0][1] < 0.05, 'la prima battuta a occhi chiusi');
assert.ok(said.some(([who]) => who === 'vecchietto'), 'il vecchietto dà la notizia');
assert.ok(g.state.flags.intro && !g.closeup);
// Rientrando in piazza non si risveglia più.
g.go('via_cipriani'); settle(g); g.go('piazza'); g.update(0);
assert.equal(g.closeup, null);
});
test('saltando il risveglio (Esc) le battute arrivano lo stesso', () => {
const g = new Game(story);
g.go(story.start.scene, story.start.at);
g.update(0);
assert.ok(g.closeupClose());
const said = settle(g).map(s => s.text);
assert.ok(said.includes('Mmmh... che botta.') && said.some(t => t.includes('Il palio è sparito')));
});
+23 -1
View File
@@ -545,11 +545,33 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
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.'));
const baron = talk('polisportiva', 'barondeduke');
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);
+57
View File
@@ -0,0 +1,57 @@
// Il Muro del Sanpierino sul telefonino del Barone, nel browser vero: il telefonino che sale, il sito a tutto
// schermo, il clic sul mattone del muro. npm run browser:telefono — schermate in /tmp/regolo-telefono.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-telefono';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
settle(game); game.go('polisportiva'); settle(game);
game.act('talk', 'barondeduke'); settle(game);
game.choose(game.options().findIndex(o => o.text.includes('muro del Sanpierino'))); settle(game);
});
await page.waitForFunction(() => game.closeup?.id === 'muro_sanpierino' && game.closeup.age > 1.2, null, { timeout: 15000 });
await page.screenshot({ path: `${out}/1-telefonino.png` });
const box = await page.locator('#scene').boundingBox(), k = box.width / 640;
const click = ([x, y]) => page.mouse.click(box.x + x * k, box.y + y * k);
await click([320, 180]);
await page.waitForFunction(() => game.closeup.step === 'site', null, { timeout: 5000 });
await page.waitForTimeout(300);
await page.screenshot({ path: `${out}/2-sito.png` });
const link = await page.evaluate(async () => { const t = await import('/src/engine/telefono.js'); const d = story => story.closeups.muro_sanpierino; const def = d(game.story); const [x, y, w, h] = t.linkBox(def, def.siteSize); return [x + w / 2, y + h / 2]; });
await click(link);
await page.waitForFunction(() => game.closeup.step === 'wall', null, { timeout: 8000 });
// Il muro è la pagina vera: i messaggi del gioco in cima, sotto quelli del 2007; «Scrivi» spento, niente email.
const frame = page.frameLocator('#muro');
await frame.locator('.name').first().waitFor({ timeout: 10000 });
assert.match(await frame.locator('.name').first().textContent(), /Il Regolo/);
assert.ok((await frame.locator('.name').allTextContents()).some(t => t.includes('Kiara')), 'i messaggi veri del 2007');
assert.match(await frame.locator('.pagerbutton', { hasText: 'SCRIVI' }).first().getAttribute('class'), /disabled/);
assert.equal(await frame.locator('a[href^="mailto:"]').count(), 0);
await page.waitForTimeout(500);
await page.screenshot({ path: `${out}/3-muro.png` });
// La storia va avanti: «Aggiorna» fa comparire il messaggio nuovo.
await page.evaluate(() => { game.speech = null; game.closeup.lines = []; game.state.flags.indizio_regolo = true; });
await frame.locator('.pagerbutton', { hasText: 'Aggiorna' }).first().click();
await page.waitForTimeout(800);
assert.match(await frame.locator('.name').first().textContent(), /Uno che passava/);
await page.screenshot({ path: `${out}/4-aggiornato.png` });
// «Home» torna alla home del sito sul telefonino.
await frame.locator('.pagerbutton', { hasText: 'Home' }).first().click();
await page.waitForFunction(() => game.closeup.step === 'site', null, { timeout: 5000 });
await page.waitForSelector('#muro', { state: 'hidden', timeout: 3000 });
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+7 -5
View File
@@ -13,7 +13,8 @@ const voci = JSON.parse(readFileSync(new URL('../tools/voci.json', import.meta.u
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('andrea', 'Il palio è sparito!'), 'ingresso in una scena, dentro un if');
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');
@@ -61,15 +62,16 @@ test('il castello e il leone hanno voci diverse da tutti gli altri', () => {
});
test('il fumetto resta a schermo almeno quanto la voce', () => {
const g = new Game(story, { voiceTime: (who, text) => (text.startsWith('Il palio') ? 9 : undefined) });
// 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);
g.update(0.05);
assert.ok(g.speech.text.startsWith('Il palio'));
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);
quiet.update(0.05);
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');
});