From 12c6357cbcf7bd663202c8aef273e56337b4fcc4 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 25 Oct 2026 10:10:51 +0100 Subject: [PATCH] Versione del gioco (src/version.js, 0.1.0): immagini, musiche e voci con ?v= e cache lunga in nginx; scripts/bump.sh e make bump per le versioni nuove Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R --- Dockerfile | 8 ++++++++ Makefile | 5 ++++- nginx.conf | 12 +++++++++--- scripts/bump.sh | 35 +++++++++++++++++++++++++++++++++++ src/engine/music.js | 4 +++- src/engine/render.js | 5 +++-- src/engine/voices.js | 3 ++- src/main.js | 11 ++++++----- src/version.js | 6 ++++++ tests/browser.mjs | 4 ++-- tests/render.test.mjs | 9 +++++---- tests/version.test.mjs | 15 +++++++++++++++ 12 files changed, 98 insertions(+), 19 deletions(-) create mode 100755 scripts/bump.sh create mode 100644 src/version.js create mode 100644 tests/version.test.mjs diff --git a/Dockerfile b/Dockerfile index 16aaacb..8bbf276 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,13 @@ COPY assets/voices /usr/share/nginx/html/assets/voices COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/effects /usr/share/nginx/html/assets/effects +# Le immagini e i font citati da index.html e style.css prendono ?v=, 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 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/Makefile b/Makefile index 2f81cc9..16473b3 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ DST ?= assets/scenes/$(notdir $(SRC)) CROP ?= 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 ## ------------------------------------------------------------ sviluppo @@ -52,6 +52,9 @@ browser: ## Partita completa nel browser con Playwright verify: test browser ## Tutti i controlli +bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v; poi make deploy + scripts/bump.sh $(or $(PARTE),patch) + ## ------------------------------------------------------------ grafica voci: ## Voci dei personaggi con XTTS sulla GPU: solo le battute nuove o cambiate (RIFAI='testo' per ritentarne una) diff --git a/nginx.conf b/nginx.conf index 6546376..0330446 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,3 +1,10 @@ +# Immagini, audio e font chiesti con ?v= (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 { listen 80; server_name _; @@ -30,9 +37,8 @@ server { location ~* \.(css|js|mjs|json)$ { add_header Cache-Control "no-cache"; } - location ~* \.(png|webp|mp3)$ { - expires 7d; - add_header Cache-Control "public"; + location ~* \.(png|webp|mp3|woff2)$ { + add_header Cache-Control $media_cache; } location = /index.html { add_header Cache-Control "no-cache"; diff --git a/scripts/bump.sh b/scripts/bump.sh new file mode 100755 index 0000000..4e9a3f2 --- /dev/null +++ b/scripts/bump.sh @@ -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. 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" diff --git a/src/engine/music.js b/src/engine/music.js index d997a47..6d36ecb 100644 --- a/src/engine/music.js +++ b/src/engine/music.js @@ -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 // 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 // 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); @@ -57,7 +59,7 @@ export class Music { } start(src) { - const audio = new Audio(src); + const audio = new Audio(versioned(src)); audio.loop = true; audio.volume = 0; audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto diff --git a/src/engine/render.js b/src/engine/render.js index 9f6f7d1..490c161 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -2,6 +2,7 @@ // Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art. import { check } from './game.js'; import { ambientEffects, ambientHash } from './ambient.js'; +import { versioned } from '../version.js'; export const WIDTH = 640, HEIGHT = 360; function drawShape(ctx, s) { @@ -64,7 +65,7 @@ const loadImage = src => new Promise(resolve => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => resolve(null); - img.src = src; + img.src = versioned(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 } = {}) { const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => { 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); if (img) sprites.set(id, { atlas, img }); return !!img; diff --git a/src/engine/voices.js b/src/engine/voices.js index 3baf875..78d1763 100644 --- a/src/engine/voices.js +++ b/src/engine/voices.js @@ -2,6 +2,7 @@ // 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 { versioned } from '../version.js'; export const voiceKey = (who, text) => `${who}|${text}`; @@ -46,7 +47,7 @@ export class Voices { this.stop(); const entry = this.index[voiceKey(who, text)]; 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.play().catch(() => {}); return true; diff --git a/src/main.js b/src/main.js index ca11b2b..756478f 100644 --- a/src/main.js +++ b/src/main.js @@ -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 { Music, trackFor } from './engine/music.js'; import { Voices } from './engine/voices.js'; +import { versioned } from './version.js'; const $ = id => document.getElementById(id); const canvas = $('scene'), ctx = canvas.getContext('2d'); @@ -40,7 +41,7 @@ $('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.val showSound(); 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; let voiced = null; const say = (who, text) => voices.play(who, text, music.muted); @@ -156,7 +157,7 @@ function rollCredits() { rolling = requestAnimationFrame(step); } 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('figcaption').textContent = name; $('face').hidden = false; @@ -185,7 +186,7 @@ function openMap() { b.classList.toggle('here', p.here); const img = document.createElement('img'); img.alt = ''; - if (p.mini) img.src = p.mini; + if (p.mini) img.src = versioned(p.mini); const name = document.createElement('span'); name.textContent = p.name; b.append(img, name); @@ -432,7 +433,7 @@ function updateInventory() { let icon; if (item.icon?.src) { icon = document.createElement('img'); - icon.src = item.icon.src; + icon.src = versioned(item.icon.src); icon.alt = ''; } else { icon = document.createElement('i'); @@ -595,7 +596,7 @@ function showSplash() { reveal(); }; // 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) { jingle.volume = music.muted ? 0 : music.volume; jingle.play().catch(() => {}); diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..2c52756 --- /dev/null +++ b/src/version.js @@ -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=: 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}`; diff --git a/tests/browser.mjs b/tests/browser.mjs index 0f11d0f..1220679 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -51,7 +51,7 @@ try { // Il cartello d'apertura, poi da solo il titolo. 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.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'); await page.waitForTimeout(2800); await page.screenshot({ path: `${OUT}/00-cartello.png` }); @@ -100,7 +100,7 @@ try { await page.waitForFunction(() => game.speech); // La prima battuta ha la sua voce, e sotto la voce la musica si abbassa. 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.screenshot({ path: `${OUT}/02-inizio.png` }); assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita'); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index c6cd1cf..dd38039 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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. function fakeImage(fails) { @@ -20,11 +21,11 @@ test('uno sfondo che non arriva si riprova dopo un po\', non a ogni fotogramma, try { assert.equal(await loadBackground(src), false); 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(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(asked.get(src), 2); + assert.equal(asked.get(versioned(src)), 2); assert.equal(await loadBackground(src, Date.now() + 2 * RETRY_WAIT + 1), true); assert.ok(hasBackground(src)); } 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; try { 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 { delete globalThis.Image; } diff --git a/tests/version.test.mjs b/tests/version.test.mjs new file mode 100644 index 0000000..385ce6f --- /dev/null +++ b/tests/version.test.mjs @@ -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=, 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'); +});