Suoni d'ambiente sintetizzati (vento, acqua, uccelli, grilli, campane) col loro volume in Pausa; la fessura in primo piano: si cerca col fascio della torcia il quadro e gli occhi (lavoro di Codex). La foto del palio leggibile da nginx, e un test che lo controlla per tutti i file serviti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 18:17:01 +01:00
co-authored by Claude Opus 5.5
parent 1fabb6e543
commit de303c5825
22 changed files with 732 additions and 61 deletions
+35 -4
View File
@@ -6,6 +6,7 @@ import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js';
import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true });
@@ -47,6 +48,8 @@ const hotspotCenter = async (page, id) => {
};
try {
await checkMultimedia(browser, PORT, OUT, errors);
if (!process.argv.includes('--media-only')) {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
@@ -66,10 +69,22 @@ try {
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => Number(getComputedStyle(document.getElementById('splash')).opacity));
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close();
}
@@ -182,12 +197,13 @@ try {
await page.click('#mute');
await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7 });
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false);
@@ -211,8 +227,21 @@ try {
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) {
await page.waitForFunction(() => game.state.flags.occhi_regolo, null, { timeout: 30000 });
await page.screenshot({ path: `${OUT}/05b-occhi.png` });
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
}
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
@@ -386,6 +415,8 @@ try {
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
}
assert.deepEqual(errors, [], 'errori nella console');
} finally {
await browser.close();
server.kill();
+13 -2
View File
@@ -1,5 +1,5 @@
// Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate.
export function settle(game, { maxSteps = 20000 } = {}) {
export function settle(game, { maxSteps = 20000, explore = false } = {}) {
const said = [];
for (let i = 0; i < maxSteps; i++) {
if (game.speech) {
@@ -7,7 +7,10 @@ export function settle(game, { maxSteps = 20000 } = {}) {
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) return said;
if (game.closeup && !game.speech && game.reach <= 0) {
if (!explore || game.story.closeups[game.closeup.id].type !== 'torch') return said;
exploreTorch(game);
}
// Un ultimo fotogramma, come nel gioco vero: a sequenza finita il motore prende il punto fermo e salva.
if (!game.busy && !game.walking && !game.queue.length && !game.task) { game.update(0); return said; }
game.update(0.05);
@@ -15,6 +18,14 @@ export function settle(game, { maxSteps = 20000 } = {}) {
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
}
// Il collaudo della soluzione compie anche i gesti richiesti dal finale interattivo.
export function exploreTorch(game) {
const c = game.closeup, def = c && game.story.closeups[c.id];
if (def?.type !== 'torch') return;
if (c.step === 'done') game.closeupClose();
else if (c.step === 'search') game.closeupAim(def.targets[c.found].at);
}
export function memoryStorage() {
return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } };
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
export async function checkMultimedia(browser, port, out, errors) {
for (const mobile of [false, true]) {
const page = await browser.newPage({ viewport: mobile ? { width: 844, height: 390 } : { width: 1280, height: 860 }, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${port}/`);
await page.click('#splash'); await page.click('#new-game');
await page.click('[data-rione="campomigliaio"]'); await page.click('#skip-intro');
const settle = async () => {
for (let i = 0; i < 200; i++) {
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
if (await page.evaluate(() => game.closeup || game.idle)) return;
await page.waitForTimeout(30);
}
throw new Error('la sequenza non si ferma');
};
await settle();
await page.waitForFunction(() => ambience.audible && ambience.context.state === 'running');
await page.click('#mute');
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'muto immediato');
await page.click('#mute');
await page.click('#pause-button');
await page.locator('#effects-volume').fill('65');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio')).effects), 0.65);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'in pausa nessun suono');
await page.screenshot({ path: `${out}/media-pausa-${mobile ? 'telefono' : 'desktop'}.png` });
await page.click('#resume');
if (mobile) {
// Il libro già esistente deve continuare a sfogliarsi anche sul telefono.
await page.evaluate(() => { game.state.inventory.push('libro'); game.act('open', 'libro'); });
await page.waitForFunction(() => game.closeup?.step === 'cover');
const r = await page.locator('#scene').boundingBox();
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'title');
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'end');
await page.screenshot({ path: `${out}/media-libro-telefono.png` });
await page.tap('#closeup-close'); await settle();
}
await page.evaluate(() => {
game.state.flags.indizio_regolo = true;
game.state.flags.casetta_aperta = true;
game.state.inventory.push('torcia');
game.go('fortezza', [462, 310]);
});
await settle();
const at = async ([x, y]) => {
const r = await page.locator('#scene').boundingBox();
return [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
};
const openTorch = async () => {
await page.click('#verbs [data-verb="use"]'); await page.click('#inventory [data-item="torcia"]');
await page.mouse.click(...await at([501, 285]));
await settle();
await page.waitForFunction(() => game.closeup?.id === 'fessura');
};
await openTorch();
const saved = await page.evaluate(() => game.savable());
await page.keyboard.press('Escape'); await settle();
assert.equal(await page.evaluate(() => game.state.ended), false, 'uscire presto non vince');
await openTorch();
await page.click('#pause-button');
const age = await page.evaluate(() => game.closeup.age);
await page.waitForTimeout(150);
assert.equal(await page.evaluate(() => game.closeup.age), age, 'il primo piano è fermo in pausa');
await page.click('#resume');
const origin = await page.evaluate(() => [game.state.x, game.state.y]);
if (mobile) {
const cdp = await page.context().newCDPSession(page);
const [x, y] = await at([320, 180]), [tx, ty] = await at([300, 90]);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: tx, y: ty }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const beam = await page.evaluate(() => game.closeup.beam);
assert.ok(Math.abs(beam[0] - 300) < 2 && Math.abs(beam[1] - 90) < 2, 'trascinare il dito sposta la torcia');
await cdp.detach();
}
for (let i = 0; i < 3; i++) {
const target = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
if (mobile) await page.touchscreen.tap(...await at(target));
else if (i === 0) await page.keyboard.press('Enter');
else await page.mouse.move(...await at(target));
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(25);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
assert.deepEqual(await page.evaluate(() => [game.state.x, game.state.y]), origin, 'il puntatore non muove Andrea');
assert.deepEqual(await page.evaluate(() => game.savable()), saved, 'durante il primo piano resta il punto fermo');
if (mobile) await page.tap('#closeup-close'); else await page.keyboard.press('Enter');
await settle();
await page.waitForSelector('#ending:not([hidden])');
assert.ok(await page.evaluate(() => game.checkpoint.ended));
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'alla fine non resta il vento della fessura');
// Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione.
if (!mobile) {
const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {};
for (const kind of ['page', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
const ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true });
if (kind === 'water' || kind === 'wind') s.update(kind === 'water' ? 'acqua' : 'fortezza', 'sera', 0.1);
else s.play(kind);
const data = (await ctx.startRendering()).getChannelData(0);
let peak = 0, sum = 0;
for (const v of data) { peak = Math.max(peak, Math.abs(v)); sum += v * v; }
results[kind] = { peak, rms: Math.sqrt(sum / data.length) };
}
return results;
});
for (const [kind, { peak, rms }] of Object.entries(levels)) {
assert.ok(rms > 0.00001 && peak < 0.5, `${kind}: audio non valido ${JSON.stringify({ peak, rms })}`);
}
}
await page.reload();
await page.waitForFunction(() => window.ambience?.volume === 0.65);
await page.close();
}
console.log('Multimedia OK: torcia con mouse, tastiera e touch; uscita e pausa; audio sintetizzato e volume salvato.');
}
+102
View File
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { Soundscape } from '../src/engine/soundscape.js';
import { soundscapes, soundscapeFor } from '../src/story/soundscapes.js';
function finale() {
const g = new Game(story, { state: { scene: 'fortezza', x: 462, y: 310, facing: 1, inventory: ['torcia'],
flags: { indizio_regolo: true, casetta_aperta: true }, used: {}, ended: false } });
g.act('use', 'fessura', 'torcia'); settle(g);
return g;
}
test('la torcia aspetta la ricerca, richiede una sosta e non scopre gli occhi prima del quadro', () => {
const g = finale();
assert.equal(g.closeup.step, 'search');
assert.equal(g.act('look', 'bastione'), false);
assert.equal(g.walkTo([100, 300]), false);
assert.equal(g.travel('piazza'), false);
const eyes = story.closeups.fessura.targets[2].at;
g.closeupAim(eyes); g.update(2);
assert.equal(g.closeup.found, 0);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
g.closeupAim(eyes); g.update(0.2);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
assert.equal(g.closeup.step, 'search', 'la sosta riparte quando si sposta il fascio');
g.update(0.2);
assert.equal(g.closeup.step, 'reveal');
assert.equal(g.speech, null, 'la pietra non rivela il testo del quadro');
assert.equal(g.state.flags.palio_trovato, undefined);
const lines = settle(g, { explore: true }).map(s => s.text);
assert.ok(lines.indexOf('È il palio! Il quadro dei ragazzi delle medie!') < lines.indexOf('E quelli... sono due occhi gialli!'));
assert.ok(g.checkpoint.ended && g.checkpoint.flags.palio_trovato);
});
test('uscire dalla fessura o ricaricare durante la ricerca non finisce la partita e permette di riprovare', () => {
const g = finale(), saved = g.savable();
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(1);
assert.deepEqual(g.savable(), saved);
assert.ok(g.closeupClose()); settle(g);
assert.ok(!g.state.ended && !g.state.flags.palio_trovato);
assert.ok(g.state.inventory.includes('torcia'));
const reload = new Game(story, { state: g.savable() });
reload.act('use', 'fessura', 'torcia'); settle(reload);
assert.equal(reload.closeup.found, 0);
settle(reload, { explore: true });
assert.ok(reload.state.ended);
});
test('aprire un primo piano ferma gli effetti successivi fino alla sua chiusura', () => {
const g = new Game(story);
g.queue = [{ closeup: 'libro' }, { set: 'dopo_libro' }];
g.update(0);
assert.equal(g.state.flags.dopo_libro, undefined);
g.closeupClose(); settle(g);
assert.equal(g.state.flags.dopo_libro, true);
});
function audioContext() {
const param = () => ({ value: 0, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; },
exponentialRampToValueAtTime(v) { this.value = v; }, setTargetAtTime(v) { this.value = v; }, cancelScheduledValues() {} });
const node = () => ({ gain: param(), frequency: param(), Q: param(), pan: param(), connect() {}, disconnect() {}, start() {}, stop() {} });
return { currentTime: 0, sampleRate: 100, destination: {}, state: 'running',
createGain: node, createBufferSource: node, createBiquadFilter: node, createStereoPanner: node, createOscillator: node,
createBuffer: (_, length) => ({ getChannelData: () => new Float32Array(length) }) };
}
test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo riparte', () => {
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true }); s.update('acqua', 'pomeriggio', 0.1); s.play('page');
assert.ok(s.nodes.size > 1);
s.configure({ muted: true });
assert.equal(s.nodes.size, 0); assert.equal(s.master.gain.value, 0);
s.play('bell'); assert.equal(s.nodes.size, 0);
s.configure({ muted: false }); s.update('acqua', 'pomeriggio', 0.1);
assert.ok(s.bed);
s.configure({ active: false }); assert.equal(s.nodes.size, 0);
s.configure({ active: true, volume: 0 }); s.play('page'); assert.equal(s.nodes.size, 0);
});
test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => {
assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura');
assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno'));
for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird'));
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true });
for (let i = 0; i < 30; i++) {
s.update(i % 2 ? 'acqua' : 'bosco', 'sera', 0.1);
assert.equal(s.nodes.size, 1);
}
const events = []; s.play = kind => events.push(kind);
s.update('paese', 'sera', 0.1); s.update('paese', 'sera', 10);
assert.deepEqual(events, ['cricket']);
s.update('pieve', 'sera', 0.1); assert.equal(s.nodes.size, 0);
});
test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } });
assert.doesNotThrow(() => { s.unlock(); s.configure({ active: true }); s.update('paese', 'sera', 1); s.play('page'); });
});
+2
View File
@@ -27,6 +27,8 @@ test('volume della musica, volume delle voci e muto si ricordano', () => {
assert.equal(readAudio(s), null);
assert.ok(writeAudio(s, { volume: 0.3, muted: true, voices: 0.4 }));
assert.deepEqual(readAudio(s), { volume: 0.3, muted: true, voices: 0.4 });
writeAudio(s, { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
writeAudio(s, { volume: 0.3, muted: false });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false }, 'un salvataggio di prima delle voci resta valido');
s.setItem('leggenda-regolo-audio', '{rotto');
+24 -5
View File
@@ -6,12 +6,16 @@ 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';
import { settle, exploreTorch } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup'];
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
for (const [id, c] of Object.entries(story.closeups ?? {})) {
for (const k of ['comment', 'closing', 'complete']) yield [`${id}.${k}`, c[k] ?? []];
for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
}
for (const [id, scene] of Object.entries(story.scenes)) {
yield [`${id}.enter`, scene.enter ?? []];
for (const h of scene.hotspots ?? [])
@@ -66,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) {
const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${e.closeup}`);
for (const src of [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
for (const src of c.type === 'torch' ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
}
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
@@ -118,7 +122,7 @@ test('il taccuino: lungo la soluzione le tappe si spuntano in ordine, una alla v
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);
settle(g, { explore: true });
const n = g.notebook().done.length;
assert.ok(n === last || n === last + 1, `tappe saltate dopo ${verb} ${target}`);
last = n;
@@ -134,7 +138,7 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
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);
settle(g, { explore: true });
if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id);
}
assert.deepEqual(seen, ids);
@@ -166,7 +170,7 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`);
assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`);
}
settle(g);
settle(g, { explore: true });
}
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');
@@ -367,6 +371,7 @@ function stepChecked(g, label, step) {
for (let i = 0; i < 20000; i++) {
seen.push(essence(g.savable()));
if (g.speech) g.skip();
exploreTorch(g);
if (!g.busy && !g.walking && !g.queue.length && !g.task) break;
g.update(0.05);
}
@@ -584,3 +589,17 @@ test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => {
assert.ok(texts().includes('Ho la torcia. Stasera entro nella fessura.'));
leave();
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]);
const closed = [];
const walk = dir => { for (const e of readdirSync(new URL(dir + '/', root), { withFileTypes: true })) {
const path = `${dir}/${e.name}`;
if (e.isDirectory()) walk(path);
else if (!(statSync(new URL(path, root)).mode & 0o004)) closed.push(path);
} };
dirs.forEach(walk);
assert.deepEqual(closed, [], 'file non leggibili da nginx (chmod 644)');
});