# Spot The Spy 2.0: piano di lavoro > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** rifare in Expo la Spot The Spy 1.5 (Swift/UIKit) come app iOS e Android funzionante sui simulatori, pronta per le build di release. **Architecture:** una sola base di codice Expo 57 in TypeScript. La logica di gioco sta in un modulo puro (`src/gioco.ts`) testato con `node --test`; i testi in `src/testi/` (generati dai `Localizable.strings` della 1.5 e poi riletti); foto e suoni copiati e ricompressi dalla 1.5 da uno script. L'interfaccia è uno stack di sei schermate (`@react-navigation/native-stack`) con uno stato condiviso in un Context. **Tech Stack:** Expo SDK 57, React Native 0.86, TypeScript, expo-audio, expo-notifications, expo-haptics, expo-keep-awake, expo-localization, expo-image, expo-linear-gradient, expo-sqlite (solo `kv-store`), expo-store-review, expo-splash-screen, @expo-google-fonts/barlow-condensed, @react-navigation/native-stack. Node 24 per i test (esegue il TypeScript senza compilarlo). ## Global Constraints - Progetto: `docs/superpowers/specs/2026-09-24-spotthespy-2-design.md`. Vale tutto quello che c'è scritto lì. - Si lavora e si compila in `/Users/paolo/dev/spotthespy` (clone di `https://github.com/paoloalby/spotthespy`, privata). Dal NAS non si compila. - Sorgenti della 1.5 (solo lettura): `V15="/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy/spotTheSpy 1.5/spotTheSpy"`, materiale in `"/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy/ - mat"`. - Commit e **push** alla fine di ogni task, con in coda al messaggio: ``` Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU ``` Dopo il push: `git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only` per tenere allineato il NAS. - 59 luoghi: numeri 1-60 **senza il 30** (McDonald's tolto). Nessun riferimento a McDonald's da nessuna parte. - Lingue: `it`, `en`, `fr`; ogni altra lingua del telefono usa `en`. - Colori: blu scuro `#232933`, blu `#22435E`, grigio `#DCE2EB`, bordo grigio `#B1B5BC`, arancio selezione `#FFA500`, fondo icona `#315069`. - Font: Barlow Condensed (OFL), `BarlowCondensed_700Bold` per titoli e pulsanti, `BarlowCondensed_500Medium` per i testi lunghi. - iOS: bundle `paoloalby.SpotTheSpy`, team `686XK96PF6`, versione `2.0.0`, iPhone **e** iPad, solo verticale, `UIRequiresFullScreen`. Android: package `it.duebytes.spotthespy`, `versionCode` 1, solo verticale anche sui tablet. - Build Android con JDK 17 (`~/.jdks/jdk-17.0.20.1+1`), SDK in `/opt/homebrew/share/android-commandlinetools`. Mai il JDK 25. - `adb` sempre con `-s `; il nostro emulatore sta sulla porta 5580 (`emulator-5580`). Altre sessioni usano altri emulatori: non toccarli. - zsh non spezza le variabili in parole: per i comandi composti con variabili usare `bash -c`. - Expo cambia spesso: prima di usare un'API di un modulo, controllarne la firma nei `.d.ts` in `node_modules//build/`. Se una firma nel piano non torna, vale il `.d.ts`: adattare il codice e annotarlo nel commit. - Testi dell'interfaccia in italiano senza trattini lunghi. ## Struttura dei file ``` app.json configurazione Expo (nomi, bundle, plugin) package.json, tsconfig.json dipendenze e TypeScript (allowImportingTsExtensions) index.ts, App.tsx ingresso generato da Expo; App.tsx riesporta src/ui/App.tsx plugins/ config plugin nativi, copiati da Quanto Basta with-ios-scene-delegate.js iOS 27 vuole il ciclo di vita a scene with-android-upload-key.js firma release con STS_UPLOAD_* da ~/.gradle/gradle.properties with-android-solo-verticale.js verticale anche sui tablet con Android 16 scripts/ estrai-testi.ts Localizable.strings della 1.5 -> src/testi/*.ts prepara-immagini.py foto, sfondi, icone, suoni della 1.5 -> assets/ e src/immagini.ts assets/ immagini e suoni (generati dallo script, versionati) src/ gioco.ts, gioco.test.ts regole della partita, senza React testi/index.ts lingua, t(), nomi di luoghi e ruoli testi/it.ts en.ts fr.ts testi dell'interfaccia (it.ts definisce le chiavi) testi/luoghi.ts 59 luoghi x 6 ruoli x 3 lingue (generato) testi/testi.test.ts immagini.ts require() di tutte le immagini (generato) suoni.ts suona('tick') e simili notifica.ts notifica locale di fine tempo ui/App.tsx font, splash, navigazione ui/navigazione.ts tipo delle schermate ui/stato.tsx Context: lingua, impostazioni, partita, luoghi recenti ui/componenti.tsx Sfondo, Pulsante, Riquadro, Contatore, SceltaStile, useBloccaIndietro ui/GrigliaLuoghi.tsx griglia dei luoghi (consultazione e scelta della spia) ui/schermate/Home.tsx Impostazioni.tsx Luoghi.tsx Regole.tsx Giro.tsx Timer.tsx CLAUDE.md, docs/BUILD.md istruzioni per le sessioni future ``` --- ### Task 1: Progetto Expo che si avvia su iOS e Android **Files:** - Create: tutto lo scheletro in `/Users/paolo/dev/spotthespy` (`package.json`, `app.json`, `tsconfig.json`, `index.ts`, `App.tsx`, `plugins/*.js`, `.gitignore`) **Interfaces:** - Produces: progetto Expo 57 con tutte le dipendenze installate; comandi di build per simulatore iOS ed emulatore Android che funzionano. I task successivi aggiungono solo file in `src/`, `assets/`, `scripts/` e toccano `app.json`. - [ ] **Step 1: Creare il progetto in una cartella temporanea e spostarlo nel clone** Il clone contiene già `docs/` e `.gitignore`, e `create-expo-app` vuole una cartella vuota. ```bash cd /Users/paolo/dev npx create-expo-app@latest sts-tmp --template blank-typescript --no-install cd sts-tmp && grep '"expo"' package.json ``` Expected: `"expo": "~57.x.x"` (o `^57`). Se la versione non è la 57, fermarsi e usare `--template blank-typescript@sdk-57`. ```bash rsync -a --exclude .git --exclude .gitignore /Users/paolo/dev/sts-tmp/ /Users/paolo/dev/spotthespy/ rm -rf /Users/paolo/dev/sts-tmp ``` - [ ] **Step 2: `.gitignore` completo** Sostituire `/Users/paolo/dev/spotthespy/.gitignore` con: ```gitignore # Vecchie versioni in Swift e materiale grafico: restano sul NAS, fuori dalla storia spotTheSpy 1.*/ - mat/ .DS_Store node_modules/ .expo/ dist/ build/ *.log # Generati da expo prebuild a ogni build ios/ android/ ``` - [ ] **Step 3: Installare le dipendenze** ```bash cd /Users/paolo/dev/spotthespy npm install npx expo install expo-audio expo-notifications expo-haptics expo-keep-awake expo-localization expo-image expo-linear-gradient expo-sqlite expo-store-review expo-splash-screen expo-font expo-status-bar react-native-safe-area-context react-native-screens @react-navigation/native @react-navigation/native-stack @expo-google-fonts/barlow-condensed npm install -D @types/node ``` Expected: nessun errore; `npx expo install --check` risponde che le dipendenze sono allineate. - [ ] **Step 4: Verificare le API che il piano usa** ```bash cd /Users/paolo/dev/spotthespy/node_modules grep -n "getItemSync\|setItemSync" expo-sqlite/build/Storage.d.ts | head grep -n "export declare function createAudioPlayer\|playsInSilentMode\|interruptionMode" -r expo-audio/build/*.d.ts | head grep -n "TIME_INTERVAL\|channelId" expo-notifications/build/Notifications.types.d.ts | head grep -rn "microphonePermission\|recordAudioAndroid" expo-audio/plugin/build/*.d.ts expo-audio/plugin/build/*.js | head ls @expo-google-fonts/barlow-condensed | grep -E "500Medium|700Bold" ``` Expected: ogni grep trova qualcosa. Annotare le differenze rispetto ai nomi usati nei task 4 e 5 (`Storage.getItemSync`, `createAudioPlayer`, `setAudioModeAsync({ playsInSilentMode, interruptionMode })`, `SchedulableTriggerInputTypes.TIME_INTERVAL`, opzioni `microphonePermission` e `recordAudioAndroid` del plugin di expo-audio). - [ ] **Step 5: `tsconfig.json`, `package.json` (script) e `App.tsx`** `tsconfig.json`: ```json { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "allowImportingTsExtensions": true, "noEmit": true, "types": ["node"] } } ``` In `package.json`, nella sezione `scripts`, aggiungere: ```json "test": "node --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" ``` `App.tsx` (per ora una schermata vuota; il task 5 lo fa puntare a `src/ui/App.tsx`): ```tsx import { Text, View } from 'react-native'; export default function App() { return ( Spot The Spy ); } ``` - [ ] **Step 6: Plugin nativi copiati da Quanto Basta** ```bash cd /Users/paolo/dev/spotthespy && mkdir -p plugins cp /Users/paolo/dev/quantobasta/plugins/with-ios-scene-delegate.js /Users/paolo/dev/quantobasta/plugins/with-android-solo-verticale.js /Users/paolo/dev/quantobasta/plugins/with-android-upload-key.js plugins/ sed -i '' 's/quantobasta-scene-delegate/spotthespy-scene-delegate/' plugins/with-ios-scene-delegate.js sed -i '' 's/quantobasta-upload-key/spotthespy-upload-key/; s/QB_UPLOAD_/STS_UPLOAD_/g' plugins/with-android-upload-key.js grep -c "STS_UPLOAD_" plugins/with-android-upload-key.js grep -c "QB_\|quantobasta" plugins/*.js ``` Expected: il primo conteggio è maggiore di 0, il secondo dà `0` per tutti e tre i file. - [ ] **Step 7: `app.json`** Sostituire il contenuto con (icona e splash per ora puntano ai file del template; il task 4 li cambia): ```json { "expo": { "name": "Spot The Spy", "slug": "spotthespy", "version": "2.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", "backgroundColor": "#000000", "ios": { "supportsTablet": true, "bundleIdentifier": "paoloalby.SpotTheSpy", "buildNumber": "1", "config": { "usesNonExemptEncryption": false }, "infoPlist": { "UIRequiresFullScreen": true } }, "android": { "package": "it.duebytes.spotthespy", "versionCode": 1, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#315069" }, "predictiveBackGestureEnabled": false, "blockedPermissions": ["android.permission.RECORD_AUDIO", "android.permission.SYSTEM_ALERT_WINDOW"] }, "plugins": [ "expo-localization", "expo-font", "expo-sqlite", ["expo-audio", { "microphonePermission": false, "recordAudioAndroid": false }], ["expo-splash-screen", { "image": "./assets/splash-icon.png", "imageWidth": 220, "resizeMode": "contain", "backgroundColor": "#0B0D12" }], "./plugins/with-ios-scene-delegate", "./plugins/with-android-upload-key", "./plugins/with-android-solo-verticale" ] } } ``` Se il template non ha `assets/adaptive-icon.png` o `assets/splash-icon.png`, usare per ora `./assets/icon.png` in entrambi i campi. - [ ] **Step 8: Build e avvio sul simulatore iOS (iPhone 17)** ```bash cd /Users/paolo/dev/spotthespy npx expo prebuild --clean --no-install && (cd ios && pod install) xcrun simctl boot "iPhone 17" 2>/dev/null; open -a Simulator cd ios && xcodebuild -workspace SpotTheSpy.xcworkspace -scheme SpotTheSpy -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ../build/sim -quiet xcrun simctl install booted ../build/sim/Build/Products/Release-iphonesimulator/SpotTheSpy.app xcrun simctl launch booted paoloalby.SpotTheSpy sleep 5 && xcrun simctl io booted screenshot /tmp/sts-ios.png ``` Expected: build riuscita; lo screenshot (aprirlo con Read) mostra «Spot The Spy» bianco su nero. Se il nome dello workspace è diverso, `ls ios/*.xcworkspace`. - [ ] **Step 9: Emulatori Android propri (telefono e tablet)** ```bash export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ls $ANDROID_HOME/system-images/android-36/ ``` Usare l'immagine che c'è (es. `google_apis/arm64-v8a`): ```bash bash -c 'export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools; IMG="system-images;android-36;google_apis;arm64-v8a" echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n SpotTheSpy_Pixel -k "$IMG" -d pixel_8 echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n SpotTheSpy_Tablet -k "$IMG" -d pixel_tablet' ``` (Se `cmdline-tools/latest` non esiste, cercare `avdmanager` con `find $ANDROID_HOME -name avdmanager`.) Avvio del telefono in background sulla porta 5580: ```bash /opt/homebrew/share/android-commandlinetools/emulator/emulator -avd SpotTheSpy_Pixel -port 5580 -no-snapshot-save ``` (con `run_in_background`), poi `adb -s emulator-5580 wait-for-device` e attendere `adb -s emulator-5580 shell getprop sys.boot_completed` = `1`. - [ ] **Step 10: Build e avvio su Android** ```bash cd /Users/paolo/dev/spotthespy echo "sdk.dir=/opt/homebrew/share/android-commandlinetools" > android/local.properties cd android && JAVA_HOME=~/.jdks/jdk-17.0.20.1+1/Contents/Home ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -q adb -s emulator-5580 push app/build/outputs/apk/release/app-release.apk /data/local/tmp/sts.apk adb -s emulator-5580 shell pm install -r /data/local/tmp/sts.apk adb -s emulator-5580 shell monkey -p it.duebytes.spotthespy -c android.intent.category.LAUNCHER 1 sleep 5 && adb -s emulator-5580 exec-out screencap -p > /tmp/sts-android.png ``` Expected: lo screenshot mostra «Spot The Spy». La release senza `STS_UPLOAD_*` in `~/.gradle/gradle.properties` è firmata con la chiave di debug: va bene per l'emulatore. - [ ] **Step 11: Commit e push** ```bash cd /Users/paolo/dev/spotthespy git add -A && git status --short | head -30 git commit -m "Scheletro Expo 57 con i plugin nativi di Quanto Basta Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` Expected: nello `status` non compaiono `ios/`, `android/`, `node_modules/`, `build/`. --- ### Task 2: Logica di gioco **Files:** - Create: `src/gioco.ts` - Test: `src/gioco.test.ts` **Interfaces:** - Produces: - `type Stile = 'a' | 'b' | 'c'`, `STILI: readonly Stile[]` - `MIN_GIOCATORI = 3`, `MIN_MINUTI = 5`, `MASSIMO = 99`, `RUOLI_PER_LUOGO = 6`, `LUOGHI_DA_NON_RIPETERE = 10` - `LUOGHI: readonly number[]` (1-60 senza 30) - `interface Impostazioni { giocatori: number; minuti: number; stile: Stile }`, `PREDEFINITE` - `spiePer(giocatori: number): number` - `aggiungiGiocatore / togliGiocatore / aggiungiMinuto / togliMinuto (imp: Impostazioni): Impostazioni` - `validaImpostazioni(x: unknown): Impostazioni`, `validaRecenti(x: unknown): number[]` - `interface Partita { giocatori: number; minuti: number; spie: number[]; luogo: number; ruoloIniziale: number }` - `nuovaPartita(imp: Impostazioni, recenti: readonly number[], caso?: () => number): { partita: Partita; recenti: number[] }` - `ruoloDi(p: Partita, giocatore: number): number | 'spia'` (indice del ruolo 0-5) - `esitoTentativo(scelto: number, vero: number, tentativi: number): 'vinto' | 'perso' | 'riprova'` - `secondiRimasti(fine: number, adesso: number): number`, `mmss(secondi: number): string` - `devoChiedereRecensione(aperture: number): boolean` - [ ] **Step 1: Scrivere i test** `src/gioco.test.ts`: ```ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { LUOGHI, PREDEFINITE, aggiungiGiocatore, aggiungiMinuto, devoChiedereRecensione, esitoTentativo, mmss, nuovaPartita, ruoloDi, secondiRimasti, spiePer, togliGiocatore, togliMinuto, validaImpostazioni, validaRecenti, type Impostazioni, type Partita, } from './gioco.ts'; /** Generatore ripetibile per i test: sempre la stessa sequenza. */ function casoFisso(seme = 1): () => number { let s = seme; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; } test('59 luoghi, senza il 30', () => { assert.equal(LUOGHI.length, 59); assert.ok(!LUOGHI.includes(30)); assert.equal(LUOGHI[0], 1); assert.equal(LUOGHI.at(-1), 60); }); test('una spia ogni 8 giocatori, arrotondando in su', () => { assert.equal(spiePer(3), 1); assert.equal(spiePer(8), 1); assert.equal(spiePer(9), 2); assert.equal(spiePer(16), 2); assert.equal(spiePer(17), 3); }); test('aggiungere un giocatore porta i minuti almeno al numero di giocatori', () => { const imp: Impostazioni = { giocatori: 8, minuti: 8, stile: 'c' }; assert.deepEqual(aggiungiGiocatore(imp), { giocatori: 9, minuti: 9, stile: 'c' }); assert.deepEqual(aggiungiGiocatore({ ...imp, minuti: 12 }), { giocatori: 9, minuti: 12, stile: 'c' }); }); test('togliere un giocatore abbassa i minuti di uno se li supera, mai sotto 5', () => { assert.deepEqual(togliGiocatore({ giocatori: 9, minuti: 9, stile: 'a' }), { giocatori: 8, minuti: 8, stile: 'a' }); assert.deepEqual(togliGiocatore({ giocatori: 6, minuti: 5, stile: 'a' }), { giocatori: 5, minuti: 5, stile: 'a' }); assert.deepEqual(togliGiocatore({ giocatori: 8, minuti: 8, stile: 'a' }), { giocatori: 7, minuti: 8, stile: 'a' }); }); test('mai meno di 3 giocatori né meno di 5 minuti, mai più di 99', () => { const minimo: Impostazioni = { giocatori: 3, minuti: 5, stile: 'b' }; assert.deepEqual(togliGiocatore(minimo), minimo); assert.deepEqual(togliMinuto(minimo), minimo); assert.equal(aggiungiMinuto(minimo).minuti, 6); const massimo: Impostazioni = { giocatori: 99, minuti: 99, stile: 'b' }; assert.deepEqual(aggiungiGiocatore(massimo), massimo); assert.deepEqual(aggiungiMinuto(massimo), massimo); }); test('impostazioni salvate male tornano ai valori predefiniti', () => { assert.deepEqual(validaImpostazioni(null), PREDEFINITE); assert.deepEqual(validaImpostazioni('ciao'), PREDEFINITE); assert.deepEqual(validaImpostazioni({ giocatori: 2, minuti: 4.5, stile: 'z' }), PREDEFINITE); assert.deepEqual(validaImpostazioni({ giocatori: 12, minuti: 15, stile: 'a' }), { giocatori: 12, minuti: 15, stile: 'a' }); }); test('luoghi recenti salvati male vengono ripuliti', () => { assert.deepEqual(validaRecenti(null), []); assert.deepEqual(validaRecenti([1, 30, 'x', 61, 5]), [1, 5]); assert.equal(validaRecenti(Array.from({ length: 20 }, (_, i) => i + 1)).length, 10); }); test('una partita ha il numero giusto di spie, tutte diverse e dentro il tavolo', () => { const caso = casoFisso(7); for (let giocatori = 3; giocatori <= 30; giocatori++) { const { partita } = nuovaPartita({ giocatori, minuti: 10, stile: 'c' }, [], caso); assert.equal(partita.spie.length, spiePer(giocatori)); assert.equal(new Set(partita.spie).size, partita.spie.length); for (const s of partita.spie) assert.ok(s >= 0 && s < giocatori); assert.ok(LUOGHI.includes(partita.luogo)); assert.ok(partita.ruoloIniziale >= 0 && partita.ruoloIniziale < 6); } }); test('il luogo non è fra gli ultimi 10 e la lista dei recenti resta di 10', () => { const caso = casoFisso(3); let recenti: number[] = []; for (let i = 0; i < 40; i++) { const r = nuovaPartita(PREDEFINITE, recenti, caso); assert.ok(!recenti.includes(r.partita.luogo)); assert.equal(r.recenti.at(-1), r.partita.luogo); assert.ok(r.recenti.length <= 10); recenti = r.recenti; } assert.equal(recenti.length, 10); }); test('i ruoli girano in ordine e la spia non fa avanzare il giro', () => { const p: Partita = { giocatori: 9, minuti: 9, spie: [1, 4], luogo: 5, ruoloIniziale: 4 }; assert.deepEqual( Array.from({ length: 9 }, (_, g) => ruoloDi(p, g)), [4, 'spia', 5, 0, 'spia', 1, 2, 3, 4], ); }); test('la spia che indovina vince; se sbaglia riprova finché ha tentativi', () => { assert.equal(esitoTentativo(12, 12, 1), 'vinto'); assert.equal(esitoTentativo(12, 13, 2), 'riprova'); assert.equal(esitoTentativo(12, 13, 1), 'perso'); }); test('secondi rimasti e formato mm:ss', () => { assert.equal(secondiRimasti(10_000, 0), 10); assert.equal(secondiRimasti(10_000, 9_001), 1); assert.equal(secondiRimasti(10_000, 10_000), 0); assert.equal(secondiRimasti(10_000, 99_000), 0); assert.equal(mmss(0), '00:00'); assert.equal(mmss(298), '04:58'); assert.equal(mmss(600), '10:00'); }); test('la recensione si chiede alla 3a, 10a, 50a apertura e poi ogni 100', () => { const si = Array.from({ length: 300 }, (_, i) => i + 1).filter(devoChiedereRecensione); assert.deepEqual(si, [3, 10, 50, 100, 200, 300]); }); ``` - [ ] **Step 2: Verificare che falliscano** Run: `cd /Users/paolo/dev/spotthespy && npm test` Expected: FAIL, `Cannot find module .../src/gioco.ts`. - [ ] **Step 3: Implementare `src/gioco.ts`** ```ts /** * Le regole di Spot The Spy, come nella 1.5. Niente React né Expo qui dentro, * così i test girano con node. */ export type Stile = 'a' | 'b' | 'c'; export const STILI: readonly Stile[] = ['a', 'b', 'c']; export const MIN_GIOCATORI = 3; export const MIN_MINUTI = 5; /** Tetto di giocatori e minuti, solo perché i numeri stiano nello schermo. */ export const MASSIMO = 99; export const GIOCATORI_PER_SPIA = 8; export const RUOLI_PER_LUOGO = 6; export const LUOGHI_DA_NON_RIPETERE = 10; /** I luoghi della 1.5 vanno da 1 a 60; il 30 (McDonald's) è stato tolto. */ export const LUOGHI: readonly number[] = Array.from({ length: 60 }, (_, i) => i + 1).filter((n) => n !== 30); export interface Impostazioni { giocatori: number; minuti: number; stile: Stile; } export const PREDEFINITE: Impostazioni = { giocatori: 8, minuti: 8, stile: 'c' }; export function spiePer(giocatori: number): number { return Math.ceil(giocatori / GIOCATORI_PER_SPIA); } /** Con un giocatore in più i minuti salgono, se serve, per restare almeno uno a testa. */ export function aggiungiGiocatore(imp: Impostazioni): Impostazioni { if (imp.giocatori >= MASSIMO) return imp; const giocatori = imp.giocatori + 1; const minuti = giocatori > imp.minuti ? Math.min(imp.minuti + 1, MASSIMO) : imp.minuti; return { ...imp, giocatori, minuti }; } export function togliGiocatore(imp: Impostazioni): Impostazioni { if (imp.giocatori <= MIN_GIOCATORI) return imp; const giocatori = imp.giocatori - 1; const scende = giocatori < imp.minuti && imp.minuti > MIN_MINUTI; return { ...imp, giocatori, minuti: scende ? imp.minuti - 1 : imp.minuti }; } export function aggiungiMinuto(imp: Impostazioni): Impostazioni { return imp.minuti >= MASSIMO ? imp : { ...imp, minuti: imp.minuti + 1 }; } export function togliMinuto(imp: Impostazioni): Impostazioni { return imp.minuti <= MIN_MINUTI ? imp : { ...imp, minuti: imp.minuti - 1 }; } function interoFra(v: unknown, min: number, max: number): number | null { return typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max ? v : null; } /** Quello che si legge dall'archivio non è affidabile: ogni campo sbagliato torna al predefinito. */ export function validaImpostazioni(x: unknown): Impostazioni { const o = (typeof x === 'object' && x !== null ? x : {}) as Record; return { giocatori: interoFra(o.giocatori, MIN_GIOCATORI, MASSIMO) ?? PREDEFINITE.giocatori, minuti: interoFra(o.minuti, MIN_MINUTI, MASSIMO) ?? PREDEFINITE.minuti, stile: STILI.includes(o.stile as Stile) ? (o.stile as Stile) : PREDEFINITE.stile, }; } export function validaRecenti(x: unknown): number[] { if (!Array.isArray(x)) return []; return x.filter((n): n is number => LUOGHI.includes(n as number)).slice(-LUOGHI_DA_NON_RIPETERE); } export interface Partita { giocatori: number; minuti: number; /** Indici (da 0) dei giocatori che sono spie, in ordine. */ spie: number[]; luogo: number; /** Indice (0-5) del ruolo che tocca al primo giocatore che non è spia. */ ruoloIniziale: number; } function intero(max: number, caso: () => number): number { return Math.floor(caso() * max); } /** Estrae spie, luogo (non fra i recenti) e primo ruolo; restituisce anche i recenti aggiornati. */ export function nuovaPartita( imp: Impostazioni, recenti: readonly number[], caso: () => number = Math.random, ): { partita: Partita; recenti: number[] } { const posti = Array.from({ length: imp.giocatori }, (_, i) => i); const nSpie = spiePer(imp.giocatori); for (let i = 0; i < nSpie; i++) { const j = i + intero(posti.length - i, caso); [posti[i], posti[j]] = [posti[j], posti[i]]; } const spie = posti.slice(0, nSpie).sort((a, b) => a - b); const liberi = LUOGHI.filter((l) => !recenti.includes(l)); const luogo = liberi[intero(liberi.length, caso)]; return { partita: { giocatori: imp.giocatori, minuti: imp.minuti, spie, luogo, ruoloIniziale: intero(RUOLI_PER_LUOGO, caso) }, recenti: [...recenti, luogo].slice(-LUOGHI_DA_NON_RIPETERE), }; } /** I ruoli girano sui 6 del luogo; chi è spia non consuma un ruolo. */ export function ruoloDi(p: Partita, giocatore: number): number | 'spia' { if (p.spie.includes(giocatore)) return 'spia'; const spiePrima = p.spie.filter((s) => s < giocatore).length; return (p.ruoloIniziale + giocatore - spiePrima) % RUOLI_PER_LUOGO; } /** La spia ha un tentativo per ogni spia in gioco. */ export function esitoTentativo(scelto: number, vero: number, tentativi: number): 'vinto' | 'perso' | 'riprova' { if (scelto === vero) return 'vinto'; return tentativi <= 1 ? 'perso' : 'riprova'; } export function secondiRimasti(fine: number, adesso: number): number { return Math.max(0, Math.ceil((fine - adesso) / 1000)); } export function mmss(secondi: number): string { const due = (n: number) => String(n).padStart(2, '0'); return `${due(Math.floor(secondi / 60))}:${due(secondi % 60)}`; } export function devoChiedereRecensione(aperture: number): boolean { return aperture === 3 || aperture === 10 || aperture === 50 || (aperture > 0 && aperture % 100 === 0); } ``` - [ ] **Step 4: Test e controllo dei tipi** Run: `npm test && npm run typecheck` Expected: tutti i test PASS, `tsc` senza errori. - [ ] **Step 5: Commit e push** ```bash git add src/gioco.ts src/gioco.test.ts git commit -m "Regole della partita, con i test Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 3: Testi in tre lingue **Files:** - Create: `scripts/estrai-testi.ts`, `src/testi/index.ts`, `src/testi/it.ts`, `src/testi/en.ts`, `src/testi/fr.ts`, `src/testi/luoghi.ts` - Test: `src/testi/testi.test.ts` **Interfaces:** - Consumes: `LUOGHI`, `RUOLI_PER_LUOGO` da `src/gioco.ts` - Produces: - `type Lingua = 'it' | 'en' | 'fr'`, `type Chiave = keyof typeof IT` - `linguaDaTag(tag: string | null | undefined): Lingua` - `t(lingua: Lingua, chiave: Chiave): string` - `nomeLuogo(lingua: Lingua, id: number): string`, `nomeRuolo(lingua: Lingua, id: number, indice: number): string` - Chiavi: `home.gioca home.regole config.desc config.giocatori config.spie config.minuti config.stile config.inizia config.luoghi config.regole avviso.desc avviso.mostra ruolo.luogo ruolo.ruolo ruolo.ricevuto ruolo.spia conferma.desc conferma.fatto timer.accusa timer.indovina timer.concludi timer.nuova timer.riprendi timer.spiaSi timer.spiaNo timer.scaduto dialogo.annulla dialogo.attenzione dialogo.sbagliato dialogo.concludi dialogo.indovina dialogo.seleziona dialogo.altroTentativo notifica.testo notifica.canale regole.chiudi regole.indietro regole.avanti regole.p1.tit1 regole.p1.tit2 regole.p1.txt1 regole.p1.txt2 regole.p1.txt3 regole.p2.tit1 regole.p2.tit2 regole.p2.txt1 regole.p2.txt2 regole.p3.tit1 regole.p3.txt1 regole.p4.tit1 regole.p4.txt1 regole.p5.tit1 regole.p5.txt1` - [ ] **Step 1: Scrivere i test** `src/testi/testi.test.ts`: ```ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { LUOGHI, RUOLI_PER_LUOGO } from '../gioco.ts'; import { IT } from './it.ts'; import { EN } from './en.ts'; import { FR } from './fr.ts'; import { LUOGHI_TESTI } from './luoghi.ts'; import { linguaDaTag, nomeLuogo, nomeRuolo, t } from './index.ts'; const LINGUE = ['it', 'en', 'fr'] as const; test('le tre lingue hanno le stesse chiavi', () => { assert.deepEqual(Object.keys(EN).sort(), Object.keys(IT).sort()); assert.deepEqual(Object.keys(FR).sort(), Object.keys(IT).sort()); }); test('nessun testo vuoto e nessuno spazio attorno agli a capo', () => { for (const diz of [IT, EN, FR]) { for (const [k, v] of Object.entries(diz)) { assert.ok(v.trim().length > 0, `vuoto: ${k}`); assert.equal(v, v.trim(), `spazi ai bordi: ${k}`); assert.ok(!/ \n|\n /.test(v), `spazio attorno a un a capo: ${k}`); } } }); test('ogni luogo ha nome e 6 ruoli in tutte le lingue, e il 30 non c\'è', () => { assert.deepEqual(Object.keys(LUOGHI_TESTI).map(Number).sort((a, b) => a - b), [...LUOGHI]); for (const id of LUOGHI) { for (const l of LINGUE) { assert.ok(nomeLuogo(l, id).length > 0, `nome ${id} ${l}`); for (let r = 0; r < RUOLI_PER_LUOGO; r++) assert.ok(nomeRuolo(l, id, r).length > 0, `ruolo ${id}/${r} ${l}`); } } }); test('nessuna traccia di McDonald\'s', () => { const tutto = JSON.stringify([IT, EN, FR, LUOGHI_TESTI]).toLowerCase(); assert.ok(!tutto.includes('mcdonald')); }); test('lingua dal tag del telefono: italiano e francese, altrimenti inglese', () => { assert.equal(linguaDaTag('it-IT'), 'it'); assert.equal(linguaDaTag('fr-CA'), 'fr'); assert.equal(linguaDaTag('en-US'), 'en'); assert.equal(linguaDaTag('de-DE'), 'en'); assert.equal(linguaDaTag(null), 'en'); }); test('t() restituisce il testo della lingua', () => { assert.equal(t('it', 'home.gioca'), IT['home.gioca']); assert.equal(t('fr', 'home.gioca'), FR['home.gioca']); }); ``` - [ ] **Step 2: Verificare che falliscano** Run: `npm test` Expected: FAIL, `Cannot find module .../src/testi/it.ts`. - [ ] **Step 3: Script di estrazione** `scripts/estrai-testi.ts`: ```ts /** * Porta i testi della 1.5 (Localizable.strings di iOS) nell'app nuova. * Si lancia una volta sola; poi i file generati si rileggono e si correggono a mano. * * node scripts/estrai-testi.ts "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy/spotTheSpy 1.5/spotTheSpy" */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { LUOGHI, RUOLI_PER_LUOGO } from '../src/gioco.ts'; const V15 = process.argv[2]; if (!V15) throw new Error('manca la cartella della 1.5'); const CARTELLE = { it: 'it.lproj', en: 'Base.lproj', fr: 'fr.lproj' } as const; type Lingua = keyof typeof CARTELLE; /** Chiave vecchia -> chiave nuova, per i testi dell'interfaccia. */ const MAPPA: Record = { 'Gioca': 'home.gioca', 'Regole del Gioco': 'home.regole', 'Desc_config': 'config.desc', 'Numero Giocatori': 'config.giocatori', 'Numero Spie': 'config.spie', 'Durata partita': 'config.minuti', 'Stile immagini': 'config.stile', 'Inizia': 'config.inizia', 'Locations': 'config.luoghi', 'Regole': 'config.regole', 'Desc_alert': 'avviso.desc', 'Mostrami': 'avviso.mostra', 'Luogo': 'ruolo.luogo', 'Ruolo': 'ruolo.ruolo', 'Ricevuto': 'ruolo.ricevuto', 'Sei la SPIA': 'ruolo.spia', 'Desc_conferma': 'conferma.desc', 'Fatto': 'conferma.fatto', 'Accusa': 'timer.accusa', 'Indovina': 'timer.indovina', 'Concludi': 'timer.concludi', 'Nuova': 'timer.nuova', 'Riprendi': 'timer.riprendi', 'Spia si': 'timer.spiaSi', 'Spia no': 'timer.spiaNo', 'Tempo scaduto': 'timer.scaduto', 'Annulla': 'dialogo.annulla', 'Attenzione': 'dialogo.attenzione', 'Sbagliato': 'dialogo.sbagliato', 'Msg_concludi': 'dialogo.concludi', 'Msg_indovina': 'dialogo.indovina', 'Msg_seleziona': 'dialogo.seleziona', 'Msg_altro_tentativo': 'dialogo.altroTentativo', 'not_body': 'notifica.testo', 'btn_chiudi': 'regole.chiudi', 'btn_indietro': 'regole.indietro', 'btn_prosegui': 'regole.avanti', 'p1_tit1': 'regole.p1.tit1', 'p1_tit2': 'regole.p1.tit2', 'p1_txt1': 'regole.p1.txt1', 'p1_txt2': 'regole.p1.txt2', 'p1_txt3': 'regole.p1.txt3', 'p2_tit1': 'regole.p2.tit1', 'p2_tit2': 'regole.p2.tit2', 'p2_txt1': 'regole.p2.txt1', 'p2_txt2': 'regole.p2.txt2', 'p3_tit1': 'regole.p3.tit1', 'p3_txt1': 'regole.p3.txt1', 'p4_tit1': 'regole.p4.tit1', 'p4_txt1': 'regole.p4.txt1', 'p5_tit1': 'regole.p5.tit1', 'p5_txt1': 'regole.p5.txt1', }; /** Testi nuovi, che la 1.5 non aveva. */ const NUOVI: Record> = { it: { 'notifica.canale': 'Fine del tempo' }, en: { 'notifica.canale': "Time's up" }, fr: { 'notifica.canale': 'Fin du temps' }, }; function leggi(file: string): Map { const testo = readFileSync(file, 'utf8'); const voci = new Map(); const re = /"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"\s*;/g; for (const m of testo.matchAll(re)) { const valore = m[2] .replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') .replace(/[ \t]*\n[ \t]*/g, '\n') .trim(); voci.set(m[1], valore); } return voci; } const voci = Object.fromEntries( (Object.keys(CARTELLE) as Lingua[]).map((l) => [l, leggi(join(V15, CARTELLE[l], 'Localizable.strings'))]), ) as Record>; function prendi(l: Lingua, chiave: string): string { const v = voci[l].get(chiave); if (v === undefined) throw new Error(`manca "${chiave}" in ${l}`); return v; } mkdirSync('src/testi', { recursive: true }); const NOMI = { it: 'IT', en: 'EN', fr: 'FR' } as const; for (const l of Object.keys(CARTELLE) as Lingua[]) { const diz: Record = {}; for (const [vecchia, nuova] of Object.entries(MAPPA)) diz[nuova] = prendi(l, vecchia); Object.assign(diz, NUOVI[l]); const intestazione = l === 'it' ? `// Testi dell'interfaccia in italiano. Le chiavi di questo file sono l'elenco\n// di riferimento: le altre lingue devono averle tutte (lo controlla il tipo).\n\nexport const IT = ` : `import type { Chiave } from './index.ts';\n\nexport const ${NOMI[l]}: Record = `; const coda = l === 'it' ? ' as const;\n' : ';\n'; writeFileSync(`src/testi/${l}.ts`, intestazione + JSON.stringify(diz, null, 2) + coda); } const luoghi: Record; ruoli: Record }> = {}; for (const id of LUOGHI) { const nome = {} as Record; const ruoli = {} as Record; for (const l of Object.keys(CARTELLE) as Lingua[]) { nome[l] = prendi(l, `l${id}`); ruoli[l] = Array.from({ length: RUOLI_PER_LUOGO }, (_, r) => prendi(l, `l${id}_r${r + 1}`)); } luoghi[id] = { nome, ruoli }; } writeFileSync( 'src/testi/luoghi.ts', `// Nomi e ruoli dei 59 luoghi, estratti dalla 1.5 con scripts/estrai-testi.ts e poi riletti.\n\n` + `type PerLingua = Record<'it' | 'en' | 'fr', T>;\n\n` + `export const LUOGHI_TESTI: Record; ruoli: PerLingua }> = ` + JSON.stringify(luoghi, null, 2) + ';\n', ); console.log('scritti src/testi/{it,en,fr,luoghi}.ts'); ``` - [ ] **Step 4: Lanciare lo script** ```bash cd /Users/paolo/dev/spotthespy node scripts/estrai-testi.ts "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy/spotTheSpy 1.5/spotTheSpy" wc -l src/testi/*.ts ``` Expected: `scritti src/testi/{it,en,fr,luoghi}.ts`; `luoghi.ts` di qualche migliaio di righe. - [ ] **Step 5: `src/testi/index.ts`** ```ts /** * Testi dell'app in italiano, inglese e francese. La lingua è quella del * telefono (vedi linguaDaTag); ogni altra lingua usa l'inglese. */ import { IT } from './it.ts'; import { EN } from './en.ts'; import { FR } from './fr.ts'; import { LUOGHI_TESTI } from './luoghi.ts'; export type Lingua = 'it' | 'en' | 'fr'; export type Chiave = keyof typeof IT; const DIZIONARI: Record> = { it: IT, en: EN, fr: FR }; export function linguaDaTag(tag: string | null | undefined): Lingua { const l = (tag ?? '').slice(0, 2).toLowerCase(); return l === 'it' || l === 'fr' ? l : 'en'; } export function t(lingua: Lingua, chiave: Chiave): string { return DIZIONARI[lingua][chiave]; } export function nomeLuogo(lingua: Lingua, id: number): string { return LUOGHI_TESTI[id].nome[lingua]; } export function nomeRuolo(lingua: Lingua, id: number, indice: number): string { return LUOGHI_TESTI[id].ruoli[lingua][indice]; } ``` - [ ] **Step 6: Test** Run: `npm test && npm run typecheck` Expected: PASS. Se il test degli spazi fallisce, correggere la regola di pulizia nello script e rilanciarlo (non ritoccare a mano i file prima di aver rilanciato). - [ ] **Step 7: Commit dei testi estratti così come sono** ```bash git add scripts/estrai-testi.ts src/testi git commit -m "Testi e luoghi estratti dalla 1.5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" ``` - [ ] **Step 8: Rilettura dei testi** Correggere a mano `it.ts`, `en.ts`, `fr.ts` e `luoghi.ts` con queste regole, e nient'altro: - errori di grammatica, ortografia e battitura; frasi che non si capiscono (l'inglese delle regole è una traduzione letterale dall'italiano: riscriverlo in inglese naturale tenendo lo stesso significato; per esempio `p5_txt1` finisce con «otherwise it would make the Spy too much», da completare in «otherwise you would make it too easy for the Spy»); - le maiuscole a caso dentro le frasi («Luogo dell'Avventura», «Spie») diventano minuscole, tranne «Spot The Spy»; - niente trattini lunghi (`—`), niente virgolette tipografiche miste: usare « » in italiano e francese, “ ” in inglese; - gli a capo forzati restano solo nelle etichette dei pulsanti affiancati (`timer.accusa`, `timer.spiaSi`, `timer.spiaNo`), negli altri testi si tolgono se spezzano una frase a metà; - nei ruoli: nessun ruolo offensivo verso aspetto, origine o disabilità; se c'è, sostituirlo con uno neutro dello stesso luogo, nelle tre lingue; - non cambiare le regole del gioco né aggiungere contenuti. Poi: `npm test && npm run typecheck` (PASS) e `git diff --stat`. - [ ] **Step 9: Commit e push** ```bash git add src/testi git commit -m "Testi riletti in italiano, inglese e francese Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 4: Immagini, icone e suoni **Files:** - Create: `scripts/prepara-immagini.py`, `assets/luoghi/*.jpg` (177), `assets/spia_{a,b,c}.jpg`, `assets/intro_{a,b,c}.jpg`, `assets/titolo.png`, `assets/mascherina.png`, `assets/icon.png`, `assets/adaptive-icon.png`, `assets/notifica.png`, `assets/suoni/*`, `src/immagini.ts` - Modify: `app.json` (icona, splash, plugin notifiche) - Delete: le immagini del template di Expo non più usate in `assets/` **Interfaces:** - Produces (in `src/immagini.ts`): - `FOTO_LUOGHI: Record>` - `FOTO_SPIA: Record`, `SFONDI: Record` - `TITOLO: number`, `MASCHERINA: number` (4:3, nera con un ovale trasparente) - Produces (file): `assets/suoni/{click,page,tick,vittoria,failure,suspense,paparapa}.{mp3|wav}` (vedi sotto per le estensioni) - [ ] **Step 1: Script** `scripts/prepara-immagini.py`: ```python #!/usr/bin/env python3 """Porta foto, sfondi, icona e suoni della 1.5 nell'app nuova e scrive src/immagini.ts. Si lancia dal clone locale, una volta (o dopo aver cambiato le immagini sorgente): python3 scripts/prepara-immagini.py "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" """ import pathlib import shutil import subprocess import sys from PIL import Image RADICE = pathlib.Path(sys.argv[1]) V15 = RADICE / 'spotTheSpy 1.5' / 'spotTheSpy' XC = V15 / 'Assets.xcassets' MAT = RADICE / ' - mat' PROGETTO = pathlib.Path(__file__).resolve().parent.parent OUT = PROGETTO / 'assets' STILI = 'abc' LUOGHI = [n for n in range(1, 61) if n != 30] # il 30 (McDonald's) è stato tolto FONDO_ICONA = (0x31, 0x50, 0x69) def jpg(src, dst, qualita): Image.open(src).convert('RGB').save(dst, 'JPEG', quality=qualita, optimize=True, progressive=True) (OUT / 'luoghi').mkdir(parents=True, exist_ok=True) (OUT / 'suoni').mkdir(exist_ok=True) for n in LUOGHI: for s in STILI: jpg(XC / f'{n:02d}_{s}.imageset' / f'{n:02d}_{s}.jpg', OUT / 'luoghi' / f'{n:02d}_{s}.jpg', 78) for s in STILI: jpg(XC / f'spia_{s}.imageset' / f'spia_{s}.jpg', OUT / f'spia_{s}.jpg', 80) jpg(XC / f'intro_{s}.imageset' / f'intro_{s}.jpg', OUT / f'intro_{s}.jpg', 80) Image.open(XC / 'titolo.imageset' / 'titolo.png').save(OUT / 'titolo.png', optimize=True) # Mascherina del timer: nella 1.5 era uno schermo intero nero con un ovale trasparente # al centro. Si ritaglia attorno all'ovale, in 4:3 come le foto, così si sovrappone # alla foto qualunque sia la forma dello schermo. m = Image.open(XC / 'timer.imageset' / 'timer.png').convert('RGBA') buco = m.getchannel('A').point(lambda v: 255 if v < 250 else 0) x0, y0, x1, y1 = buco.getbbox() cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 w = (x1 - x0) * 1.08 h = w * 3 / 4 if h < (y1 - y0) * 1.08: h = (y1 - y0) * 1.08 w = h * 4 / 3 m.crop((round(cx - w / 2), round(cy - h / 2), round(cx + w / 2), round(cy + h / 2))).save(OUT / 'mascherina.png', optimize=True) # Icona: iOS usa quella della 1.5 così com'è. Android la vuole adattiva: il simbolo # (cappello e punto di domanda) su fondo pieno, dentro la zona sicura centrale. icona = Image.open(MAT / 'icona' / 'AppIcon.png').convert('RGB') icona.save(OUT / 'icon.png') simbolo = icona.crop((140, 140, 900, 900)) primo_piano = Image.new('RGB', (1024, 1024), FONDO_ICONA) primo_piano.paste(simbolo.resize((600, 600), Image.LANCZOS), (212, 212)) primo_piano.save(OUT / 'adaptive-icon.png') # Icona delle notifiche Android: solo il simbolo, bianco su trasparente. chiaro = simbolo.convert('L').point(lambda v: 255 if v > 160 else 0) notifica = Image.new('RGBA', simbolo.size, (255, 255, 255, 0)) notifica.putalpha(chiaro) bianco = Image.new('RGBA', simbolo.size, (255, 255, 255, 255)) bianco.putalpha(chiaro) bianco.resize((96, 96), Image.LANCZOS).save(OUT / 'notifica.png') # Suoni. Il «paparapa» di fine tempo diventa .wav, perché le notifiche di iOS non # accettano .m4a; lo usa anche l'app, così il file è uno solo. SUONI = V15 / 'Sounds' for nome in ['click.mp3', 'page.mp3', 'failure.mp3', 'suspense.mp3', 'tick.wav', 'vittoria.wav']: shutil.copy(SUONI / nome, OUT / 'suoni' / nome) subprocess.run(['afconvert', '-f', 'WAVE', '-d', 'LEI16@44100', str(SUONI / 'paparapa.m4a'), str(OUT / 'suoni' / 'paparapa.wav')], check=True) # src/immagini.ts: React Native vuole require() con il percorso scritto per esteso. r = ['// Generato da scripts/prepara-immagini.py: non modificare a mano.', "import type { Stile } from './gioco.ts';", ''] r.append('export const FOTO_LUOGHI: Record> = {') for s in STILI: r.append(f' {s}: {{') r += [f" {n}: require('../assets/luoghi/{n:02d}_{s}.jpg')," for n in LUOGHI] r.append(' },') r += ['};', ''] r.append('export const FOTO_SPIA: Record = {') r += [f" {s}: require('../assets/spia_{s}.jpg')," for s in STILI] r += ['};', ''] r.append('export const SFONDI: Record = {') r += [f" {s}: require('../assets/intro_{s}.jpg')," for s in STILI] r += ['};', ''] r.append("export const TITOLO: number = require('../assets/titolo.png');") r.append("export const MASCHERINA: number = require('../assets/mascherina.png');") (PROGETTO / 'src' / 'immagini.ts').write_text('\n'.join(r) + '\n') print('fatto') ``` - [ ] **Step 2: Lanciarlo e controllare pesi e numeri** ```bash cd /Users/paolo/dev/spotthespy python3 scripts/prepara-immagini.py "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" ls assets/luoghi | wc -l du -sh assets ls assets/suoni ``` Expected: `fatto`; `177`; `assets` sotto i 25 MB; sette file di suono. `afinfo assets/suoni/paparapa.wav` dà circa 4,1 secondi (le notifiche iOS accettano fino a 30). - [ ] **Step 3: Controllare a occhio icone e mascherina** Aprire con Read `assets/adaptive-icon.png`, `assets/notifica.png`, `assets/mascherina.png`. Criteri: - `adaptive-icon.png`: simbolo intero, centrato, con almeno il 20% di margine su ogni lato, nessun bordo del vecchio pulsante visibile. Se si vede il bordo, restringere il ritaglio `(140, 140, 900, 900)` di 20 px per lato e rilanciare. - `notifica.png`: il cappello e il punto di domanda riconoscibili, bianchi. - `mascherina.png`: ovale trasparente al centro, bordi neri su tutti e quattro i lati. - [ ] **Step 4: Aggiornare `app.json`** Nella sezione `plugins` sostituire la riga di `expo-splash-screen` e aggiungere quella delle notifiche: ```json ["expo-splash-screen", { "image": "./assets/titolo.png", "imageWidth": 220, "resizeMode": "contain", "backgroundColor": "#0B0D12" }], ["expo-notifications", { "icon": "./assets/notifica.png", "color": "#22435E", "sounds": ["./assets/suoni/paparapa.wav"] }], ``` e in `android.adaptiveIcon` lasciare `"foregroundImage": "./assets/adaptive-icon.png"`, `"backgroundColor": "#315069"`. Poi togliere dal template i file non più usati: ```bash cd /Users/paolo/dev/spotthespy grep -o '\./assets/[^"]*' app.json | sort -u ls assets ``` e cancellare con `git rm` (o `rm` se non versionati) i file di `assets/` al primo livello che non compaiono né in `app.json` né in `src/immagini.ts` (per esempio `splash-icon.png`, `favicon.png`). - [ ] **Step 5: Controllo dei tipi** Run: `npm run typecheck` Expected: nessun errore. - [ ] **Step 6: Commit e push** ```bash git add -A scripts assets src/immagini.ts app.json git commit -m "Foto, sfondi, icone e suoni della 1.5, ricompressi Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 5: Stato, componenti, navigazione e Home **Files:** - Create: `src/suoni.ts`, `src/notifica.ts`, `src/ui/navigazione.ts`, `src/ui/stato.tsx`, `src/ui/componenti.tsx`, `src/ui/App.tsx`, `src/ui/schermate/Home.tsx` - Modify: `App.tsx` **Interfaces:** - Consumes: `src/gioco.ts`, `src/testi/index.ts`, `src/immagini.ts` - Produces: - `suona(s: Suono): void` con `type Suono = 'click' | 'page' | 'tick' | 'vittoria' | 'failure' | 'suspense' | 'paparapa'` - `preparaNotifiche(nomeCanale: string): Promise`, `programmaFineTempo(secondi: number, titolo: string, testo: string): Promise`, `annullaFineTempo(): Promise` - `type Schermate = { Home: undefined; Impostazioni: undefined; Giro: undefined; Timer: undefined; Luoghi: undefined; Regole: undefined }` - `useStato(): { lingua: Lingua; t: (k: Chiave) => string; imp: Impostazioni; setImp(i: Impostazioni): void; partita: Partita | null; setPartita(p: Partita | null): void; recenti: number[]; setRecenti(r: number[]): void }`, `StatoProvider` - componenti: `Sfondo`, `Pulsante({ titolo, onPress, suono?, disattivo?, style? })`, `Riquadro({ children, style? })`, `Contatore({ valore, meno, piu })`, `SceltaStile()`, `useBloccaIndietro()`, costanti `FONT`, `FONT_TESTO`, `COLORI`, stili `testi.desc`, `testi.etichetta`, `testi.grande` - [ ] **Step 1: `src/suoni.ts`** ```ts /** * Effetti sonori. Ogni suono ha il suo lettore, creato al primo uso e tenuto * per tutta la vita dell'app (sono sette file piccoli). * Come nella 1.5 i suoni rispettano l'interruttore del silenzioso. */ import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from 'expo-audio'; const FILE = { click: require('../assets/suoni/click.mp3'), page: require('../assets/suoni/page.mp3'), tick: require('../assets/suoni/tick.wav'), vittoria: require('../assets/suoni/vittoria.wav'), failure: require('../assets/suoni/failure.mp3'), suspense: require('../assets/suoni/suspense.mp3'), paparapa: require('../assets/suoni/paparapa.wav'), } as const; export type Suono = keyof typeof FILE; const lettori: Partial> = {}; setAudioModeAsync({ playsInSilentMode: false, interruptionMode: 'mixWithOthers' }).catch(() => {}); export function suona(s: Suono): void { try { const p = (lettori[s] ??= createAudioPlayer(FILE[s])); p.seekTo(0); p.play(); } catch { // Un suono che non parte non deve fermare la partita. } } ``` - [ ] **Step 2: `src/notifica.ts`** ```ts /** * La notifica di fine tempo: serve solo quando l'app è in background, perché * lì il timer JavaScript si ferma. Col telefono in mano il suono lo fa il timer. */ import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; const CANALE = 'fine-tempo'; const SUONO = 'paparapa.wav'; Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldPlaySound: false, shouldSetBadge: false, shouldShowBanner: false, shouldShowList: false, }), }); /** Crea il canale Android (serve prima della richiesta di permesso) e chiede il permesso. */ export async function preparaNotifiche(nomeCanale: string): Promise { if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync(CANALE, { name: nomeCanale, importance: Notifications.AndroidImportance.HIGH, sound: SUONO, }); } const { status } = await Notifications.getPermissionsAsync(); if (status !== 'granted') await Notifications.requestPermissionsAsync(); } export async function programmaFineTempo(secondi: number, titolo: string, testo: string): Promise { await Notifications.cancelAllScheduledNotificationsAsync(); if (secondi <= 0) return; await Notifications.scheduleNotificationAsync({ content: { title: titolo, body: testo, sound: SUONO }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: secondi, channelId: CANALE }, }); } export async function annullaFineTempo(): Promise { await Notifications.cancelAllScheduledNotificationsAsync(); await Notifications.dismissAllNotificationsAsync(); } ``` - [ ] **Step 3: `src/ui/navigazione.ts` e `src/ui/stato.tsx`** `src/ui/navigazione.ts`: ```ts export type Schermate = { Home: undefined; Impostazioni: undefined; Giro: undefined; Timer: undefined; Luoghi: undefined; Regole: undefined; }; ``` `src/ui/stato.tsx`: ```tsx /** * Lo stato condiviso fra le schermate: lingua, impostazioni della partita, * partita in corso e ultimi luoghi usati. Impostazioni e recenti si salvano in * un archivio chiave/valore e si rileggono all'avvio. */ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; import Storage from 'expo-sqlite/kv-store'; import { getLocales } from 'expo-localization'; import { validaImpostazioni, validaRecenti, type Impostazioni, type Partita } from '../gioco.ts'; import { linguaDaTag, t as testo, type Chiave, type Lingua } from '../testi/index.ts'; export function leggi(chiave: string): unknown { try { const v = Storage.getItemSync(chiave); return v == null ? null : JSON.parse(v); } catch { return null; } } export function scrivi(chiave: string, valore: unknown): void { try { Storage.setItemSync(chiave, JSON.stringify(valore)); } catch { // Se l'archivio non scrive si perde solo la preferenza, non la partita. } } interface Stato { lingua: Lingua; t: (k: Chiave) => string; imp: Impostazioni; setImp: (i: Impostazioni) => void; partita: Partita | null; setPartita: (p: Partita | null) => void; recenti: number[]; setRecenti: (r: number[]) => void; } const Contesto = createContext(null); export function StatoProvider({ children }: { children: ReactNode }) { const [lingua] = useState(() => linguaDaTag(getLocales()[0]?.languageTag)); const [imp, setImpInterno] = useState(() => validaImpostazioni(leggi('impostazioni'))); const [recenti, setRecentiInterno] = useState(() => validaRecenti(leggi('recenti'))); const [partita, setPartita] = useState(null); const t = useCallback((k: Chiave) => testo(lingua, k), [lingua]); const setImp = useCallback((i: Impostazioni) => { setImpInterno(i); scrivi('impostazioni', i); }, []); const setRecenti = useCallback((r: number[]) => { setRecentiInterno(r); scrivi('recenti', r); }, []); const valore = useMemo( () => ({ lingua, t, imp, setImp, partita, setPartita, recenti, setRecenti }), [lingua, t, imp, setImp, partita, recenti, setRecenti], ); return {children}; } export function useStato(): Stato { const s = useContext(Contesto); if (!s) throw new Error('useStato usato fuori da StatoProvider'); return s; } ``` - [ ] **Step 4: `src/ui/componenti.tsx`** ```tsx /** * I pezzi dell'aspetto della 1.5: foto di sfondo scura, pulsanti con sfumatura * blu e bordo chiaro, riquadri grigi chiari. */ import { useCallback, type ReactNode } from 'react'; import { BackHandler, ImageBackground, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import * as Haptics from 'expo-haptics'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import { STILI, type Stile } from '../gioco.ts'; import { SFONDI } from '../immagini.ts'; import { suona, type Suono } from '../suoni.ts'; import { useStato } from './stato.tsx'; export const FONT = 'BarlowCondensed_700Bold'; export const FONT_TESTO = 'BarlowCondensed_500Medium'; export const COLORI = { bluScuro: '#232933', blu: '#22435E', grigio: '#DCE2EB', bordo: '#B1B5BC', spento: '#8A9099', arancio: '#FFA500', }; /** Sul tablet il contenuto resta una colonna centrata, come sul telefono. */ const LARGHEZZA_MAX = 520; const NOMI_STILI: Record = { a: 'Hollywood', b: 'Dallas', c: 'Gothic' }; export function vibra(): void { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); } export function Sfondo({ children }: { children: ReactNode }) { const { imp } = useStato(); return ( {children} ); } export function Pulsante({ titolo, onPress, suono = 'click', disattivo = false, style, }: { titolo: string; onPress: () => void; suono?: Suono | null; disattivo?: boolean; style?: StyleProp; }) { return ( { vibra(); if (suono) suona(suono); onPress(); }} style={({ pressed }) => [st.pulsante, style, pressed && st.premuto]} > {titolo} ); } export function Riquadro({ children, style }: { children: ReactNode; style?: StyleProp }) { return ( {children} ); } function Tondo({ segno, onPress, etichetta }: { segno: string; onPress: () => void; etichetta: string }) { return ( { vibra(); onPress(); }} style={({ pressed }) => [st.tondo, pressed && st.premuto]} > {segno} ); } export function Contatore({ valore, meno, piu }: { valore: number; meno: () => void; piu: () => void }) { return ( {valore} ); } /** Hollywood, Dallas, Gothic: cambia foto e sfondi di tutta l'app. */ export function SceltaStile() { const { imp, setImp } = useStato(); return ( {STILI.map((s) => ( { vibra(); setImp({ ...imp, stile: s }); }} > {NOMI_STILI[s]} ))} ); } /** Durante il giro del telefono e il timer il tasto indietro di Android non fa niente. */ export function useBloccaIndietro(): void { useFocusEffect( useCallback(() => { const sub = BackHandler.addEventListener('hardwareBackPress', () => true); return () => sub.remove(); }, []), ); } export const testi = StyleSheet.create({ desc: { fontFamily: FONT_TESTO, fontSize: 20, lineHeight: 26, color: COLORI.bluScuro, textAlign: 'center' }, etichetta: { fontFamily: FONT, fontSize: 16, color: COLORI.spento, textAlign: 'center' }, grande: { fontFamily: FONT, fontSize: 30, color: COLORI.bluScuro, textAlign: 'center' }, }); const st = StyleSheet.create({ pieno: { flex: 1 }, colonna: { flex: 1, width: '100%', maxWidth: LARGHEZZA_MAX, alignSelf: 'center', padding: 16, gap: 12 }, pulsante: { borderRadius: 5, borderWidth: 2, borderColor: COLORI.grigio, overflow: 'hidden' }, pulsanteFondo: { minHeight: 56, paddingHorizontal: 12, paddingVertical: 8, alignItems: 'center', justifyContent: 'center' }, pulsanteTesto: { fontFamily: FONT, fontSize: 28, color: '#FFFFFF', textAlign: 'center' }, premuto: { opacity: 0.75 }, riquadro: { borderRadius: 5, borderWidth: 2, borderColor: COLORI.bordo, padding: 14 }, contatore: { flexDirection: 'row', alignItems: 'center', gap: 14 }, numero: { minWidth: 44 }, tondo: { width: 40, height: 40, borderRadius: 20, backgroundColor: COLORI.blu, alignItems: 'center', justifyContent: 'center', }, tondoTesto: { fontFamily: FONT, fontSize: 26, color: '#FFFFFF', marginTop: -2 }, stili: { flexDirection: 'row', justifyContent: 'center', gap: 18 }, stile: { fontFamily: FONT, fontSize: 20 }, }); ``` - [ ] **Step 5: `src/ui/schermate/Home.tsx`** ```tsx import { StyleSheet, View } from 'react-native'; import { Image } from 'expo-image'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { TITOLO } from '../../immagini.ts'; import { Pulsante, Sfondo } from '../componenti.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; export function Home({ navigation }: NativeStackScreenProps) { const { t } = useStato(); return ( navigation.navigate('Impostazioni')} /> navigation.navigate('Regole')} /> ); } const st = StyleSheet.create({ titolo: { width: '85%', aspectRatio: 1242 / 908, alignSelf: 'center', marginTop: 24 }, spazio: { flex: 1 }, }); ``` - [ ] **Step 6: `src/ui/App.tsx` e `App.tsx`** Le schermate non ancora scritte (Impostazioni, Luoghi, Regole, Giro, Timer) per ora puntano a un segnaposto dentro questo file; i task 6-8 sostituiscono gli import uno per uno. `src/ui/App.tsx`: ```tsx import { useEffect } from 'react'; import { Text, View } from 'react-native'; import { DarkTheme, NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import * as SplashScreen from 'expo-splash-screen'; import * as StoreReview from 'expo-store-review'; import { useFonts, BarlowCondensed_500Medium, BarlowCondensed_700Bold } from '@expo-google-fonts/barlow-condensed'; import { devoChiedereRecensione } from '../gioco.ts'; import type { Schermate } from './navigazione.ts'; import { leggi, scrivi, StatoProvider } from './stato.tsx'; import { Home } from './schermate/Home.tsx'; SplashScreen.preventAutoHideAsync().catch(() => {}); const Stack = createNativeStackNavigator(); function DaFare() { return ( … ); } const Impostazioni = DaFare; const Luoghi = DaFare; const Regole = DaFare; const Giro = DaFare; const Timer = DaFare; /** Come nella 1.5: la richiesta di recensione alla 3a, 10a, 50a apertura e poi ogni 100. */ async function contaAperture(): Promise { const n = (Number(leggi('aperture')) || 0) + 1; scrivi('aperture', n); if (devoChiedereRecensione(n) && (await StoreReview.isAvailableAsync())) await StoreReview.requestReview(); } export default function App() { const [caricati] = useFonts({ BarlowCondensed_500Medium, BarlowCondensed_700Bold }); useEffect(() => { if (caricati) SplashScreen.hideAsync().catch(() => {}); }, [caricati]); useEffect(() => { contaAperture().catch(() => {}); }, []); if (!caricati) return null; return ( ); } ``` `App.tsx` (radice): ```tsx // L'app vera sta in src/ui/App.tsx. Questo file resta perché index.ts, generato // da Expo, importa './App' per registrare il componente radice. export { default } from './src/ui/App.tsx'; ``` - [ ] **Step 7: Controlli e prova sul simulatore iOS** Run: `npm test && npm run typecheck` Expected: PASS, nessun errore. Poi rifare la build per simulatore e installarla (stessi comandi del Task 1 Step 8, compreso `npx expo prebuild --clean --no-install && (cd ios && pod install)` perché sono cambiati plugin e asset). Screenshot con `xcrun simctl io booted screenshot /tmp/sts-home.png`, aprirlo con Read. Expected: sfondo con la città (stile Gothic), titolo «Spot the Spy» in alto, pulsanti «Gioca» e «Regole del gioco» (o l'equivalente nella lingua del simulatore) in basso, niente sotto l'isola o la barra della home. Toccando «Gioca» si apre la schermata segnaposto e lo swipe indietro riporta alla Home. - [ ] **Step 8: Commit e push** ```bash git add -A App.tsx src git commit -m "Stato, componenti, navigazione e schermata iniziale Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 6: Impostazioni, Luoghi e Regole **Files:** - Create: `src/ui/GrigliaLuoghi.tsx`, `src/ui/schermate/Impostazioni.tsx`, `src/ui/schermate/Luoghi.tsx`, `src/ui/schermate/Regole.tsx` - Modify: `src/ui/App.tsx` (import delle tre schermate al posto dei segnaposto) **Interfaces:** - Consumes: componenti e stato del Task 5; `nuovaPartita`, `spiePer`, `aggiungiGiocatore`, `togliGiocatore`, `aggiungiMinuto`, `togliMinuto`, `LUOGHI` da `gioco.ts`; `preparaNotifiche` da `notifica.ts` - Produces: `GrigliaLuoghi({ onScegli?: (id: number) => void })` (senza `onScegli` è solo consultazione); schermate `Impostazioni`, `Luoghi`, `Regole`. «Inizia» imposta `partita` e `recenti` nello stato e naviga a `Giro`. - [ ] **Step 1: `src/ui/GrigliaLuoghi.tsx`** ```tsx import { useState } from 'react'; import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { Image } from 'expo-image'; import { LUOGHI } from '../gioco.ts'; import { FOTO_LUOGHI } from '../immagini.ts'; import { nomeLuogo } from '../testi/index.ts'; import { FONT, vibra } from './componenti.tsx'; import { useStato } from './stato.tsx'; const SPAZIO = 4; /** Tutti i luoghi con foto e nome: 3 colonne sul telefono, 5 dai 600 punti in su. */ export function GrigliaLuoghi({ onScegli }: { onScegli?: (id: number) => void }) { const { lingua, imp } = useStato(); const [larghezza, setLarghezza] = useState(0); const colonne = larghezza >= 600 ? 5 : 3; const lato = larghezza > 0 ? (larghezza - SPAZIO * (colonne + 1)) / colonne : 0; return ( setLarghezza(e.nativeEvent.layout.width)}> {lato > 0 && ( String(id)} contentContainerStyle={{ padding: SPAZIO / 2 }} renderItem={({ item: id }) => ( { vibra(); onScegli?.(id); }} style={({ pressed }) => [{ width: lato, margin: SPAZIO / 2 }, pressed && st.premuto]} > {nomeLuogo(lingua, id)} )} /> )} ); } const st = StyleSheet.create({ pieno: { flex: 1 }, premuto: { opacity: 0.6 }, nome: { fontFamily: FONT, fontSize: 15, color: '#FFFFFF', textAlign: 'center', paddingVertical: 4 }, }); ``` - [ ] **Step 2: `src/ui/schermate/Luoghi.tsx`** ```tsx import { Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { FONT, SceltaStile } from '../componenti.tsx'; import { GrigliaLuoghi } from '../GrigliaLuoghi.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; export function Luoghi({ navigation }: NativeStackScreenProps) { const { t } = useStato(); return ( navigation.goBack()} style={st.chiudi}> ✕ ); } const st = StyleSheet.create({ pieno: { flex: 1, backgroundColor: '#000' }, barra: { height: 52, justifyContent: 'center' }, chiudi: { position: 'absolute', right: 16, top: 12 }, x: { fontFamily: FONT, fontSize: 24, color: '#FFFFFF' }, }); ``` - [ ] **Step 3: `src/ui/schermate/Impostazioni.tsx`** ```tsx import { ScrollView, StyleSheet, Text, View } from 'react-native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { aggiungiGiocatore, aggiungiMinuto, nuovaPartita, spiePer, togliGiocatore, togliMinuto, } from '../../gioco.ts'; import { preparaNotifiche } from '../../notifica.ts'; import { COLORI, Contatore, FONT, Pulsante, Riquadro, SceltaStile, Sfondo, testi } from '../componenti.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; export function Impostazioni({ navigation }: NativeStackScreenProps) { const { t, imp, setImp, recenti, setRecenti, setPartita } = useStato(); const inizia = async () => { // Il permesso si chiede qui, alla prima partita, e non all'apertura dell'app. await preparaNotifiche(t('notifica.canale')).catch(() => {}); const r = nuovaPartita(imp, recenti); setPartita(r.partita); setRecenti(r.recenti); navigation.navigate('Giro'); }; return ( {t('config.desc')} setImp(togliGiocatore(imp))} piu={() => setImp(aggiungiGiocatore(imp))} /> {spiePer(imp.giocatori)} setImp(togliMinuto(imp))} piu={() => setImp(aggiungiMinuto(imp))} /> navigation.navigate('Luoghi')} /> navigation.navigate('Regole')} /> ); } function Riga({ etichetta, children }: { etichetta: string; children: React.ReactNode }) { return ( {etichetta} {children} ); } const st = StyleSheet.create({ lista: { gap: 12, flexGrow: 1, justifyContent: 'center' }, config: { gap: 16 }, riga: { alignItems: 'center', gap: 6 }, etichetta: { fontFamily: FONT, fontSize: 20, color: COLORI.bluScuro }, coppia: { flexDirection: 'row', gap: 12 }, meta: { flex: 1 }, }); ``` Aggiungere in testa `import type React from 'react';` se `tsc` segnala `React.ReactNode` (oppure importare `type ReactNode` da `react` e usare quello). - [ ] **Step 4: `src/ui/schermate/Regole.tsx`** ```tsx import { useRef, useState } from 'react'; import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { Chiave } from '../../testi/index.ts'; import { COLORI, FONT, FONT_TESTO, Pulsante, Riquadro, Sfondo } from '../componenti.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; /** Le cinque pagine della 1.5, con i loro paragrafi. */ const PAGINE: { titolo?: Chiave; testo: Chiave }[][] = [ [ { testo: 'regole.p1.txt1' }, { titolo: 'regole.p1.tit1', testo: 'regole.p1.txt2' }, { titolo: 'regole.p1.tit2', testo: 'regole.p1.txt3' }, ], [ { titolo: 'regole.p2.tit1', testo: 'regole.p2.txt1' }, { titolo: 'regole.p2.tit2', testo: 'regole.p2.txt2' }, ], [{ titolo: 'regole.p3.tit1', testo: 'regole.p3.txt1' }], [{ titolo: 'regole.p4.tit1', testo: 'regole.p4.txt1' }], [{ titolo: 'regole.p5.tit1', testo: 'regole.p5.txt1' }], ]; export function Regole({ navigation }: NativeStackScreenProps) { const { t } = useStato(); const lista = useRef>(null); const [pagina, setPagina] = useState(0); const [larghezza, setLarghezza] = useState(0); const ultima = pagina === PAGINE.length - 1; const vai = (p: number) => { lista.current?.scrollToIndex({ index: p, animated: true }); setPagina(p); }; return ( setLarghezza(e.nativeEvent.layout.width)}> {larghezza > 0 && ( String(i)} getItemLayout={(_, i) => ({ length: larghezza, offset: larghezza * i, index: i })} onMomentumScrollEnd={(e) => setPagina(Math.round(e.nativeEvent.contentOffset.x / larghezza))} renderItem={({ item }) => ( {item.map((p) => ( {p.titolo && {t(p.titolo)}} {t(p.testo)} ))} )} /> )} {PAGINE.map((_, i) => ( ))} {pagina > 0 && vai(pagina - 1)} />} (ultima ? navigation.goBack() : vai(pagina + 1))} /> ); } const st = StyleSheet.create({ pieno: { flex: 1 }, pagina: { paddingBottom: 8 }, riquadro: { gap: 14 }, paragrafo: { gap: 4 }, titolo: { fontFamily: FONT, fontSize: 24, color: COLORI.blu }, testo: { fontFamily: FONT_TESTO, fontSize: 19, lineHeight: 25, color: COLORI.bluScuro }, punti: { flexDirection: 'row', justifyContent: 'center', gap: 8 }, punto: { width: 8, height: 8, borderRadius: 4, backgroundColor: COLORI.spento }, puntoAttivo: { backgroundColor: COLORI.arancio }, coppia: { flexDirection: 'row', gap: 12 }, meta: { flex: 1 }, }); ``` La 1.5 aveva anche una «X» di chiusura: qui la modale si chiude con lo swipe in giù (iOS), col tasto indietro (Android) o con «Chiudi» all'ultima pagina. - [ ] **Step 5: Collegare le schermate in `src/ui/App.tsx`** Aggiungere: ```tsx import { Impostazioni } from './schermate/Impostazioni.tsx'; import { Luoghi } from './schermate/Luoghi.tsx'; import { Regole } from './schermate/Regole.tsx'; ``` e togliere le righe `const Impostazioni = DaFare;`, `const Luoghi = DaFare;`, `const Regole = DaFare;`. - [ ] **Step 6: Controlli e prova** Run: `npm test && npm run typecheck` → PASS. Build e installazione sul simulatore iPhone 17 (come Task 1 Step 8, senza prebuild se `app.json` e `plugins/` non sono cambiati). Verificare, con screenshot aperti via Read: - Impostazioni: `+` giocatori da 8 a 9 porta spie a 2 e minuti a 9; `−` fino a 3 si ferma a 3; minuti non scendono sotto 5; lo stile cambia lo sfondo subito. - Luoghi: 59 foto con i nomi, 3 colonne; lo stile cambia le foto; la «✕» chiude. - Regole: cinque pagine, «Avanti», «Indietro», «Chiudi» sull'ultima; lo scorrimento col dito aggiorna i puntini. - Chiudere e riaprire l'app (`xcrun simctl terminate booted paoloalby.SpotTheSpy` e `launch`): giocatori, minuti e stile restano quelli scelti. - [ ] **Step 7: Commit e push** ```bash git add -A src git commit -m "Impostazioni della partita, griglia dei luoghi e regole Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 7: Giro del telefono **Files:** - Create: `src/ui/schermate/Giro.tsx` - Modify: `src/ui/App.tsx` (import di `Giro`) **Interfaces:** - Consumes: `partita` dallo stato (impostata da «Inizia»), `ruoloDi`, `nomeLuogo`, `nomeRuolo`, `FOTO_LUOGHI`, `FOTO_SPIA`, `suona` - Produces: alla fine del giro `navigation.replace('Timer')`, così lo stack resta Home → Impostazioni → Timer e «Nuova partita» torna alle impostazioni con `goBack()`. - [ ] **Step 1: `src/ui/schermate/Giro.tsx`** ```tsx /** * Il telefono passa di mano in mano. Per ogni giocatore tre momenti: * l'avviso («non farti vedere»), il ruolo segreto, la conferma. Fra un giocatore * e l'altro un conto alla rovescia di 3 secondi; dopo l'ultimo parte il timer. */ import { useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Image } from 'expo-image'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { ruoloDi } from '../../gioco.ts'; import { FOTO_LUOGHI, FOTO_SPIA } from '../../immagini.ts'; import { suona } from '../../suoni.ts'; import { nomeLuogo, nomeRuolo } from '../../testi/index.ts'; import { COLORI, Pulsante, Riquadro, Sfondo, testi, useBloccaIndietro } from '../componenti.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; type Fase = 'avviso' | 'ruolo' | 'conferma'; export function Giro({ navigation }: NativeStackScreenProps) { const { t, lingua, imp, partita } = useStato(); const [giocatore, setGiocatore] = useState(0); const [fase, setFase] = useState('avviso'); const [conto, setConto] = useState(null); useBloccaIndietro(); useEffect(() => { if (conto === null) return; const id = setTimeout(() => { if (conto > 1) { suona('tick'); setConto(conto - 1); } else { setConto(null); setGiocatore((g) => g + 1); setFase('avviso'); } }, 1000); return () => clearTimeout(id); }, [conto]); if (!partita) return null; if (fase === 'avviso') { return ( {t('avviso.desc')} setFase('ruolo')} /> ); } if (fase === 'ruolo') { const r = ruoloDi(partita, giocatore); const spia = r === 'spia'; return ( {t('ruolo.luogo')} {spia ? '? ? ? ?' : nomeLuogo(lingua, partita.luogo)} {t('ruolo.ruolo')} {spia ? t('ruolo.spia') : nomeRuolo(lingua, partita.luogo, r)} setFase('conferma')} /> ); } const fatto = () => { if (giocatore < partita.giocatori - 1) setConto(3); else navigation.replace('Timer'); }; return ( {t('conferma.desc')} ); } const st = StyleSheet.create({ centro: { flex: 1, justifyContent: 'center' }, foto: { width: '100%', aspectRatio: 4 / 3, borderRadius: 5, borderWidth: 2, borderColor: COLORI.grigio }, }); ``` - [ ] **Step 2: Collegarla in `src/ui/App.tsx`** Aggiungere `import { Giro } from './schermate/Giro.tsx';` e togliere `const Giro = DaFare;`. - [ ] **Step 3: Controlli e prova** Run: `npm run typecheck` → nessun errore. Build e installazione sul simulatore. Impostare 3 giocatori e giocare il giro. Verificare con screenshot: - avviso → «Mostrami…» → luogo, foto e ruolo; per la spia «? ? ? ?», foto della spia e «Sei la SPIA»; - «Fatto» mostra 3..., 2..., 1... (un secondo l'uno, tic udibile) e torna all'avviso del giocatore dopo; - esattamente una spia su tre giocatori; i due non spia hanno ruoli diversi e consecutivi; - lo swipe dal bordo sinistro non torna indietro; - dopo il terzo «Fatto» si apre la schermata segnaposto del timer (il Task 8 la sostituisce). - [ ] **Step 4: Commit e push** ```bash git add -A src git commit -m "Giro del telefono: avviso, ruolo segreto, conferma Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 8: Timer, accuse e scelta della spia **Files:** - Create: `src/ui/schermate/Timer.tsx` - Modify: `src/ui/App.tsx` (import di `Timer`, via `DaFare`) **Interfaces:** - Consumes: `partita`, `secondiRimasti`, `mmss`, `esitoTentativo`, `GrigliaLuoghi`, `programmaFineTempo`, `annullaFineTempo`, `suona`, `MASCHERINA` - Produces: la schermata finale; «Nuova partita» fa `navigation.goBack()` verso Impostazioni. - [ ] **Step 1: `src/ui/schermate/Timer.tsx`** ```tsx /** * Il timer della partita. Il conto alla rovescia si ricava dall'ora di fine: * se l'app va in background il JavaScript si ferma, ma al ritorno il tempo è * giusto lo stesso. Nel frattempo la fine la segnala una notifica locale. * * Fasi: 'corre'; 'accusa' (pausa per votare una spia); 'scaduto'; 'finita'. */ import { useEffect, useRef, useState } from 'react'; import { Alert, AppState, Modal, StyleSheet, Text, View } from 'react-native'; import { Image } from 'expo-image'; import { useKeepAwake } from 'expo-keep-awake'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { esitoTentativo, mmss, secondiRimasti } from '../../gioco.ts'; import { FOTO_LUOGHI, FOTO_SPIA, MASCHERINA } from '../../immagini.ts'; import { annullaFineTempo, programmaFineTempo } from '../../notifica.ts'; import { suona } from '../../suoni.ts'; import { COLORI, FONT, Pulsante, Riquadro, testi, useBloccaIndietro } from '../componenti.tsx'; import { GrigliaLuoghi } from '../GrigliaLuoghi.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; type Fase = 'corre' | 'accusa' | 'scaduto' | 'finita'; export function Timer({ navigation }: NativeStackScreenProps) { const { t, imp, partita } = useStato(); useKeepAwake(); useBloccaIndietro(); const totale = (partita?.minuti ?? 0) * 60; const [fase, setFase] = useState('corre'); const [rimasti, setRimasti] = useState(totale); const [fine, setFine] = useState(() => Date.now() + totale * 1000); const [foto, setFoto] = useState<'spia' | 'luogo'>('spia'); const [scelta, setScelta] = useState(false); const [tentativi, setTentativi] = useState(partita?.spie.length ?? 1); const ultimo = useRef(totale); useEffect(() => { if (fine === null) return; const id = setInterval(() => { const r = secondiRimasti(fine, Date.now()); if (r !== ultimo.current) { ultimo.current = r; setRimasti(r); if (r > 0) suona('tick'); } if (r === 0) { clearInterval(id); setFine(null); setFase('scaduto'); suona('paparapa'); } }, 250); return () => clearInterval(id); }, [fine]); useEffect(() => { const sub = AppState.addEventListener('change', (stato) => { if (stato === 'background' && fine !== null) { programmaFineTempo(secondiRimasti(fine, Date.now()), t('timer.scaduto'), t('notifica.testo')).catch(() => {}); } if (stato === 'active') annullaFineTempo().catch(() => {}); }); return () => sub.remove(); }, [fine, t]); useEffect(() => () => { annullaFineTempo().catch(() => {}); }, []); if (!partita) return null; /** Ferma il tempo e restituisce i secondi rimasti, per poterlo far ripartire. */ const pausa = (): number => { const r = fine === null ? rimasti : secondiRimasti(fine, Date.now()); ultimo.current = r; setRimasti(r); setFine(null); return r; }; const riprendi = (r: number) => { setFase('corre'); setFine(Date.now() + r * 1000); }; const finisci = () => { setFine(null); setFase('finita'); }; const nuova = () => { finisci(); suona('suspense'); navigation.goBack(); }; // Primo pulsante: «Accusa una spia», poi «Spia smascherata». const accusa = () => { if (fase === 'corre') { pausa(); setFase('accusa'); } else { suona('vittoria'); finisci(); } }; // Secondo pulsante: «Indovina il luogo», poi «Spia scappata». const indovina = () => { if (fase !== 'corre') { suona('failure'); finisci(); return; } const r = pausa(); Alert.alert( t('dialogo.attenzione'), t('dialogo.indovina'), [ { text: t('dialogo.annulla'), style: 'cancel', onPress: () => riprendi(r) }, { text: t('timer.indovina'), onPress: () => { finisci(); setScelta(true); } }, ], { cancelable: false }, ); }; // Terzo pulsante: «Concludi», «Riprendi» durante l'accusa, «Nuova partita» a fine partita. const terzo = () => { if (fase === 'corre') { Alert.alert(t('dialogo.attenzione'), t('dialogo.concludi'), [ { text: t('dialogo.annulla'), style: 'cancel' }, { text: t('timer.concludi'), style: 'destructive', onPress: nuova }, ]); } else if (fase === 'accusa') { riprendi(rimasti); } else { nuova(); } }; const scegli = (id: number) => { const esito = esitoTentativo(id, partita.luogo, tentativi); if (esito === 'riprova') { setTentativi(tentativi - 1); Alert.alert(t('dialogo.sbagliato'), t('dialogo.altroTentativo')); return; } suona(esito === 'vinto' ? 'vittoria' : 'failure'); setFoto('luogo'); setScelta(false); }; const inPausa = fase === 'accusa' || fase === 'scaduto'; return ( {rimasti === 0 ? t('timer.scaduto') : mmss(rimasti)} {fase !== 'finita' && ( )} {fase !== 'scaduto' && ( )} Alert.alert(t('dialogo.attenzione'), t('dialogo.seleziona'))} > {t('dialogo.seleziona')} ); } const st = StyleSheet.create({ pieno: { flex: 1, backgroundColor: '#000' }, colonna: { flex: 1, width: '100%', maxWidth: 520, alignSelf: 'center', padding: 16, gap: 12 }, orologio: { ...testi.grande, fontSize: 56 }, centro: { flex: 1, justifyContent: 'center' }, cornice: { width: '100%', aspectRatio: 4 / 3 }, coppia: { flexDirection: 'row', gap: 12 }, meta: { flex: 1 }, avvisoScelta: { fontFamily: FONT, fontSize: 22, color: COLORI.arancio, textAlign: 'center', padding: 12 }, }); ``` - [ ] **Step 2: Collegarla e togliere i segnaposto** In `src/ui/App.tsx`: aggiungere `import { Timer } from './schermate/Timer.tsx';`, togliere `const Timer = DaFare;`, la funzione `DaFare` e gli import `Text`, `View` rimasti inutilizzati. - [ ] **Step 3: Controlli** Run: `npm test && npm run typecheck` Expected: PASS, nessun errore; `grep -n DaFare src/ui/App.tsx` non trova niente. - [ ] **Step 4: Prova completa sul simulatore iOS (iPhone 17)** Build e installazione. Per accorciare le prove impostare 3 giocatori e 5 minuti. Verificare, con screenshot: 1. Il timer parte da 05:00, scende di un secondo alla volta con il tic. 2. «Accusa una spia»: il tempo si ferma; i pulsanti diventano «Spia smascherata» e «Spia scappata», il terzo «Riprendi». «Riprendi» fa ripartire dallo stesso secondo. 3. «Spia smascherata»: suono di vittoria, restano l'orologio fermo e «Nuova partita»; «Nuova partita» torna alle impostazioni. 4. Nuova partita, «Indovina il luogo» → conferma → «Annulla»: il tempo riparte dallo stesso secondo. Di nuovo «Indovina il luogo» → «Indovina»: si apre la griglia con la scritta arancione. Scegliere un luogo sbagliato con 1 spia: suono di sconfitta, la griglia si chiude, al centro la foto del luogo vero. 5. Partita con 9 giocatori (2 spie): il primo errore dà «Sbagliato» e la griglia resta aperta; il secondo chiude la partita. 6. Background: `xcrun simctl launch booted com.apple.Preferences`, attendere 70 secondi, riaprire l'app con `xcrun simctl launch booted paoloalby.SpotTheSpy`: il timer segna circa 70 secondi in meno (non 10: era il difetto della 1.5). 7. Tempo scaduto in background: impostare 5 minuti, avviare, mandare in background e controllare dopo la scadenza che la notifica «Tempo scaduto» sia arrivata (`xcrun simctl io booted screenshot` sul centro notifiche). Al ritorno: scritta «Tempo scaduto», pulsanti «Spia smascherata» e «Spia scappata», terzo pulsante nascosto. 8. Durante il timer lo schermo non si spegne (keep awake): verificarlo sul dispositivo o dall'emulatore Android al Task 9. - [ ] **Step 5: Commit e push** ```bash git add -A src git commit -m "Timer: accuse, scelta del luogo della spia, notifica in background Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ### Task 9: Prove su tutti i dispositivi, lingue, documentazione **Files:** - Create: `CLAUDE.md`, `docs/BUILD.md` - Modify: quello che le prove trovano da correggere **Interfaces:** - Consumes: l'app completa - Produces: app provata su iPhone, iPad, telefono e tablet Android, in tre lingue; istruzioni di build per le sessioni future. - [ ] **Step 1: iPad** ```bash xcrun simctl boot "iPad Pro 13-inch (M5)" xcrun simctl install "iPad Pro 13-inch (M5)" /Users/paolo/dev/spotthespy/build/sim/Build/Products/Release-iphonesimulator/SpotTheSpy.app xcrun simctl launch "iPad Pro 13-inch (M5)" paoloalby.SpotTheSpy ``` Verificare con screenshot di ogni schermata: contenuto in colonna centrata, sfondo a tutto schermo, griglia dei luoghi a 5 colonne, app in verticale (ruotare con `xcrun simctl` non serve: `UIRequiresFullScreen` e `orientation: portrait` la bloccano). - [ ] **Step 2: Android, telefono e tablet** Build release arm64 e installazione su `emulator-5580` (comandi del Task 1 Step 10, preceduti da `npx expo prebuild --clean --no-install` e dalla riscrittura di `android/local.properties`). Partita completa come al Task 8 Step 4, punti 1-5. In più: - tasto indietro di sistema durante il giro e il timer: non succede niente (`adb -s emulator-5580 shell input keyevent KEYCODE_BACK`); - tasto indietro nella griglia di scelta della spia: compare l'avviso «La spia deve scegliere un luogo»; - alla prima «Inizia» compare la richiesta di permesso per le notifiche; - background (`adb -s emulator-5580 shell input keyevent KEYCODE_HOME`) fino alla scadenza: arriva la notifica con il suono «paparapa» e l'icona bianca; - lo schermo resta acceso durante il timer oltre il tempo di spegnimento (`adb -s emulator-5580 shell settings put system screen_off_timeout 15000`, attendere 30 secondi, screenshot). Poi chiudere il telefono (`adb -s emulator-5580 emu kill`), avviare `SpotTheSpy_Tablet` sulla porta 5580 e verificare che l'app resti in verticale e in colonna centrata. - [ ] **Step 3: Lingue** Su iOS: `xcrun simctl spawn booted defaults write -g AppleLanguages -array fr` poi riavviare l'app; ripetere con `en` e `de` (deve uscire l'inglese) e rimettere `it`. Su Android: `adb -s emulator-5580 shell "setprop persist.sys.locale fr-FR; setprop ctl.restart zygote"`. In ogni lingua controllare Home, Impostazioni, un ruolo, il timer e una pagina delle regole: nessun testo tagliato o che esce dai pulsanti. Se un'etichetta non entra, accorciarla nel file della lingua. - [ ] **Step 4: `CLAUDE.md` e `docs/BUILD.md`** `CLAUDE.md`: ```markdown # Spot The Spy Gioco di società a un telefono solo (tipo Spyfall), per iOS e Android. Versione 2.0 rifatta nel 2026 in Expo dalla 1.5 in Swift del 2018 (sul NAS in `spotTheSpy 1.5/`, fuori dal repository). - Progetto: `docs/superpowers/specs/2026-09-24-spotthespy-2-design.md` - Build, firma e prove: `docs/BUILD.md` - Regole comuni alle app di Paolo: `../CLAUDE.md` e `../comune/` Si lavora nel clone `~/dev/spotthespy` (dal NAS non si compila); commit e push a ogni passo, poi `git pull` nella cartella del NAS. `npm test` prova la logica di gioco e i testi; `npm run typecheck` i tipi. I testi stanno in `src/testi/` (le chiavi le decide `it.ts`); foto e suoni si rigenerano dalla 1.5 con `scripts/prepara-immagini.py`. ``` `docs/BUILD.md`: i comandi effettivamente usati nei task 1, 8 e 9 (prebuild, pod install, build simulatore iOS, build Android con JDK 17, emulatori `SpotTheSpy_Pixel` e `SpotTheSpy_Tablet` sulla porta 5580, installazione con push + `pm install`, cambio lingua), più la sezione release: - iOS: archivio e caricamento come in `comune/pubblicare.md` con `-workspace ios/SpotTheSpy.xcworkspace -scheme SpotTheSpy`; `ios.buildNumber` in `app.json` da alzare a ogni caricamento. - Android: `./gradlew bundleRelease` con JDK 17; firma con `STS_UPLOAD_*` in `~/.gradle/gradle.properties` (chiave in `_private/ - App/SpotTheSpy - firma Android/`, da creare prima della prima release); controllo con `keytool -printcert -jarfile app-release.aab`; `android.versionCode` in `app.json` da alzare a ogni caricamento. - Le trappole incontrate durante le prove, se ce ne sono state. - [ ] **Step 5: Controllo finale e push** Run: `npm test && npm run typecheck` Expected: PASS. ```bash git add -A git commit -m "Prove su iPhone, iPad, Android e tablet; istruzioni di build Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyuvKMx5Tz5Yw8TyPziMQU" git push && git -C "/Users/paolo/Server/Siti Web/_private/ - App/SpotTheSpy" pull --ff-only ``` --- ## Dopo questo piano La pubblicazione (chiave Android, pagine su duebytes.it, pagina beta, TestFlight sulla vecchia scheda, Google Play a pagamento con il test chiuso, schermate degli store) avrà un piano suo, da scrivere quando l'app è provata. Prima di quel piano va chiarito come funziona il test chiuso obbligatorio di Google con un'app a pagamento.