Versione del gioco (src/version.js, 0.1.0): immagini, musiche e voci con ?v=<versione> e cache lunga in nginx; scripts/bump.sh e make bump per le versioni nuove

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 10:10:51 +01:00
co-authored by Claude Opus 5.5
parent 4467909237
commit 12c6357cbc
12 changed files with 98 additions and 19 deletions
+8
View File
@@ -13,5 +13,13 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects COPY assets/effects /usr/share/nginx/html/assets/effects
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \
&& V=$(sed -n "s/^export const VERSION = '\([0-9.]*\)';$/\1/p" src/version.js) \
&& test -n "$V" \
&& sed -i -E "s#url\((assets/[^)]*)\)#url(\1?v=$V)#g" style.css \
&& sed -i -E "s#src=\"(assets/[^\"]*)\"#src=\"\1?v=$V\"#g" index.html \
&& grep -q "?v=$V" style.css && grep -q "?v=$V" index.html
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
+4 -1
View File
@@ -23,7 +23,7 @@ DST ?= assets/scenes/$(notdir $(SRC))
CROP ?= CROP ?=
COLORS ?= 64 COLORS ?= 64
.PHONY: help serve open test browser verify voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \ .PHONY: help serve open test browser verify bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
build up down restart logs ps shell image-size deploy deploy-logs clean distclean build up down restart logs ps shell image-size deploy deploy-logs clean distclean
## ------------------------------------------------------------ sviluppo ## ------------------------------------------------------------ sviluppo
@@ -52,6 +52,9 @@ browser: ## Partita completa nel browser con Playwright
verify: test browser ## Tutti i controlli verify: test browser ## Tutti i controlli
bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy
scripts/bump.sh $(or $(PARTE),patch)
## ------------------------------------------------------------ grafica ## ------------------------------------------------------------ grafica
voci: ## Voci dei personaggi con XTTS sulla GPU: solo le battute nuove o cambiate (RIFAI='testo' per ritentarne una) voci: ## Voci dei personaggi con XTTS sulla GPU: solo le battute nuove o cambiate (RIFAI='testo' per ritentarne una)
+9 -3
View File
@@ -1,3 +1,10 @@
# Immagini, audio e font chiesti con ?v=<versione> (src/version.js) non cambiano mai a quell'indirizzo: cache lunga.
# Senza ?v= (pagine vecchie) una settimana, come prima.
map $arg_v $media_cache {
"" "public, max-age=604800";
default "public, max-age=31536000, immutable";
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -30,9 +37,8 @@ server {
location ~* \.(css|js|mjs|json)$ { location ~* \.(css|js|mjs|json)$ {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
} }
location ~* \.(png|webp|mp3)$ { location ~* \.(png|webp|mp3|woff2)$ {
expires 7d; add_header Cache-Control $media_cache;
add_header Cache-Control "public";
} }
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Nuova versione: scrive la versione in src/version.js e package.json, verifica, fa il commit e crea il tag
# annotato v<versione>. Con la versione cambiano gli indirizzi di immagini, musiche e voci (?v=): al prossimo
# `make deploy` i browser le riscaricano invece di usare quelle in cache.
# scripts/bump.sh [patch|minor|major] (default patch: 0.1.0 → 0.1.1)
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
PARTE=${1:-patch}
[[ $PARTE =~ ^(patch|minor|major)$ ]] || { echo "uso: $0 [patch|minor|major]"; exit 2; }
[[ $(git branch --show-current) == main ]] || { echo "✗ non sono su main"; exit 1; }
[[ -z $(git status --porcelain) ]] || { echo "✗ ci sono modifiche non committate: prima il commit"; git status --short; exit 1; }
ATTUALE=$(sed -n "s/^export const VERSION = '\([0-9.]*\)';$/\1/p" src/version.js)
[[ $ATTUALE =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✗ versione non leggibile in src/version.js"; exit 1; }
IFS=. read -r MA MI PA <<<"$ATTUALE"
case $PARTE in
major) NUOVA="$((MA + 1)).0.0" ;;
minor) NUOVA="$MA.$((MI + 1)).0" ;;
patch) NUOVA="$MA.$MI.$((PA + 1))" ;;
esac
git rev-parse -q --verify "refs/tags/v$NUOVA" >/dev/null && { echo "✗ il tag v$NUOVA esiste già"; exit 1; }
sed -i "s/^export const VERSION = '$ATTUALE';$/export const VERSION = '$NUOVA';/" src/version.js
python3 - "$NUOVA" <<'PY'
import json, sys
d = json.load(open('package.json')); d['version'] = sys.argv[1]
open('package.json', 'w').write(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
PY
if ! make -s verify; then
git checkout -- src/version.js package.json
echo "✗ verifiche fallite: versione rimessa a $ATTUALE"; exit 1
fi
git commit -q -am "Versione $NUOVA"
git tag -a "v$NUOVA" -m "La Leggenda del Regolo $NUOVA"
echo "✓ $ATTUALE → $NUOVA (commit e tag v$NUOVA); per pubblicarla: make deploy"
+3 -1
View File
@@ -2,6 +2,8 @@
// Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo // Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo
// permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock). // permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock).
import { versioned } from '../version.js';
// Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco). Sotto il cartello // Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco). Sotto il cartello
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js). // d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
@@ -57,7 +59,7 @@ export class Music {
} }
start(src) { start(src) {
const audio = new Audio(src); const audio = new Audio(versioned(src));
audio.loop = true; audio.loop = true;
audio.volume = 0; audio.volume = 0;
audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto
+3 -2
View File
@@ -2,6 +2,7 @@
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art. // Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
import { check } from './game.js'; import { check } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js'; import { ambientEffects, ambientHash } from './ambient.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
function drawShape(ctx, s) { function drawShape(ctx, s) {
@@ -64,7 +65,7 @@ const loadImage = src => new Promise(resolve => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = () => resolve(null); img.onerror = () => resolve(null);
img.src = src; img.src = versioned(src);
}); });
export const hasBackground = src => backgrounds.has(src); export const hasBackground = src => backgrounds.has(src);
@@ -96,7 +97,7 @@ export function loadBackground(src, now = Date.now()) {
export async function loadAssets(story, first = story.start.scene, { together = 3 } = {}) { export async function loadAssets(story, first = story.start.scene, { together = 3 } = {}) {
const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => { const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => {
try { try {
const atlas = await (await fetch(c.sprite)).json(); const atlas = await (await fetch(versioned(c.sprite))).json();
const img = await loadImage(new URL(atlas.image, new URL(c.sprite, location.href)).href); const img = await loadImage(new URL(atlas.image, new URL(c.sprite, location.href)).href);
if (img) sprites.set(id, { atlas, img }); if (img) sprites.set(id, { atlas, img });
return !!img; return !!img;
+2 -1
View File
@@ -2,6 +2,7 @@
// Senza assets/voices/index.json, o per una battuta nuova non ancora generata, il gioco resta muto e va avanti. // Senza assets/voices/index.json, o per una battuta nuova non ancora generata, il gioco resta muto e va avanti.
import { ENGINE_LINES } from './game.js'; import { ENGINE_LINES } from './game.js';
import { versioned } from '../version.js';
export const voiceKey = (who, text) => `${who}|${text}`; export const voiceKey = (who, text) => `${who}|${text}`;
@@ -46,7 +47,7 @@ export class Voices {
this.stop(); this.stop();
const entry = this.index[voiceKey(who, text)]; const entry = this.index[voiceKey(who, text)];
if (!entry || muted || this.volume <= 0) return false; if (!entry || muted || this.volume <= 0) return false;
this.audio = new Audio(this.base + entry.file); this.audio = new Audio(versioned(this.base + entry.file));
this.audio.volume = this.volume; this.audio.volume = this.volume;
this.audio.play().catch(() => {}); this.audio.play().catch(() => {});
return true; return true;
+6 -5
View File
@@ -6,6 +6,7 @@ import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/rende
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js'; import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js'; import { Voices } from './engine/voices.js';
import { versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const canvas = $('scene'), ctx = canvas.getContext('2d'); const canvas = $('scene'), ctx = canvas.getContext('2d');
@@ -40,7 +41,7 @@ $('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.val
showSound(); showSound();
window.music = music; // per i test nel browser window.music = music; // per i test nel browser
fetch('assets/voices/index.json').then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {}); fetch(versioned('assets/voices/index.json')).then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {});
window.voices = voices; window.voices = voices;
let voiced = null; let voiced = null;
const say = (who, text) => voices.play(who, text, music.muted); const say = (who, text) => voices.play(who, text, music.muted);
@@ -156,7 +157,7 @@ function rollCredits() {
rolling = requestAnimationFrame(step); rolling = requestAnimationFrame(step);
} }
function showFace({ name, face }) { function showFace({ name, face }) {
$('face').querySelector('img').src = face; $('face').querySelector('img').src = versioned(face);
$('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png')); $('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png'));
$('face').querySelector('figcaption').textContent = name; $('face').querySelector('figcaption').textContent = name;
$('face').hidden = false; $('face').hidden = false;
@@ -185,7 +186,7 @@ function openMap() {
b.classList.toggle('here', p.here); b.classList.toggle('here', p.here);
const img = document.createElement('img'); const img = document.createElement('img');
img.alt = ''; img.alt = '';
if (p.mini) img.src = p.mini; if (p.mini) img.src = versioned(p.mini);
const name = document.createElement('span'); const name = document.createElement('span');
name.textContent = p.name; name.textContent = p.name;
b.append(img, name); b.append(img, name);
@@ -432,7 +433,7 @@ function updateInventory() {
let icon; let icon;
if (item.icon?.src) { if (item.icon?.src) {
icon = document.createElement('img'); icon = document.createElement('img');
icon.src = item.icon.src; icon.src = versioned(item.icon.src);
icon.alt = ''; icon.alt = '';
} else { } else {
icon = document.createElement('i'); icon = document.createElement('i');
@@ -595,7 +596,7 @@ function showSplash() {
reveal(); reveal();
}; };
// La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto. // La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto.
const jingle = story.music?.splash ? new Audio(story.music.splash) : null; const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null;
if (jingle) { if (jingle) {
jingle.volume = music.muted ? 0 : music.volume; jingle.volume = music.muted ? 0 : music.volume;
jingle.play().catch(() => {}); jingle.play().catch(() => {});
+6
View File
@@ -0,0 +1,6 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.0';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
export const versioned = url => /^(data|blob):/.test(url) ? url : `${url}${url.includes('?') ? '&' : '?'}v=${VERSION}`;
+2 -2
View File
@@ -51,7 +51,7 @@ try {
// Il cartello d'apertura, poi da solo il titolo. // Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/); assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello'); assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3$/, 'la musichetta del cartello'); assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo'); assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
await page.waitForTimeout(2800); await page.waitForTimeout(2800);
await page.screenshot({ path: `${OUT}/00-cartello.png` }); await page.screenshot({ path: `${OUT}/00-cartello.png` });
@@ -100,7 +100,7 @@ try {
await page.waitForFunction(() => game.speech); await page.waitForFunction(() => game.speech);
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa. // La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 }); await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3$/); assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 }); await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
await page.screenshot({ path: `${OUT}/02-inizio.png` }); await page.screenshot({ path: `${OUT}/02-inizio.png` });
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita'); assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
+5 -4
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { loadBackground, hasBackground, RETRIES, RETRY_WAIT, flameFrame } from '../src/engine/render.js'; import { loadBackground, hasBackground, RETRIES, RETRY_WAIT, flameFrame } from '../src/engine/render.js';
import { versioned } from '../src/version.js';
// Un Image finto: fallisce le prime `fails` volte per ogni indirizzo, e conta le richieste. // Un Image finto: fallisce le prime `fails` volte per ogni indirizzo, e conta le richieste.
function fakeImage(fails) { function fakeImage(fails) {
@@ -20,11 +21,11 @@ test('uno sfondo che non arriva si riprova dopo un po\', non a ogni fotogramma,
try { try {
assert.equal(await loadBackground(src), false); assert.equal(await loadBackground(src), false);
assert.equal(await loadBackground(src), false, 'subito dopo non si riprova'); assert.equal(await loadBackground(src), false, 'subito dopo non si riprova');
assert.equal(asked.get(src), 1); assert.equal(asked.get(versioned(src)), 1);
assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'dopo l\'attesa si riprova'); assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'dopo l\'attesa si riprova');
assert.equal(asked.get(src), 2); assert.equal(asked.get(versioned(src)), 2);
assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'la seconda attesa è più lunga'); assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'la seconda attesa è più lunga');
assert.equal(asked.get(src), 2); assert.equal(asked.get(versioned(src)), 2);
assert.equal(await loadBackground(src, Date.now() + 2 * RETRY_WAIT + 1), true); assert.equal(await loadBackground(src, Date.now() + 2 * RETRY_WAIT + 1), true);
assert.ok(hasBackground(src)); assert.ok(hasBackground(src));
} finally { } finally {
@@ -36,7 +37,7 @@ test('uno sfondo che non c\'è proprio si lascia stare dopo pochi tentativi', as
const asked = fakeImage(Infinity), src = 'x/non-esiste.png', later = () => Date.now() + 3600e3; const asked = fakeImage(Infinity), src = 'x/non-esiste.png', later = () => Date.now() + 3600e3;
try { try {
for (let i = 0; i < 10; i++) await loadBackground(src, later()); for (let i = 0; i < 10; i++) await loadBackground(src, later());
assert.equal(asked.get(src), RETRIES + 1); assert.equal(asked.get(versioned(src)), RETRIES + 1);
} finally { } finally {
delete globalThis.Image; delete globalThis.Image;
} }
+15
View File
@@ -0,0 +1,15 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { VERSION, versioned } from '../src/version.js';
test('la versione del gioco è quella di package.json', () => {
assert.match(VERSION, /^\d+\.\d+\.\d+$/);
assert.equal(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version, VERSION);
});
test('immagini e suoni prendono ?v=<versione>, senza rompere gli indirizzi già con parametri', () => {
assert.equal(versioned('assets/scenes/pieve.png'), `assets/scenes/pieve.png?v=${VERSION}`);
assert.equal(versioned('a.png?x=1'), `a.png?x=1&v=${VERSION}`);
assert.equal(versioned('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA');
});