Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
174 lines
9.7 KiB
JavaScript
174 lines
9.7 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync } from 'node:fs';
|
|
import { trackFor, mix, Music } from '../src/engine/music.js';
|
|
import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js';
|
|
import { Game, check, POSTER_READY } from '../src/engine/game.js';
|
|
import { story } from '../src/story/story.js';
|
|
import { settle } from './helpers.mjs';
|
|
import { render } from '../src/engine/render.js';
|
|
|
|
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
|
|
// Fa scorrere il tempo finché l'incantesimo non è finito.
|
|
const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); };
|
|
const fromPiazza = () => {
|
|
const g = new Game(story); g.go('piazza'); settle(g);
|
|
g.act('look', 'manifesto_ingorgo'); settle(g);
|
|
assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito');
|
|
assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta');
|
|
g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g);
|
|
assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale');
|
|
spell(g);
|
|
return g;
|
|
};
|
|
|
|
test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => {
|
|
const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }];
|
|
const near = mix(stages, [110, 300]);
|
|
assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1);
|
|
assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è');
|
|
const mid = mix(stages, [350, 300]);
|
|
assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari');
|
|
// Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano.
|
|
const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } };
|
|
assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3');
|
|
const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' });
|
|
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
|
|
});
|
|
|
|
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
|
|
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
|
|
const m = new Music({ volume: 0.5 });
|
|
for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1);
|
|
const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio];
|
|
assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9);
|
|
assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale');
|
|
for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1);
|
|
assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano');
|
|
assert.deepEqual([...m.tracks.keys()], ['c.mp3']);
|
|
delete globalThis.Audio;
|
|
});
|
|
|
|
test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => {
|
|
const c = newIncanto('ingorgo'), seen = [];
|
|
let swapAt = null;
|
|
for (let i = 0; i < 400; i++) {
|
|
const now = updateIncanto(c, 1 / 60);
|
|
if (now === 'swap') swapAt = c.t;
|
|
if (now) seen.push(now);
|
|
if (now === 'end') break;
|
|
}
|
|
assert.deepEqual(seen, ['swap', 'end']);
|
|
assert.ok(Math.abs(swapAt - T.swap) < 0.02);
|
|
assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente');
|
|
assert.equal(whiteness(T.end), 0);
|
|
const kicks = c.events.filter(e => e.kind === 'incanto-cassa');
|
|
assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo'));
|
|
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } });
|
|
for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); }
|
|
});
|
|
|
|
test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => {
|
|
const g = fromPiazza();
|
|
assert.ok(g.state.flags.ingorgo, 'la festa c\'è');
|
|
assert.equal(g.phase().id, 'ingorgo');
|
|
assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa');
|
|
assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve');
|
|
// Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console.
|
|
for (const scene of ZONE.scenes) {
|
|
g.go(scene); settle(g);
|
|
assert.ok(g.state.flags.ingorgo, scene);
|
|
assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`);
|
|
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
|
|
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
|
|
}
|
|
g.go('via_cipriani'); settle(g);
|
|
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
|
|
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
|
|
});
|
|
|
|
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
|
|
const g = new Game(story); g.go('piazza'); settle(g);
|
|
g.act('look', 'manifesto_ingorgo'); settle(g);
|
|
g.update(POSTER_READY); g.closeupTap(); settle(g);
|
|
assert.equal(g.closeup?.id, 'ingorgo');
|
|
g.closeupClose(); settle(g);
|
|
assert.ok(g.state.flags.ingorgo);
|
|
});
|
|
|
|
test('col dito il manifesto dell\'Ingorgo si prende anche toccando vicino, e non si scambia con quello del Palio', () => {
|
|
const g = new Game(story); g.go('piazza'); settle(g);
|
|
const [x, y, w, h] = g.hotspot('manifesto_ingorgo').box, reach = 28; // 22 pixel di schermo su un iPhone in orizzontale
|
|
for (const p of [[x + w / 2, y + h / 2], [x - 6, y + h / 2], [x + w + 4, y + h / 2], [x + w / 2, y + h + 6]])
|
|
assert.equal(g.hotspotAt(p, reach)?.id, 'manifesto_ingorgo', `tocco in ${p}`);
|
|
const palio = g.hotspot('manifesto').box;
|
|
assert.equal(g.hotspotAt([palio[0] + palio[2] / 2, palio[1] + palio[3] / 2], reach)?.id, 'manifesto');
|
|
});
|
|
|
|
test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => {
|
|
const g = new Game(story); g.go('piazza'); settle(g);
|
|
g.act('look', 'manifesto_ingorgo'); settle(g);
|
|
g.update(1); g.closeupClose(); settle(g);
|
|
assert.ok(!g.state.flags.ingorgo && !g.closeup);
|
|
});
|
|
|
|
test('alla festa si clicca solo per andare da un posto all\'altro', () => {
|
|
const g = fromPiazza();
|
|
for (const scene of ZONE.scenes) {
|
|
g.go(scene); settle(g);
|
|
const list = g.hotspots();
|
|
assert.ok(list.length && list.every(h => h.exit), `${scene}: solo le uscite`);
|
|
assert.ok(story.scenes[scene].hotspots.some(h => !h.exit), `${scene}: di giorno c'è anche altro`);
|
|
}
|
|
g.go('piazza'); settle(g);
|
|
assert.equal(g.hotspotAt([505, 200]), null, 'la colonna, alla festa, non risponde');
|
|
});
|
|
|
|
test('alla festa le luci da palco si muovono, e solo alla festa', () => {
|
|
globalThis.Image = class { set src(v) {} };
|
|
try {
|
|
const ops = [], gradient = { addColorStop() {} };
|
|
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { ops.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
|
|
const g = fromPiazza();
|
|
for (const scene of ZONE.scenes) {
|
|
g.go(scene); settle(g);
|
|
const lights = story.scenes[scene].effects.filter(e => e.phase === 'ingorgo');
|
|
assert.ok(lights.length, `${scene}: le luci`);
|
|
const frame = time => { ops.length = 0; render(ctx, g, { time }); return JSON.stringify(ops.filter(([op]) => op === 'lineTo' || op === 'fillRect')); };
|
|
assert.notEqual(frame(1000), frame(1700), `${scene}: le luci si muovono`);
|
|
}
|
|
g.go('via_cipriani'); settle(g); g.go('piazza'); settle(g);
|
|
ops.length = 0; render(ctx, g, { time: 1000 });
|
|
assert.ok(!ops.some(([op, x, y]) => op === 'moveTo' && x === 116 && y === 146), 'di giorno niente teste mobili');
|
|
} finally { delete globalThis.Image; }
|
|
});
|
|
|
|
test('le musiche e gli sfondi della festa esistono', () => {
|
|
const files = new Set();
|
|
for (const id of ZONE.scenes) {
|
|
const sc = story.scenes[id];
|
|
for (const st of sc.stages ?? []) files.add(st.music);
|
|
for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src);
|
|
}
|
|
files.add(story.closeups.manifesto_ingorgo.picture);
|
|
assert.ok(files.size >= 10);
|
|
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
|
});
|
|
|
|
test('alla festa la folla dipinta salta a tempo: l\'effetto crowd in tutte e cinque le scene, dietro piano e davanti forte', async () => {
|
|
const { EFFECTS } = await import('../src/engine/render.js');
|
|
assert.equal(typeof EFFECTS.crowd, 'function');
|
|
for (const id of ZONE.scenes) {
|
|
const crowds = (story.scenes[id].effects ?? []).filter(e => e.type === 'crowd');
|
|
assert.equal(crowds.length, 2, `${id}: due fasce di folla`);
|
|
assert.ok(crowds.every(e => e.phase === 'ingorgo' && e.bpm > 0 && e.area[1] + e.area[3] <= 360), id);
|
|
assert.ok(crowds[0].amount < crowds[1].amount, `${id}: quella davanti salta di più`);
|
|
}
|
|
// Il salto: ogni striscia va su e giù nel tempo; senza sfondo l'effetto non disegna niente.
|
|
const calls = [];
|
|
const ctx = { drawImage: (...a) => calls.push(a) };
|
|
const game = { backdrop: () => 'x.png' };
|
|
EFFECTS.crowd(ctx, { area: [0, 200, 640, 100], bpm: 120 }, 0, game);
|
|
assert.equal(calls.length, 0, 'senza sfondo caricato non disegna');
|
|
});
|