Prototipo del motore: scena, camminata, verbi, inventario, dialoghi, salvataggio

Motore guidato dai dati con due scene di prova (Piazza e salita alla Fortezza),
percorsi su area concava, clic intelligente e pressione lunga, test della soluzione
completa in node e nel browser con Playwright. Nel documento la leggenda del nonno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 00:08:00 +02:00
co-authored by Claude Opus 5.5
parent 0c5cd240e4
commit 826f5f99ec
22 changed files with 1572 additions and 1 deletions
+105
View File
@@ -0,0 +1,105 @@
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
// npm run browser
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution } from '../src/story/solution.js';
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900);
mkdirSync(OUT, { recursive: true });
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' });
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
async function open(viewport) {
const page = await browser.newPage({ viewport });
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
for (let i = 0; i < 50; i++) {
try { await page.goto(`http://127.0.0.1:${PORT}/`); break; } catch { await page.waitForTimeout(100); }
}
return page;
}
// Clic su un punto della scena (coordinate 640×360).
async function clickScene(page, [x, y]) {
const r = await page.locator('#scene').boundingBox();
await page.mouse.click(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
// Aspetta che il gioco sia fermo, saltando le battute con la barra spaziatrice.
async function settle(page) {
for (let i = 0; i < 400; i++) {
const s = await page.evaluate(() => ({ speech: !!game.speech, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.speech) await page.keyboard.press('Space');
else if (s.idle) return;
await page.waitForTimeout(50);
}
throw new Error('il gioco non si ferma');
}
const hotspotCenter = (page, id) => page.evaluate(id => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
try {
const page = await open({ width: 1280, height: 860 });
await page.waitForSelector('#title:not([hidden])');
await page.screenshot({ path: `${OUT}/01-titolo.png` });
await page.click('#new-game');
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);
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) {
await page.click(`#verbs [data-verb="${verb}"]`);
await page.click(`#inventory [data-item="${item}"]`);
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
await clickScene(page, await hotspotCenter(page, target));
}
await page.waitForTimeout(80);
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 === 'verso_salita') await page.screenshot({ path: `${OUT}/05-salita.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
await clickScene(page, await hotspotCenter(page, 'verso_salita'));
await settle(page);
await page.reload();
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
assert.equal(await page.evaluate(() => game.state.scene), 'salita');
const phone = await open({ width: 390, height: 844 });
await phone.click('#new-game');
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}/07-telefono.png` });
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await landscape.screenshot({ path: `${OUT}/08-orizzontale.png`, fullPage: true });
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
} finally {
await browser.close();
server.kill();
}
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { Command } from '../src/engine/command.js';
import { settle } from './helpers.mjs';
const center = h => [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2];
const setup = () => {
const g = new Game(story);
return { g, c: new Command(g), at: id => center(g.hotspot(id)) };
};
test('clic intelligente: parla con le persone, guarda le cose, attraversa le uscite', () => {
const { g, c, at } = setup();
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('bacheca'), 'Guarda bacheca');
assert.equal(c.sentence('verso_salita'), 'Vai a verso la Fortezza');
assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g);
assert.equal(g.dialogue?.id, 'vecchietti');
});
test('verbo scelto, poi bersaglio; il verbo si azzera dopo il comando', () => {
const { g, c, at } = setup();
c.pickVerb('take');
assert.equal(c.sentence(), 'Prendi…');
assert.equal(c.sentence('asta'), 'Prendi asta del drappo');
c.clickScene(at('asta'));
assert.equal(c.verb, null);
assert.match(settle(g)[0].text, /rincorrerebbero/);
});
test('pressione lunga o clic destro guarda', () => {
const { g, c, at } = setup();
c.clickScene(at('vecchietti'), { look: true });
assert.match(settle(g)[0].text, /panchina/);
assert.equal(g.dialogue, null);
});
test('un clic sul vuoto cammina; un clic mentre si parla salta la battuta', () => {
const { g, c } = setup();
assert.equal(c.clickScene([300, 330]), 'walk');
settle(g);
assert.deepEqual([g.state.x, g.state.y], [300, 330]);
g.queue.push({ say: 'Una battuta lunga.' });
g.update(0.01);
assert.equal(c.clickScene([10, 330]), 'skip');
assert.equal(g.speech, null);
});
test('oggetto dell\'inventario: usa con, dai a, guarda', () => {
const { g, c, at } = setup();
g.state.inventory.push('squama');
assert.equal(c.clickItem('squama'), 'select');
assert.equal(c.sentence('vecchietti'), 'Usa squama con vecchietti');
c.reset();
c.pickVerb('give');
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(g.state.inventory, ['squama'], 'nessun vicolo cieco: la squama resta');
c.clickItem('squama', { look: true });
assert.match(settle(g)[0].text, /cantina/);
});
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { inside, visible, nearestInside, concaveVertices, findPath } from '../src/engine/geometry.js';
// Una "L": il passaggio dall'alto a destra deve girare attorno all'angolo interno (100,100).
const L = [[0, 0], [100, 0], [100, 100], [300, 100], [300, 200], [0, 200]];
test('punto dentro, fuori e sul bordo', () => {
assert.ok(inside(L, [50, 50]));
assert.ok(!inside(L, [200, 50]));
assert.ok(inside(L, [100, 50]));
});
test('la visibilità non attraversa i muri', () => {
assert.ok(visible(L, [50, 50], [50, 150]));
assert.ok(!visible(L, [50, 50], [250, 150]));
});
test('un clic fuori finisce sul bordo più vicino', () => {
assert.deepEqual(nearestInside(L, [200, 50]), [200, 100]);
assert.deepEqual(nearestInside(L, [50, -20]), [50, 0]);
});
test('solo l\'angolo interno è concavo', () => {
assert.deepEqual(concaveVertices(L), [[100, 100]]);
assert.deepEqual(concaveVertices([...L].reverse()), [[100, 100]]);
});
test('il percorso gira attorno all\'angolo', () => {
assert.deepEqual(findPath(L, [50, 50], [250, 150]), [[100, 100], [250, 150]]);
assert.deepEqual(findPath(L, [10, 10], [90, 190]), [[90, 190]]);
});
test('una destinazione fuori dall\'area viene avvicinata', () => {
const path = findPath(L, [50, 50], [250, 20]);
assert.deepEqual(path.at(-1), [250, 100]);
for (let i = 1; i < path.length; i++) assert.ok(visible(L, path[i - 1], path[i]));
});
+17
View File
@@ -0,0 +1,17 @@
// Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate.
export function settle(game, { maxSteps = 20000 } = {}) {
const said = [];
for (let i = 0; i < maxSteps; i++) {
if (game.speech) {
said.push(game.speech);
game.skip();
}
if (!game.busy && !game.walking && !game.queue.length && !game.task) return said;
game.update(0.05);
}
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
}
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]; } };
}
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, check, restoreState, newState } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
// Una storia minima, indipendente da quella vera: il motore si collauda con i suoi dati.
const mini = () => ({
start: { scene: 'a', at: [10, 50] },
items: {
chiave: { name: 'chiave', actions: { look: [{ say: 'Una chiave.' }] }, with: { sasso: [{ say: 'Chiave sul sasso.' }] } },
sasso: { name: 'sasso' },
},
scenes: {
a: {
walk: [[0, 0], [200, 0], [200, 100], [0, 100]],
entry: [100, 50],
hotspots: [
{ id: 'chiave_a_terra', name: 'chiave a terra', box: [90, 40, 10, 10], at: [95, 50], when: '!has:chiave', actions: { take: [{ give: 'chiave' }] } },
{ id: 'porta', name: 'porta', box: [180, 20, 20, 60], at: [190, 50], actions: { open: [{ if: 'has:chiave', then: [{ go: 'b' }], else: [{ say: 'Chiusa.' }] }] } },
{ id: 'uscita', name: 'uscita', box: [0, 0, 5, 100], at: [2, 50], exit: { scene: 'b', at: [20, 20] } },
{ id: 'tizio', name: 'tizio', box: [50, 0, 10, 20], at: [55, 30], default: 'talk', actions: { talk: [{ dialogue: 'd' }] }, give: { sasso: [{ say: 'Grazie del sasso.', who: 'tizio' }, { drop: 'sasso' }] } },
],
},
b: { walk: [[0, 0], [100, 0], [100, 100], [0, 100]], entry: [50, 50], enter: [{ set: 'visto_b' }] },
},
dialogues: {
d: { nodes: { start: { options: [
{ id: 'uno', once: true, text: 'Una volta sola', then: [{ give: 'sasso' }] },
{ text: 'Dimmi altro', next: 'altro' },
{ text: 'Ciao', next: 'end' },
] }, altro: { options: [{ text: 'Torna', next: 'start' }, { text: 'Segreto', when: 'has:sasso', then: [{ set: 'segreto' }], next: 'end' }] } } },
},
});
test('condizioni su flag e inventario', () => {
const s = { flags: { a: true }, inventory: ['x'] };
assert.ok(check('a', s) && check('!b', s) && check('has:x', s) && check('!has:y', s));
assert.ok(check(['a', 'has:x'], s) && !check(['a', 'b'], s) && check(undefined, s));
});
test('cammina fino all\'oggetto, lo prende e lo toglie dalla scena', () => {
const g = new Game(mini());
assert.ok(g.act('take', 'chiave_a_terra'));
settle(g);
assert.deepEqual([g.state.x, g.state.y], [95, 50]);
assert.deepEqual(g.state.inventory, ['chiave']);
assert.equal(g.hotspot('chiave_a_terra'), undefined);
});
test('le risposte di ripiego non lasciano mai il giocatore senza una frase', () => {
const g = new Game(mini());
for (const verb of ['look', 'take', 'use', 'talk', 'open', 'give']) assert.ok(g.resolve(verb, 'porta').length, verb);
assert.deepEqual(g.resolve('use', 'porta', 'sasso'), [{ say: 'Non funziona.' }]);
assert.deepEqual(g.resolve('take', 'sasso'), [{ say: 'Ce l\'ho già.' }]);
});
test('usa oggetto con oggetto in entrambi i versi', () => {
const g = new Game(mini());
assert.deepEqual(g.resolve('use', 'sasso', 'chiave'), [{ say: 'Chiave sul sasso.' }]);
assert.deepEqual(g.resolve('use', 'chiave', 'sasso'), [{ say: 'Chiave sul sasso.' }]);
});
test('porta chiusa, poi aperta con la chiave; il cambio scena salva ed esegue l\'ingresso', () => {
const saves = [];
const g = new Game(mini(), { onSave: s => saves.push(s) });
g.act('open', 'porta');
assert.deepEqual(settle(g).map(s => s.text), ['Chiusa.']);
g.act('take', 'chiave_a_terra'); settle(g);
g.act('open', 'porta'); settle(g);
assert.equal(g.state.scene, 'b');
assert.deepEqual([g.state.x, g.state.y], [50, 50]);
assert.ok(g.state.flags.visto_b);
assert.equal(saves.at(-1).scene, 'b');
});
test('l\'uscita porta nell\'altra scena', () => {
const g = new Game(mini());
g.act('walk', 'uscita'); settle(g);
assert.equal(g.state.scene, 'b');
assert.deepEqual([g.state.x, g.state.y], [20, 20]);
});
test('un clic durante la camminata cambia destinazione e scarta l\'azione', () => {
const g = new Game(mini());
g.act('take', 'chiave_a_terra');
g.update(0.1);
assert.ok(g.walking);
assert.ok(g.walkTo([10, 10]));
settle(g);
assert.deepEqual(g.state.inventory, []);
assert.deepEqual([g.state.x, g.state.y], [10, 10]);
});
test('mentre parla un personaggio i comandi sono ignorati', () => {
const g = new Game(mini());
g.queue.push({ say: 'Aspetta.' });
g.update(0.01);
assert.ok(g.speech && g.busy);
assert.equal(g.walkTo([10, 10]), false);
g.update(10);
assert.equal(g.speech, null);
});
test('dialogo: opzioni una tantum, nodi, condizioni e uscita', () => {
const g = new Game(mini());
g.act('talk', 'tizio'); settle(g);
assert.ok(g.dialogue);
assert.deepEqual(g.options().map(o => o.text), ['Una volta sola', 'Dimmi altro', 'Ciao']);
g.choose(0); settle(g);
assert.deepEqual(g.state.inventory, ['sasso']);
assert.deepEqual(g.options().map(o => o.text), ['Dimmi altro', 'Ciao']);
g.choose(0); settle(g);
assert.deepEqual(g.options().map(o => o.text), ['Torna', 'Segreto']);
g.choose(1); settle(g);
assert.equal(g.dialogue, null);
assert.ok(g.state.flags.segreto);
assert.equal(g.walkTo([20, 20]), true);
});
test('dare un oggetto a un personaggio', () => {
const g = new Game(mini());
g.state.inventory.push('sasso');
g.act('give', 'tizio', 'sasso');
const said = settle(g);
assert.deepEqual(said.map(s => [s.who, s.text]), [['tizio', 'Grazie del sasso.']]);
assert.deepEqual(g.state.inventory, []);
});
test('un salvataggio rovinato o vecchio non rompe la partita', () => {
const story = mini();
assert.deepEqual(restoreState(story, null), newState(story));
assert.deepEqual(restoreState(story, { scene: 'sparita' }), newState(story));
const s = restoreState(story, { scene: 'b', x: 500, y: 50, inventory: ['chiave', 'fantasma', 'chiave'], flags: { ok: true, strano: 3 }, used: 'x' });
assert.deepEqual(s, { scene: 'b', x: 100, y: 50, facing: 1, inventory: ['chiave'], flags: { ok: true }, used: {}, ended: false });
});
test('lo stato salvato si ricarica uguale', () => {
const g = new Game(mini());
g.act('take', 'chiave_a_terra'); settle(g);
const again = new Game(mini(), { state: JSON.parse(JSON.stringify(g.snapshot())) });
assert.deepEqual(again.snapshot(), g.snapshot());
});
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readSave, writeSave, clearSave } from '../src/engine/storage.js';
import { memoryStorage } from './helpers.mjs';
test('scrive, rilegge e cancella il salvataggio', () => {
const s = memoryStorage();
assert.equal(readSave(s), null);
assert.ok(writeSave(s, { scene: 'piazza' }));
assert.deepEqual(readSave(s), { scene: 'piazza' });
assert.ok(clearSave(s));
assert.equal(readSave(s), null);
});
test('un archivio rotto o bloccato non fa cadere il gioco', () => {
const broken = { getItem() { throw new Error('bloccato'); }, setItem() { throw new Error('pieno'); }, removeItem() { throw new Error('no'); } };
assert.equal(readSave(broken), null);
assert.equal(writeSave(broken, {}), false);
assert.equal(clearSave(broken), false);
const garbage = memoryStorage();
garbage.setItem('leggenda-regolo-save', '{non è json');
assert.equal(readSave(garbage), null);
});
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { story } from '../src/story/story.js';
import { solution } from '../src/story/solution.js';
import { Game, VERBS } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js';
import { settle } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end'];
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
for (const [id, scene] of Object.entries(story.scenes)) {
yield [`${id}.enter`, scene.enter ?? []];
for (const h of scene.hotspots ?? [])
for (const table of ['actions', 'with', 'give'])
for (const [k, list] of Object.entries(h[table] ?? {})) yield [`${id}.${h.id}.${table}.${k}`, list];
}
for (const [id, item] of Object.entries(story.items))
for (const table of ['actions', 'with'])
for (const [k, list] of Object.entries(item[table] ?? {})) yield [`${id}.${table}.${k}`, list];
for (const [id, d] of Object.entries(story.dialogues))
for (const [n, node] of Object.entries(d.nodes))
for (const o of node.options) yield [`${id}.${n}.${o.text}`, o.then ?? []];
}
function* flatten(list) {
for (const e of list) {
yield e;
if (e.if !== undefined) { yield* flatten(e.then ?? []); yield* flatten(e.else ?? []); }
}
}
test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esistono', () => {
assert.ok(story.scenes[story.start.scene] && inside(story.scenes[story.start.scene].walk, story.start.at));
for (const [id, scene] of Object.entries(story.scenes)) {
assert.ok(inside(scene.walk, scene.entry), `${id}: ingresso fuori dall'area`);
const ids = new Set();
for (const h of scene.hotspots ?? []) {
assert.ok(!ids.has(h.id), `${id}.${h.id} doppio`);
ids.add(h.id);
assert.ok(!story.items[h.id], `${id}.${h.id}: stesso nome di un oggetto dell'inventario`);
assert.ok(h.name && h.box?.length === 4, `${id}.${h.id}: nome o riquadro mancante`);
if (h.at) assert.ok(inside(scene.walk, h.at), `${id}.${h.id}: punto d'arrivo fuori dall'area`);
if (h.default) assert.ok(VERBS.includes(h.default));
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) {
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`);
}
}
}
for (const [where, list] of effectLists())
for (const e of flatten(list)) {
const kind = EFFECTS.find(k => e[k] !== undefined);
assert.ok(kind, `${where}: effetto sconosciuto ${JSON.stringify(e)}`);
if (e.give || e.drop) assert.ok(story.items[e.give ?? e.drop], `${where}: oggetto ${e.give ?? e.drop}`);
if (e.dialogue) assert.ok(story.dialogues[e.dialogue], `${where}: dialogo ${e.dialogue}`);
if (e.go) assert.ok(story.scenes[e.go], `${where}: scena ${e.go}`);
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
for (const [id, d] of Object.entries(story.dialogues)) {
assert.ok(d.nodes.start, `${id}: manca il nodo start`);
for (const node of Object.values(d.nodes)) {
assert.ok(node.options.some(o => o.next === 'end' && !o.when && !o.once), `${id}: ogni nodo deve avere un'uscita sempre disponibile`);
for (const o of node.options) {
if (o.once) assert.ok(o.id, `${id}: opzione una tantum senza id`);
if (o.next && o.next !== 'end') assert.ok(d.nodes[o.next], `${id}: nodo ${o.next}`);
}
}
}
});
test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
const saves = [];
const g = new Game(story, { onSave: s => saves.push(s) });
g.go(story.start.scene, story.start.at);
settle(g);
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
const i = g.options().findIndex(o => o.text === target);
assert.ok(i >= 0, `opzione non disponibile: ${target}`);
g.choose(i);
} else {
if (!story.items[target]) assert.ok(g.hotspot(target), `non c'è ${target} in ${g.state.scene}`);
if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`);
assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`);
}
settle(g);
}
assert.ok(g.state.ended, 'la partita non è finita');
assert.ok(g.state.flags.indizio_regolo);
assert.ok(saves.at(-1).ended, 'la fine non è salvata');
});