Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdbef1dd7 | ||
|
|
a926ad6423 | ||
|
|
8319f58f1e | ||
|
|
21cfe8bb89 | ||
|
|
b5a38531c4 | ||
|
|
666e274140 | ||
|
|
4015beb4f2 | ||
|
|
4afac814ee | ||
|
|
37bfe4e15e | ||
|
|
c701edd977 | ||
|
|
a455e3f12f | ||
|
|
be099aa810 | ||
|
|
24e9b8c055 | ||
|
|
cfe7cb7fd6 | ||
|
|
9499c7792b | ||
|
|
3a9db6d109 |
@@ -0,0 +1,68 @@
|
|||||||
|
# Le Gitea Actions del gioco: girano sul runner del PC di Andrea (make runner; label ubuntu-latest = node:22-bookworm).
|
||||||
|
# A ogni push su main e a ogni tag i test unitari; su un tag vX.Y.Z (o a mano, «Run workflow») anche le venti prove nel
|
||||||
|
# browser, una per job nell'immagine di Playwright, sulla scheda video del PC; se passa tutto, la release.
|
||||||
|
# Il controllo dei file pubblicabili non sta qui (a pipeline partita il push è già pubblico): è l'hook pre-push.
|
||||||
|
name: test
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Test unitari
|
||||||
|
run: node --version && node --test tests/*.test.mjs
|
||||||
|
|
||||||
|
# Le prove nel browser, una per job: l'elenco è SUITES di scripts/verify.mjs (verify.test.mjs pretende che coincidano),
|
||||||
|
# l'immagine ha la stessa versione di Playwright di package-lock.json (idem).
|
||||||
|
browser:
|
||||||
|
needs: test
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||||
|
# La scheda video del PC (runtime NVIDIA di Docker): Chromium disegna lì, tests/chromium.mjs la trova da solo.
|
||||||
|
options: --gpus all -e NVIDIA_DRIVER_CAPABILITIES=all
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
prova: [principale, minigiochi, telefoni, ambient, veicoli, ingorgo, smarronata, briscola, tressette, scopa, biliardo, flipper, micro, cerbottana, condor, campana, bruciate, gara, telefono, risveglio]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Playwright
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
- name: Prova ${{ matrix.prova }}
|
||||||
|
run: node scripts/verify.mjs ${{ matrix.prova }}
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [test, browser]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Release del tag
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
|
||||||
|
{
|
||||||
|
echo "Si gioca su https://50037.manzolo.it; qui sotto lo zip di questa versione."
|
||||||
|
echo
|
||||||
|
echo "Cosa cambia${prev:+ da $prev}:"
|
||||||
|
echo
|
||||||
|
git log --no-merges --format='- %s' ${prev:+$prev..}"$TAG" | grep -v -e '^- Versione [0-9]' | cut -c1-400
|
||||||
|
} > note.md
|
||||||
|
node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"Versione "+process.env.TAG.slice(1),body:fs.readFileSync("note.md","utf8")}))' > release.json
|
||||||
|
code=$(curl -s -o risposta.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' --data @release.json "$API/releases")
|
||||||
|
cat risposta.json | head -c 400; echo
|
||||||
|
[ "$code" = 201 ] || [ "$code" = 409 ]
|
||||||
@@ -7,3 +7,4 @@ assets/generated/
|
|||||||
.venv/
|
.venv/
|
||||||
.env
|
.env
|
||||||
deploy.local.mk
|
deploy.local.mk
|
||||||
|
.verify-ok.json
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p
|
|||||||
(`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages).
|
(`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages).
|
||||||
- Nessun build step: ES modules statici, server locale con `python3 -m http.server`.
|
- Nessun build step: ES modules statici, server locale con `python3 -m http.server`.
|
||||||
- Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura.
|
- Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura.
|
||||||
- Verifica visiva nel browser con Playwright e swiftshader prima di dichiarare finito qualcosa di grafico.
|
- Le prove nel browser sono a sezioni (`scripts/verify.mjs`, dal 2026-11-01): `make verify` rifà solo quelle i cui ingressi
|
||||||
|
sono cambiati dall'ultimo verde (la briscola se cambia la briscola, la partita intera se cambiano storia, motore, stile
|
||||||
|
o risorse). Un `chromium.launch` nuovo nelle prove usa `args: ARGS` di `tests/chromium.mjs`. Una prova nuova in `tests/*.browser.mjs` va aggiunta a `SUITES` (il test `verify.test.mjs` lo pretende).
|
||||||
|
- Verifica visiva nel browser con Playwright prima di dichiarare finito qualcosa di grafico. Chromium disegna sulla scheda
|
||||||
|
video del PC se c'è, altrimenti con swiftshader (`tests/chromium.mjs`, dal 2026-11-01: giro intero ~4 min invece di ~16).
|
||||||
- Pubblicare (tag/bump/push) solo quando Andrea lo chiede.
|
- Pubblicare (tag/bump/push) solo quando Andrea lo chiede.
|
||||||
- Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti.
|
- Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti.
|
||||||
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
|
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
|
||||||
- Sfondi nuovi: li genera **Codex** con il suo strumento immagini integrato, gratis e senza chiave API
|
- Sfondi nuovi: li genera **Codex** con il suo strumento immagini integrato, gratis e senza chiave API
|
||||||
(`codex exec`, ricetta in `docs/GENERAZIONE_SFONDI.md`, «Generare con Codex»). Si lancia da qui, senza chiedere la chiave ad Andrea.
|
(`codex exec`, ricetta in `docs/GENERAZIONE_SFONDI.md`, «Generare con Codex»). Si lancia da qui, senza chiedere la chiave ad Andrea.
|
||||||
|
- **Pubblicare (dal 2026-11-01)**: origin è https://git.manzolo.it/manzolo/leggenda-del-regolo, pubblico: prima `make check-public`, poi `git push origin main --tags`, solo se Andrea lo chiede.
|
||||||
|
`make deploy` costruisce l'immagine dal tag della versione, la spinge su `git.manzolo.it/manzolo/leggenda-regolo:vX.Y.Z` (e `:latest`); sul VPS il compose fissa quel tag e fa `docker compose pull && up -d`.
|
||||||
|
Per tornare indietro: sul VPS (`Workspaces/docker/leggenda-regolo/docker-compose.yml`) si rimette il tag precedente e si rifà pull e up.
|
||||||
|
Hook pre-push (`make hooks`, una volta per clone) lancia check-public; le Gitea Actions (`.gitea/workflows/test.yml`: test unitari a ogni push; sui tag, o a mano, anche le venti prove nel browser, una per job, poi la release) girano sul runner del PC, che si crea o riavvia con `make runner`.
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ DST ?= assets/scenes/$(notdir $(SRC))
|
|||||||
CROP ?=
|
CROP ?=
|
||||||
COLORS ?= 64
|
COLORS ?= 64
|
||||||
|
|
||||||
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
.PHONY: help serve open test browser verify verify-browser verify-plan check-public hooks bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
||||||
build up down restart logs ps shell image-size deploy deploy-logs clean distclean
|
build up down restart logs ps shell image-size deploy deploy-logs runner clean distclean
|
||||||
|
|
||||||
## ------------------------------------------------------------ sviluppo
|
## ------------------------------------------------------------ sviluppo
|
||||||
|
|
||||||
@@ -47,14 +47,26 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
|
|||||||
$(NODE) --test tests/*.test.mjs
|
$(NODE) --test tests/*.test.mjs
|
||||||
$(PYTHON) -m unittest discover -s tests -p "test_*.py" -q
|
$(PYTHON) -m unittest discover -s tests -p "test_*.py" -q
|
||||||
|
|
||||||
browser: ## Partita completa nel browser con Playwright
|
browser: ## Partita completa nel browser con Playwright (tre parti in processi paralleli, ~4,5 min)
|
||||||
$(NODE) tests/browser.mjs
|
$(NODE) tests/browser.mjs
|
||||||
|
|
||||||
verify: test browser check-public ## Tutti i controlli
|
verify: test check-public verify-browser ## Tutti i controlli: test unitari, file pubblicabili, prove nel browser cambiate (ALL=1: tutte)
|
||||||
|
|
||||||
|
# Le prove nel browser a sezioni: si rifanno solo quelle i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json,
|
||||||
|
# fuori da git); make verify-browser ALL=1 le rifà tutte, make verify-plan dice solo cosa rifarebbe; JOBS=2 in parallelo (con 4 vanno in timeout).
|
||||||
|
verify-browser: ## Le prove nel browser da rifare (ALL=1 tutte; PROVE="briscola flipper" solo quelle)
|
||||||
|
$(NODE) scripts/verify.mjs $(if $(ALL),--all,) $(PROVE)
|
||||||
|
|
||||||
|
verify-plan: ## Dice quali prove nel browser rifarebbe make verify, e perché
|
||||||
|
$(NODE) scripts/verify.mjs --plan
|
||||||
|
|
||||||
check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia)
|
check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia)
|
||||||
bash scripts/check-public.sh
|
bash scripts/check-public.sh
|
||||||
|
|
||||||
|
hooks: ## Attiva gli hook del repo (pre-push: check-public prima di ogni push)
|
||||||
|
git config core.hooksPath scripts/git-hooks
|
||||||
|
@echo "hook attivi da scripts/git-hooks: $$(ls scripts/git-hooks | tr '\n' ' ')"
|
||||||
|
|
||||||
bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy
|
bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy
|
||||||
scripts/bump.sh $(or $(PARTE),patch)
|
scripts/bump.sh $(or $(PARTE),patch)
|
||||||
|
|
||||||
@@ -121,40 +133,53 @@ image-size: ## Dimensione dell'immagine
|
|||||||
|
|
||||||
## ------------------------------------------------------------ deploy su un server proprio
|
## ------------------------------------------------------------ deploy su un server proprio
|
||||||
|
|
||||||
# Host e indirizzo stanno in deploy.local.mk, fuori da git, per esempio:
|
# Host e cartella stanno in deploy.local.mk, fuori da git, per esempio:
|
||||||
# DEPLOY_HOST = root@mio-server
|
# DEPLOY_HOST = root@mio-server
|
||||||
# DEPLOY_URL = https://gioco.esempio
|
# DEPLOY_URL = https://gioco.esempio
|
||||||
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
|
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
|
||||||
|
# DEPLOY_IMAGE = registry.esempio/utente/leggenda-regolo
|
||||||
-include deploy.local.mk
|
-include deploy.local.mk
|
||||||
DEPLOY_DIR ?= /root/leggenda-regolo
|
DEPLOY_DIR ?= /root/leggenda-regolo
|
||||||
|
DEPLOY_IMAGE ?= git.manzolo.it/manzolo/leggenda-regolo
|
||||||
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
|
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
|
||||||
|
# La versione dell'ultimo commit (non della cartella di lavoro): il tag git, dell'immagine e del compose.
|
||||||
|
DEPLOY_VER := v$(shell git show HEAD:src/version.js 2>/dev/null | sed -n "s/.*VERSION = '\([^']*\)'.*/\1/p")
|
||||||
|
|
||||||
# Si pubblica l'ultimo commit (git archive HEAD), mai la cartella di lavoro: un'altra sessione può avere modifiche a
|
# Si pubblica il tag della versione dichiarata dall'ultimo commit (git archive vX.Y.Z, quello di make bump), mai la
|
||||||
# metà. Test su quella copia, immagine costruita e provata qui, poi spedita già pronta (docker save | docker load):
|
# cartella di lavoro: un'altra sessione può avere modifiche a metà, e i commit dopo il tag aspettano la versione dopo.
|
||||||
# sul server non si compila niente.
|
# Test su quella copia, immagine costruita e provata qui, spinta sul registry di Gitea come :vX.Y.Z e :latest; il
|
||||||
|
# VPS la scarica con il tag scritto nel suo compose (preso da HEAD, docker-compose.deploy.yml).
|
||||||
SMOKE_PORT ?= 18737
|
SMOKE_PORT ?= 18737
|
||||||
deploy: ## Pubblica l'ultimo commit: test, immagine costruita e provata qui, spedita al server e riavviata
|
deploy: ## Pubblica la versione dell'ultimo commit: test, immagine provata qui, push sul registry, pull e riavvio sul server
|
||||||
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
||||||
mkdir -p $(DEPLOY_TMP) && git archive HEAD | tar -x -C $(DEPLOY_TMP)
|
@git rev-parse -q --verify 'refs/tags/$(DEPLOY_VER)' >/dev/null || { echo "manca il tag $(DEPLOY_VER): prima make bump"; exit 2; }
|
||||||
|
mkdir -p $(DEPLOY_TMP) && git archive $(DEPLOY_VER) | tar -x -C $(DEPLOY_TMP)
|
||||||
cd $(DEPLOY_TMP) && $(NODE) --test tests/*.test.mjs
|
cd $(DEPLOY_TMP) && $(NODE) --test tests/*.test.mjs
|
||||||
docker build -q -t $(IMAGE):latest -t $(IMAGE):$$(git rev-parse --short HEAD) $(DEPLOY_TMP)
|
docker build -q -t $(DEPLOY_IMAGE):$(DEPLOY_VER) -t $(DEPLOY_IMAGE):latest $(DEPLOY_TMP)
|
||||||
@docker rm -f $(IMAGE)-prova >/dev/null 2>&1 || true
|
@docker rm -f $(IMAGE)-prova >/dev/null 2>&1 || true
|
||||||
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(IMAGE):latest >/dev/null
|
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(DEPLOY_IMAGE):$(DEPLOY_VER) >/dev/null
|
||||||
@ok=1; sleep 1; for u in / /src/main.js /assets/voices/index.json /assets/audio/cartello.mp3; do \
|
@ok=1; sleep 1; for u in / /src/main.js /assets/voices/index.json /assets/audio/cartello.mp3; do \
|
||||||
c=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$(SMOKE_PORT)$$u); echo " prova $$u → $$c"; [ "$$c" = 200 ] || ok=0; done; \
|
c=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$(SMOKE_PORT)$$u); echo " prova $$u → $$c"; [ "$$c" = 200 ] || ok=0; done; \
|
||||||
docker rm -f $(IMAGE)-prova >/dev/null; [ $$ok = 1 ] || { echo "l'immagine non risponde come deve: non la spedisco"; exit 1; }
|
docker rm -f $(IMAGE)-prova >/dev/null; [ $$ok = 1 ] || { echo "l'immagine non risponde come deve: non la spedisco"; exit 1; }
|
||||||
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR)'
|
docker push -q $(DEPLOY_IMAGE):$(DEPLOY_VER)
|
||||||
scp -q $(DEPLOY_TMP)/docker-compose.homeserver.yml $(DEPLOY_HOST):$(DEPLOY_DIR)/docker-compose.yml
|
docker push -q $(DEPLOY_IMAGE):latest
|
||||||
docker save $(IMAGE):latest | gzip | ssh $(DEPLOY_HOST) 'gunzip | docker load -q && cd $(DEPLOY_DIR) && docker compose up -d && docker image prune -f >/dev/null'
|
git show HEAD:docker-compose.deploy.yml | sed 's|$(DEPLOY_IMAGE):VERSIONE|$(DEPLOY_IMAGE):$(DEPLOY_VER)|' | \
|
||||||
|
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR) && cat > $(DEPLOY_DIR)/docker-compose.yml'
|
||||||
|
ssh $(DEPLOY_HOST) 'cd $(DEPLOY_DIR) && docker compose pull -q && docker compose up -d && \
|
||||||
|
docker image ls $(DEPLOY_IMAGE) --format "{{.Repository}}:{{.Tag}}" | grep -v -x -e $(DEPLOY_IMAGE):$(DEPLOY_VER) | xargs -r docker image rm >/dev/null; \
|
||||||
|
docker image prune -f >/dev/null'
|
||||||
rm -rf $(DEPLOY_TMP)
|
rm -rf $(DEPLOY_TMP)
|
||||||
@# Sul server il prune toglie l'immagine vecchia; qui restano solo latest e quella appena pubblicata.
|
@# Qui restano solo latest e la versione appena pubblicata.
|
||||||
@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $$(git rev-parse --short HEAD) | \
|
@docker image ls $(DEPLOY_IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $(DEPLOY_VER) | \
|
||||||
xargs -r -I{} docker image rm -f $(IMAGE):{} >/dev/null
|
xargs -r -I{} docker image rm -f $(DEPLOY_IMAGE):{} >/dev/null
|
||||||
@# L'indirizzo pubblico deve servire la versione appena spedita.
|
@# L'indirizzo pubblico deve servire la versione appena spedita.
|
||||||
@v=$$(grep -o "VERSION = '[^']*'" src/version.js); \
|
@v=$$(git show $(DEPLOY_VER):src/version.js | grep -o "VERSION = '[^']*'"); \
|
||||||
for i in 1 2 3 4 5; do curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" && break; sleep 2; done; \
|
for i in 1 2 3 4 5; do curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" && break; sleep 2; done; \
|
||||||
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
|
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
|
||||||
@echo "→ $(DEPLOY_URL) ($$(git log -1 --format='%h %s'))"
|
@echo "→ $(DEPLOY_URL) ($(DEPLOY_IMAGE):$(DEPLOY_VER), $$(git log -1 --format='%h %s' $(DEPLOY_VER)))"
|
||||||
|
|
||||||
|
runner: ## Crea (se manca) e avvia sul PC il runner delle Gitea Actions (tools/gitea-runner, scripts/runner.sh)
|
||||||
|
bash scripts/runner.sh
|
||||||
|
|
||||||
deploy-logs: ## Log del container sul server di deploy
|
deploy-logs: ## Log del container sul server di deploy
|
||||||
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero),
|
|||||||
|
|
||||||
## Giocare in locale
|
## Giocare in locale
|
||||||
|
|
||||||
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
|
Il gioco si scarica da **https://git.manzolo.it/manzolo/leggenda-del-regolo**: nella pagina dei
|
||||||
|
[tag](https://git.manzolo.it/manzolo/leggenda-del-regolo/tags) c'è lo zip di ogni versione (l'ultima è quella online).
|
||||||
|
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES. Dentro la cartella scompattata:
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
python3 -m http.server 8000 # poi http://localhost:8000
|
python3 -m http.server 8000 # poi http://localhost:8000
|
||||||
@@ -40,16 +42,16 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con
|
|||||||
- `assets/`: sfondi, sprite, musiche, voci, carattere, video.
|
- `assets/`: sfondi, sprite, musiche, voci, carattere, video.
|
||||||
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi).
|
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi).
|
||||||
- `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre
|
- `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre
|
||||||
tutta la soluzione dell'avventura, su desktop e telefono); `npm run browser:briscola`, `:tressette`, `:scopa`,
|
tutta la soluzione dell'avventura, su desktop e telefono, in tre processi paralleli; `--parte=` per una sola);
|
||||||
`:biliardo`, `:effects` per le parti singole.
|
`npm run browser:briscola`, `:tressette`, `:scopa`, `:biliardo`, `:effects` per le parti singole.
|
||||||
- `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale.
|
- `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale.
|
||||||
- `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
|
- `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
|
make verify # test, file pubblicabili e le prove nel browser cambiate dall'ultimo verde (ALL=1: tutte)
|
||||||
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
||||||
make bump # nuova versione: verifica, commit e tag
|
make bump # nuova versione: verifica, commit e tag
|
||||||
make deploy # pubblica l'ultimo commit (immagine Docker spedita al server)
|
make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica
|
||||||
```
|
```
|
||||||
|
|
||||||
## Chi ha fatto cosa
|
## Chi ha fatto cosa
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Il compose del VPS (make deploy lo copia in DEPLOY_DIR con il tag della versione al posto di VERSIONE).
|
||||||
|
# L'immagine viene dal registry di Gitea; niente porte esposte: il container sta sulla rete esterna nginx-net,
|
||||||
|
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
|
||||||
|
# Per tornare indietro: sul VPS si rimette il tag precedente qui sotto e si rifà `docker compose pull && docker compose up -d`.
|
||||||
|
services:
|
||||||
|
leggenda-regolo:
|
||||||
|
image: git.manzolo.it/manzolo/leggenda-regolo:VERSIONE
|
||||||
|
container_name: leggenda-regolo
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nginx-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx-net:
|
||||||
|
external: true
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Deploy dietro un reverse proxy (make deploy): l'immagine arriva già costruita dal PC (docker load), niente porte esposte, il container sta sulla rete esterna nginx-net,
|
|
||||||
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
|
|
||||||
services:
|
|
||||||
leggenda-regolo:
|
|
||||||
image: leggenda-regolo:latest
|
|
||||||
container_name: leggenda-regolo
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- nginx-net
|
|
||||||
|
|
||||||
networks:
|
|
||||||
nginx-net:
|
|
||||||
external: true
|
|
||||||
@@ -302,6 +302,33 @@ versione nuova.
|
|||||||
- **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser`
|
- **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser`
|
||||||
(partita completa con clic veri in Chromium, ripresa del salvataggio, telefono).
|
(partita completa con clic veri in Chromium, ripresa del salvataggio, telefono).
|
||||||
|
|
||||||
|
- **Impaginazione (1 novembre, Andrea: «come mai non sfruttiamo tutto lo schermo?»):** la scena prende la finestra. La
|
||||||
|
riserva sotto non è più 250 px fissi ma l'altezza vera del pannello, 13,3 unità `--u` (un centesimo della larghezza
|
||||||
|
del gioco, `cqw`, fra 8,75 e 12,8 px: `#app` è un container). Frase, verbi, inventario, risposte, HUD e fumetti scalano
|
||||||
|
col gioco, non con la finestra: i verbi non si tagliano più nelle finestre piccole. Il pannello è alto sempre uguale
|
||||||
|
(ci stanno quattro risposte; da cinque il carattere cala, da sette ancora) così la scena non si muove nei dialoghi;
|
||||||
|
la frase è una riga sola coi puntini. «Schermo intero» nella Pausa (dove il browser lo permette). A 1040×590 la scena
|
||||||
|
passa da 604 a 842 px di larghezza, a 1366×650 da 711 a 935; sui telefoni niente cambia. Prova in `browser.mjs`.
|
||||||
|
|
||||||
|
- **Prove a sezioni (1 novembre, idea di Andrea):** `scripts/verify.mjs` conosce gli ingressi di ogni prova nel browser
|
||||||
|
(i file da cui dipende: i propri, più il motore comune; per la partita intera tutto il gioco tranne i minigiochi a sé)
|
||||||
|
e rifà solo quelle la cui impronta è cambiata dall'ultimo verde, ricordato in `.verify-ok.json` fuori da git. Così
|
||||||
|
`make bump` dopo una modifica alla briscola riprova la briscola e basta; dopo una modifica alla storia la partita intera
|
||||||
|
e le scene speciali (ambient, veicoli, ingorgo, smarronata), non la briscola; dopo `style.css` o `game.js` tutto.
|
||||||
|
Le prove dei minigiochi, che prima non entravano in `make verify`, ora ci stanno. `make verify-plan` spiega.
|
||||||
|
Nella pipeline di Gitea (runner sul PC) sui tag, o a mano, girano tutte e venti, una per job nell'immagine di
|
||||||
|
Playwright, prima della release: il primo giro completo (1 novembre, a mano su main) verde in 17 minuti.
|
||||||
|
|
||||||
|
- **Prove sulla scheda video (1 novembre, Andrea: «prova con la scheda video»):** Chromium disegnava con swiftshader,
|
||||||
|
sul processore: una schermata 633 ms. Con la RTX del PC (`--use-angle=gl-egl`) 41 ms; micro da 283 a 27 s, briscola da
|
||||||
|
259 a 29, flipper da 186 a 10; tutte e venti da ~16 minuti (due alla volta) a ~4 (sei alla volta), tutte verdi. Le
|
||||||
|
opzioni stanno in `tests/chromium.mjs`: scheda video se c'è (`/dev/dri`, `/dev/nvidia0`), altrimenti swiftshader;
|
||||||
|
`REGOLO_GPU=0/1` forza. Con 6 o 8 prove insieme il giro dura uguale: il limite è la partita intera (243 s, il tempo
|
||||||
|
del gioco, non della grafica). La pipeline dà la GPU ai job (`--gpus all`, runtime NVIDIA di Docker); runner a 6 job.
|
||||||
|
|
||||||
|
- **Flipper da tastiera (1 novembre, Andrea):** la freccia giù carica la molla come Spazio, i due Shift comandano le
|
||||||
|
palette (sinistro e destro, `e.code` passato da main.js ai minigiochi). Test unitario e nel browser.
|
||||||
|
|
||||||
## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git)
|
## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git)
|
||||||
|
|
||||||
| File | Cosa | Uso previsto |
|
| File | Cosa | Uso previsto |
|
||||||
|
|||||||
+4
-3
@@ -86,6 +86,7 @@
|
|||||||
<div class="buttons pause-buttons">
|
<div class="buttons pause-buttons">
|
||||||
<button id="resume">Riprendi</button>
|
<button id="resume">Riprendi</button>
|
||||||
<button id="pause-map" class="secondary">Mappa</button>
|
<button id="pause-map" class="secondary">Mappa</button>
|
||||||
|
<button id="fullscreen" class="secondary" title="Il gioco su tutto lo schermo; Esc per uscire">Schermo intero</button>
|
||||||
<button id="save-file" class="secondary" title="Scarica la partita in un file da aprire su un altro dispositivo">Salva su file</button>
|
<button id="save-file" class="secondary" title="Scarica la partita in un file da aprire su un altro dispositivo">Salva su file</button>
|
||||||
<button class="open-credits secondary">Ringraziamenti</button>
|
<button class="open-credits secondary">Ringraziamenti</button>
|
||||||
<button id="to-title" class="secondary">Torna al titolo</button>
|
<button id="to-title" class="secondary">Torna al titolo</button>
|
||||||
@@ -304,9 +305,9 @@
|
|||||||
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
|
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
|
||||||
</details>
|
</details>
|
||||||
<details id="flipper-help" hidden><summary>Come si gioca</summary>
|
<details id="flipper-help" hidden><summary>Come si gioca</summary>
|
||||||
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa
|
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure tieni
|
||||||
Spazio o il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
|
premuto Spazio o la freccia giù, o usa il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
|
||||||
Le frecce sinistra e destra, i pulsanti Flipper o le due metà della scena comandano le palette;
|
Le frecce sinistra e destra, i due Shift, i pulsanti Flipper o le due metà della scena comandano le palette;
|
||||||
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
|
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
|
||||||
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
|
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
|
||||||
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
|
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.67",
|
"version": "0.1.70",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Prima di ogni push: il repo su git.manzolo.it è pubblico, quindi niente che check-public bocci deve partire.
|
||||||
|
# Si attiva una volta con `make hooks` (core.hooksPath = scripts/git-hooks).
|
||||||
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||||
|
bash scripts/check-public.sh || { echo; echo "pre-push: push bloccato da check-public (vedi sopra)."; exit 1; }
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Crea (se manca) e avvia il runner delle Gitea Actions su questo PC: `make runner`.
|
||||||
|
# Rifarlo da zero: `docker compose -f tools/gitea-runner/docker-compose.yml down`, cancellare $RUNNER_DATA e il runner
|
||||||
|
# vecchio da Gitea (repo → Impostazioni → Actions → Runner), poi di nuovo `make runner`.
|
||||||
|
# Il token di registrazione si chiede all'API di Gitea con le credenziali di git (serve lo scope write:repository).
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
COMPOSE="$ROOT/tools/gitea-runner/docker-compose.yml"
|
||||||
|
REPO=${RUNNER_REPO:-manzolo/leggenda-del-regolo}
|
||||||
|
export RUNNER_DATA=${RUNNER_DATA:-$HOME/.local/share/gitea-runner}
|
||||||
|
export GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-pc-$(hostname | tr '[:upper:]' '[:lower:]')}
|
||||||
|
mkdir -p "$RUNNER_DATA"
|
||||||
|
|
||||||
|
if [[ ! -s $RUNNER_DATA/.runner ]]; then
|
||||||
|
pass=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^password=//p')
|
||||||
|
user=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^username=//p')
|
||||||
|
[[ -n $pass ]] || { echo "nessuna credenziale git per git.manzolo.it"; exit 1; }
|
||||||
|
tok=$(curl -sf -X POST -u "$user:$pass" "https://git.manzolo.it/api/v1/repos/$REPO/actions/runners/registration-token" |
|
||||||
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') ||
|
||||||
|
{ echo "Gitea non ha dato il token di registrazione (scope write:repository?)"; exit 1; }
|
||||||
|
echo "registro il runner $GITEA_RUNNER_NAME su $REPO"
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN=$tok docker compose -f "$COMPOSE" up -d
|
||||||
|
for _ in $(seq 30); do [[ -s $RUNNER_DATA/.runner ]] && break; sleep 1; done
|
||||||
|
[[ -s $RUNNER_DATA/.runner ]] || { docker logs --tail 20 gitea-runner; echo "registrazione non riuscita"; exit 1; }
|
||||||
|
chmod 600 "$RUNNER_DATA/.runner" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Senza token nell'ambiente del container: da qui in poi si autentica con .runner.
|
||||||
|
docker compose -f "$COMPOSE" up -d
|
||||||
|
# config.yaml si legge solo all'avvio: se è cambiato (capacità, opzioni) il runner riparte.
|
||||||
|
[[ $(docker inspect -f '{{.State.StartedAt}}' gitea-runner | date -f - +%s) -lt $(stat -c %Y "$(dirname "$COMPOSE")/config.yaml") ]] && docker restart gitea-runner >/dev/null
|
||||||
|
sleep 2; docker logs --tail 1 gitea-runner
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Le prove nel browser a sezioni (idea di Andrea, 1 novembre 2026): ogni prova ha i suoi ingressi, i file da cui dipende;
|
||||||
|
// si rifà solo se la loro impronta (i blob di git, più i file modificati nella cartella di lavoro) è cambiata dall'ultimo
|
||||||
|
// verde, ricordato in .verify-ok.json (fuori da git). Se cambia la briscola si riprova la briscola; se cambia la storia, il
|
||||||
|
// motore, l'impaginazione o le risorse si riprova la partita intera (e le prove delle scene speciali), non la briscola.
|
||||||
|
// node scripts/verify.mjs le prove da rifare, in parallelo, e aggiorna i verdi (JOBS: quante insieme; con la
|
||||||
|
// scheda video JOBS_GPU, senza 2: con quattro Chromium swiftshader le schermate vanno in timeout)
|
||||||
|
// node scripts/verify.mjs --plan dice solo cosa rifarebbe e perché
|
||||||
|
// node scripts/verify.mjs --all tutte, senza guardare i verdi
|
||||||
|
// node scripts/verify.mjs briscola solo quelle nominate (anche se verdi)
|
||||||
|
// I test unitari e check-public non stanno qui: costano pochi secondi e girano sempre (make verify).
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { cpus } from 'node:os';
|
||||||
|
import { GPU } from '../tests/chromium.mjs';
|
||||||
|
|
||||||
|
// Il motore comune a tutto: cambia questo e si riprova ogni minigioco.
|
||||||
|
export const ENGINE = ['index.html', 'style.css', 'src/main.js', 'src/engine/game.js', 'src/engine/render.js',
|
||||||
|
'tests/helpers.mjs', 'tests/chromium.mjs', 'package-lock.json'];
|
||||||
|
// Non toccano il gioco nel browser: documenti, strumenti, pubblicazione, versione (make bump la cambia prima di verificare),
|
||||||
|
// test unitari (girano sempre, a parte), e questo stesso script (lo prova verify.test.mjs).
|
||||||
|
export const IGNORED = [/^docs\//, /^[^/]*\.md$/, /^src\/version\.js$/, /^package\.json$/, /^Makefile$/, /^scripts\//,
|
||||||
|
/^tools\//, /^video\//, /^\.gitea\//, /^prompts\//, /^LICENSE$/, /^Dockerfile$/, /^nginx\.conf$/, /^docker-compose/, /^requirements\.txt$/,
|
||||||
|
/^\.(gitignore|dockerignore|verify-ok\.json)$/, /^tests\/[^/]*\.test\.mjs$/, /^tests\/test_[^/]*\.py$/, /^deploy\.local\.mk$/, /(^|\/)__pycache__\//];
|
||||||
|
|
||||||
|
// Le prove. `core: true` = dipende da tutto il gioco tranne i file dei minigiochi a sé (che hanno la loro prova); altrimenti
|
||||||
|
// solo dal motore e dai propri file: quelli che si chiamano come la prova (src/engine/flipper.js, flipper-render.js,
|
||||||
|
// assets/closeups/flipper-*.png, docs/BRISCOLA.md…) più gli `extra`. `run` sono gli argomenti di node.
|
||||||
|
export const SUITES = {
|
||||||
|
principale: { core: true, run: ['tests/browser.mjs', '--parte=principale'], note: 'la partita intera, pausa e salvataggi' },
|
||||||
|
minigiochi: { core: true, run: ['tests/browser.mjs', '--parte=minigiochi'], note: 'libro, bocce, tiri, pesca e biliardo dalla partita' },
|
||||||
|
telefoni: { core: true, run: ['tests/browser.mjs', '--parte=telefoni'], note: 'multimedia, telefoni, la finestra di Andrea, debug' },
|
||||||
|
ambient: { core: true, run: ['tests/ambient.browser.mjs'], note: 'tutte le scene nelle tre fasi e gli effetti' },
|
||||||
|
veicoli: { core: true, run: ['tests/veicoli.browser.mjs'], note: 'i veicoli nelle scene' },
|
||||||
|
ingorgo: { core: true, run: ['tests/ingorgo.browser.mjs'], note: "l'Ingorgo Sonoro" },
|
||||||
|
smarronata: { core: true, run: ['tests/smarronata.browser.mjs'], note: 'la Smarronata del 1993' },
|
||||||
|
briscola: { run: ['tests/briscola.browser.mjs'] },
|
||||||
|
tressette: { run: ['tests/tressette.browser.mjs'] },
|
||||||
|
scopa: { run: ['tests/scopa.browser.mjs'] },
|
||||||
|
biliardo: { run: ['tests/biliardo.browser.mjs'] },
|
||||||
|
flipper: { run: ['tests/flipper.browser.mjs'] },
|
||||||
|
micro: { run: ['tests/micro.browser.mjs'] },
|
||||||
|
cerbottana: { run: ['tests/cerbottana.browser.mjs'] },
|
||||||
|
condor: { run: ['tests/condor.browser.mjs'] },
|
||||||
|
campana: { run: ['tests/campana.browser.mjs'] },
|
||||||
|
bruciate: { run: ['tests/bruciate.browser.mjs'] },
|
||||||
|
gara: { run: ['tests/gara.browser.mjs'], extra: ['src/engine/corsa.js', 'src/story/piloti.js', /^assets\/[^/]*\/(gara|gp|pilot)/] },
|
||||||
|
telefono: { run: ['tests/telefono.browser.mjs'], extra: [/^assets\/muro\//] },
|
||||||
|
risveglio: { run: ['tests/risveglio.browser.mjs'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prove insieme con la scheda video (misurato il 1° novembre 2026 sul PC di Andrea, 16 thread e RTX 3080 Ti).
|
||||||
|
const JOBS_GPU = 6; // con 6 e con 8 il giro intero dura uguale (~4 min): da solo «principale» ne prende 243 s
|
||||||
|
|
||||||
|
const ownRe = name => new RegExp(`(^|/)${name}([-_.][^/]*)?$`, 'i');
|
||||||
|
const matches = (path, rule) => rule instanceof RegExp ? rule.test(path) : rule === path;
|
||||||
|
export const ignored = path => IGNORED.some(r => r.test(path));
|
||||||
|
export function ownFiles(name, files) {
|
||||||
|
const s = SUITES[name], re = ownRe(name);
|
||||||
|
return files.filter(p => re.test(p) || (s.extra || []).some(r => matches(p, r)));
|
||||||
|
}
|
||||||
|
// Gli ingressi di una prova fra i file dati (tutti quelli di git più quelli nuovi nella cartella).
|
||||||
|
export function inputsOf(name, files) {
|
||||||
|
const s = SUITES[name], live = files.filter(p => !ignored(p));
|
||||||
|
if (!s.core) return [...new Set([...ENGINE.filter(p => files.includes(p)), ...ownFiles(name, live)])].sort();
|
||||||
|
const others = Object.keys(SUITES).filter(n => !SUITES[n].core);
|
||||||
|
// I file degli altri minigiochi, e i file di prova delle altre prove (tests/ambient.browser.mjs non riguarda la partita).
|
||||||
|
const foreign = new Set([...others.flatMap(n => ownFiles(n, live)),
|
||||||
|
...Object.keys(SUITES).filter(n => SUITES[n].run[0] !== s.run[0]).map(n => SUITES[n].run[0])]);
|
||||||
|
return live.filter(p => !foreign.has(p)).sort();
|
||||||
|
}
|
||||||
|
export const fingerprint = (paths, hashOf) => createHash('sha1').update(paths.map(p => `${p} ${hashOf(p)}`).join('\n')).digest('hex');
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
|
||||||
|
|
||||||
|
// I file del progetto con l'impronta di ognuno: il blob dell'indice di git, o il contenuto attuale se modificato o nuovo.
|
||||||
|
export function snapshot() {
|
||||||
|
const hashes = new Map();
|
||||||
|
for (const line of git('ls-files', '-s', '-z').split('\0')) if (line) { const [meta, path] = line.split('\t'); hashes.set(path, meta.split(' ')[1]); }
|
||||||
|
const dirty = [];
|
||||||
|
for (const line of git('status', '--porcelain', '-z', '--untracked-files=all').split('\0')) {
|
||||||
|
if (!line) continue;
|
||||||
|
const code = line.slice(0, 2), path = line.slice(3);
|
||||||
|
if (code.includes('D')) hashes.delete(path); else dirty.push(path);
|
||||||
|
}
|
||||||
|
for (const path of dirty) if (existsSync(`${ROOT}/${path}`)) hashes.set(path, git('hash-object', '--', path).trim());
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OK_FILE = `${ROOT}/.verify-ok.json`;
|
||||||
|
const readOk = () => { try { return JSON.parse(readFileSync(OK_FILE, 'utf8')); } catch { return {}; } };
|
||||||
|
|
||||||
|
export function plan(hashes, ok, names = Object.keys(SUITES)) {
|
||||||
|
const files = [...hashes.keys()], out = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const inputs = inputsOf(name, files), fp = fingerprint(inputs, p => hashes.get(p));
|
||||||
|
const was = ok[name], changed = was?.fingerprint === fp ? [] : inputs.filter(p => !was?.hashes || was.hashes[p] !== hashes.get(p));
|
||||||
|
out.push({ name, fp, inputs, changed, green: was?.fingerprint === fp, seconds: was?.seconds });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(name) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const t0 = Date.now(), lines = [];
|
||||||
|
const p = spawn(process.execPath, SUITES[name].run, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let rest = '';
|
||||||
|
const onData = chunk => { const parts = (rest + chunk).split('\n'); rest = parts.pop(); for (const l of parts) { lines.push(l); console.log(`[${name}] ${l}`); } };
|
||||||
|
p.stdout.on('data', onData); p.stderr.on('data', onData);
|
||||||
|
p.on('close', code => { if (rest) console.log(`[${name}] ${rest}`); resolve({ name, code, seconds: Math.round((Date.now() - t0) / 1000) }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2), all = args.includes('--all'), onlyPlan = args.includes('--plan');
|
||||||
|
const named = args.filter(a => !a.startsWith('--'));
|
||||||
|
for (const n of named) if (!SUITES[n]) { console.error(`prova sconosciuta: ${n} (${Object.keys(SUITES).join(', ')})`); process.exit(2); }
|
||||||
|
const hashes = snapshot(), ok = readOk();
|
||||||
|
const todo = plan(hashes, ok, named.length ? named : undefined).filter(p => all || named.length || !p.green);
|
||||||
|
const green = Object.keys(SUITES).filter(n => !todo.some(t => t.name === n));
|
||||||
|
if (!todo.length) { console.log(`Prove nel browser: tutte verdi dall'ultimo giro (${green.length}); niente da rifare.`); return; }
|
||||||
|
for (const t of todo) {
|
||||||
|
const why = all ? 'tutte' : named.includes(t.name) ? 'chiesta' : !ok[t.name] ? 'mai provata' : !t.changed.length ? "cambiato l'elenco dei file da cui dipende" : `cambiati ${t.changed.slice(0, 4).join(', ')}${t.changed.length > 4 ? ` e altri ${t.changed.length - 4}` : ''}`;
|
||||||
|
console.log(`da rifare: ${t.name}${t.seconds ? ` (~${t.seconds} s)` : ''}: ${why}`);
|
||||||
|
}
|
||||||
|
if (green.length && !named.length) console.log(`già verdi: ${green.join(', ')}`);
|
||||||
|
if (onlyPlan) return;
|
||||||
|
const jobs = Math.max(1, Number(process.env.JOBS) || Math.min(GPU ? JOBS_GPU : 2, Math.floor(cpus().length / (GPU ? 2 : 4))));
|
||||||
|
console.log(`Chromium ${GPU ? 'sulla scheda video' : 'con swiftshader'}, ${Math.min(jobs, todo.length)} prove alla volta.`);
|
||||||
|
const queue = todo.sort((a, b) => (b.seconds || 999) - (a.seconds || 999)), results = [];
|
||||||
|
const worker = async () => { for (let t; (t = queue.shift());) {
|
||||||
|
const r = await run(t.name); results.push(r);
|
||||||
|
if (r.code === 0) {
|
||||||
|
const now = readOk();
|
||||||
|
now[t.name] = { fingerprint: t.fp, seconds: r.seconds, when: new Date().toISOString(), hashes: Object.fromEntries(t.inputs.map(p => [p, hashes.get(p)])) };
|
||||||
|
writeFileSync(OK_FILE, JSON.stringify(now, null, 1) + '\n');
|
||||||
|
}
|
||||||
|
} };
|
||||||
|
await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker));
|
||||||
|
for (const r of results.sort((a, b) => a.name.localeCompare(b.name))) console.log(`${r.code === 0 ? 'OK ' : 'FAIL'} ${r.name} (${r.seconds} s)`);
|
||||||
|
const failed = results.filter(r => r.code !== 0).map(r => r.name);
|
||||||
|
if (failed.length) { console.log(`Prove fallite: ${failed.join(', ')}`); process.exit(1); }
|
||||||
|
console.log(`Prove nel browser OK: ${results.length} rifatte${named.length ? '' : `, ${green.length} già verdi`}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
|
||||||
@@ -498,7 +498,7 @@ export function cancel(c, pointerId) {
|
|||||||
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce.
|
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), le frecce e i due Shift.
|
||||||
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
|
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
|
||||||
export function stopTurn(c, who = 'button') {
|
export function stopTurn(c, who = 'button') {
|
||||||
if (who === 'click') return false; // il colpo breve finisce da sé
|
if (who === 'click') return false; // il colpo breve finisce da sé
|
||||||
@@ -509,8 +509,9 @@ export const nudge = () => false;
|
|||||||
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
|
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
|
||||||
export function key(c, k, down) {
|
export function key(c, k, down) {
|
||||||
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
||||||
if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; }
|
if (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); return true; }
|
||||||
if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
|
// La molla: Spazio, o la freccia giù (si tira indietro come la molla vera).
|
||||||
|
if (k === ' ' || k === 'ArrowDown') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
|
||||||
if (k === 'Enter') { if (down) shoot(c); return true; }
|
if (k === 'Enter') { if (down) shoot(c); return true; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -399,6 +399,18 @@ addEventListener('pagehide', () => leave(true));
|
|||||||
addEventListener('pageshow', () => leave(document.hidden));
|
addEventListener('pageshow', () => leave(document.hidden));
|
||||||
$('resume').addEventListener('click', closePause);
|
$('resume').addEventListener('click', closePause);
|
||||||
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
||||||
|
// Schermo intero dalla Pausa, dove il browser lo permette (su iPhone no: il pulsante sparisce); Esc o il pulsante per uscire.
|
||||||
|
{
|
||||||
|
const full = $('fullscreen');
|
||||||
|
if (!document.fullscreenEnabled) full.hidden = true;
|
||||||
|
else {
|
||||||
|
full.addEventListener('click', () => {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
||||||
|
});
|
||||||
|
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement ? 'Esci da schermo intero' : 'Schermo intero'; });
|
||||||
|
}
|
||||||
|
}
|
||||||
$('to-title').addEventListener('click', () => {
|
$('to-title').addEventListener('click', () => {
|
||||||
$('pause').hidden = true;
|
$('pause').hidden = true;
|
||||||
music.hold = false;
|
music.hold = false;
|
||||||
@@ -649,7 +661,7 @@ const MINI = {
|
|||||||
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
||||||
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
||||||
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
|
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
|
||||||
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper o le frecce sinistra e destra.' },
|
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift.' },
|
||||||
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
||||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||||
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||||
@@ -813,6 +825,8 @@ function updateChoices() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ai minigiochi lo Shift arriva coi due lati distinti (ShiftLeft, ShiftRight): nel flipper comandano le due palette.
|
||||||
|
const keyName = e => e.key === 'Shift' ? e.code : e.key;
|
||||||
window.addEventListener('keydown', e => {
|
window.addEventListener('keydown', e => {
|
||||||
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return;
|
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return;
|
||||||
// Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
|
// Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
|
||||||
@@ -905,7 +919,7 @@ window.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Escape') { game.closeupClose(); return; }
|
if (e.key === 'Escape') { game.closeupClose(); return; }
|
||||||
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
|
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
|
||||||
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
|
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
|
||||||
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; }
|
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, keyName(e), true, e.shiftKey)) { e.preventDefault(); return; }
|
||||||
if (onControl && [' ', 'Enter'].includes(e.key)) return;
|
if (onControl && [' ', 'Enter'].includes(e.key)) return;
|
||||||
const c = game.closeup, m = mini();
|
const c = game.closeup, m = mini();
|
||||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||||
@@ -952,7 +966,7 @@ window.addEventListener('keydown', e => {
|
|||||||
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
||||||
window.addEventListener('keyup', e => {
|
window.addEventListener('keyup', e => {
|
||||||
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
|
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
|
||||||
if (miniState() && mini().key?.(game.closeup, e.key, false)) return;
|
if (miniState() && mini().key?.(game.closeup, keyName(e), false)) return;
|
||||||
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
|
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.67';
|
export const VERSION = '0.1.70';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|||||||
button, input { font-family: var(--font); }
|
button, input { font-family: var(--font); }
|
||||||
body { line-height: 1.5; }
|
body { line-height: 1.5; }
|
||||||
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
||||||
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
/* Il gioco è largo quanto può: tutta la finestra fino a 1280px, oppure quanto ci sta in altezza con il pannello sotto la
|
||||||
|
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
|
||||||
|
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 12,8px: tutto il pannello cresce e cala col gioco, non con
|
||||||
|
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
|
||||||
|
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
|
||||||
|
#app { --u: clamp(8.75px, 1cqw, 12.8px); --side-h: 13.3; container-type: inline-size;
|
||||||
|
width: min(100vw, 1280px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
|
||||||
|
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
||||||
|
|
||||||
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||||
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
@@ -80,7 +87,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
|
|
||||||
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
|
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
|
||||||
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
|
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
|
||||||
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
|
font-size: calc(1.4625 * var(--u)); font-weight: bold; line-height: 1.2; text-align: center;
|
||||||
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
||||||
#speech:empty { display: none; }
|
#speech:empty { display: none; }
|
||||||
#speech { z-index: 3; }
|
#speech { z-index: 3; }
|
||||||
@@ -89,7 +96,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0;
|
#muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0;
|
||||||
transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
|
transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
|
||||||
/* Il primo piano del libro si chiude da qui (o con Esc). */
|
/* Il primo piano del libro si chiude da qui (o con Esc). */
|
||||||
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
|
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold calc(1.17 * var(--u)) var(--font);
|
||||||
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
|
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
|
||||||
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
|
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
|
||||||
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
|
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
|
||||||
@@ -104,24 +111,27 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
background: rgba(27, 20, 32, .62); border-radius: 18px; }
|
background: rgba(27, 20, 32, .62); border-radius: 18px; }
|
||||||
#mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
|
#mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
|
||||||
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
|
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
|
||||||
#volume, #voice-volume { width: clamp(50px, 9vw, 100px); accent-color: var(--accent); cursor: pointer; }
|
#volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
|
||||||
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
|
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
|
||||||
|
|
||||||
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; background: var(--panel); }
|
/* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
|
||||||
|
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--panel); }
|
/* Verbi e inventario: alti sempre uguale (10,8 --u, dove stanno anche quattro risposte di dialogo: vedi #choices). */
|
||||||
|
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: calc(.625 * var(--u)); padding: calc(.625 * var(--u)); height: calc(10.8 * var(--u)); background: var(--panel); }
|
||||||
/* Otto verbi su due righe piene, quattro per riga. */
|
/* Otto verbi su due righe piene, quattro per riga. */
|
||||||
#verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; align-content: start; }
|
#verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: calc(.3125 * var(--u)); align-content: stretch; }
|
||||||
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
|
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
|
||||||
padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; }
|
padding: .35em .2em; font-size: calc(1.069 * var(--u)); font-weight: bold; cursor: pointer; }
|
||||||
#verbs button:hover, #inventory button:hover { color: var(--ink); }
|
#verbs button:hover, #inventory button:hover { color: var(--ink); }
|
||||||
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||||
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
|
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: calc(.3125 * var(--u)); align-content: start;
|
||||||
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
|
min-height: 0; padding: calc(.3125 * var(--u)); background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
|
||||||
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
|
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
|
||||||
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
|
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
|
||||||
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
|
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
|
||||||
font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; }
|
font-size: calc(1.069 * var(--u)); font-weight: bold; letter-spacing: .08em; }
|
||||||
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
|
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
|
||||||
#inventory button { min-width: 0; overflow: hidden; }
|
#inventory button { min-width: 0; overflow: hidden; }
|
||||||
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
|
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
|
||||||
@@ -130,8 +140,14 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||||
|
|
||||||
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
||||||
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
|
/* Le risposte: alte quanto il pannello dei verbi, così la scena non si muove quando parte un dialogo. Quattro risposte ci
|
||||||
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
stanno al carattere pieno; da cinque in su il carattere cala (e se mai non bastasse, si scorre). */
|
||||||
|
#choices { margin: 0; padding: calc(.78 * var(--u)) calc(1.1 * var(--u)) calc(.94 * var(--u)) 3.6em; background: var(--panel); height: calc(10.8 * var(--u));
|
||||||
|
overflow-y: auto; scrollbar-width: thin; font-size: calc(1.2375 * var(--u)); line-height: 1.4; }
|
||||||
|
#choices li { padding: .2em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||||
|
#choices:has(li:nth-child(5)) { font-size: calc(.96 * var(--u)); line-height: 1.25; }
|
||||||
|
#choices:has(li:nth-child(5)) li { padding: .15em 0; }
|
||||||
|
#choices:has(li:nth-child(7)) { font-size: calc(.72 * var(--u)); }
|
||||||
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||||
|
|
||||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||||
@@ -223,10 +239,11 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
||||||
/* In alto a sinistra: Pausa e Mappa. */
|
/* In alto a sinistra: Pausa e Mappa. */
|
||||||
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
|
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
|
||||||
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
|
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
|
||||||
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
||||||
#back-button { color: #1b1420; background: #9fd0ff; }
|
#back-button { color: #1b1420; background: #9fd0ff; }
|
||||||
#pause { z-index: 3; background: rgba(15, 10, 20, .9); }
|
/* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
|
||||||
|
#pause { z-index: 3; background: rgba(15, 10, 20, .9); justify-content: safe center; }
|
||||||
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
|
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
|
||||||
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
|
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
|
||||||
#tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
|
#tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
|
||||||
@@ -323,6 +340,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
|
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
|
||||||
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
#sentence { height: auto; min-height: 1.8em; white-space: normal; }
|
||||||
|
#panel, #choices { height: auto; }
|
||||||
|
#choices { min-height: 6.5em; }
|
||||||
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
||||||
#verbs button { font-size: 9px; padding: .7em .1em; }
|
#verbs button { font-size: 9px; padding: .7em .1em; }
|
||||||
#inventory { height: 7.6em; }
|
#inventory { height: 7.6em; }
|
||||||
@@ -341,7 +361,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
|
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
|
||||||
.card-hand { flex-direction: column; gap: 4px; }
|
.card-hand { flex-direction: column; gap: 4px; }
|
||||||
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
|
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
|
||||||
#sentence { font-size: 10.08px; }
|
#sentence { height: auto; min-height: 1.8em; white-space: normal; font-size: 10.08px; }
|
||||||
|
#panel, #choices { height: auto; }
|
||||||
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
|
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
|
||||||
#speech { max-width: 72%; font-size: 11px; }
|
#speech { max-width: 72%; font-size: 11px; }
|
||||||
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
||||||
@@ -358,6 +379,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
.buttons { gap: 6px; margin-top: .2em; }
|
.buttons { gap: 6px; margin-top: .2em; }
|
||||||
.buttons button { font-size: 10px; padding: .45em 1em; }
|
.buttons button { font-size: 10px; padding: .45em 1em; }
|
||||||
.pause-buttons { min-width: 12em; }
|
.pause-buttons { min-width: 12em; }
|
||||||
|
#pause .hint { display: none; }
|
||||||
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
|
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
|
||||||
#map-board button { width: 68px; }
|
#map-board button { width: 68px; }
|
||||||
#map-board button img { width: 60px; }
|
#map-board button img { width: 60px; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
|
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ mkdirSync(out, { recursive: true });
|
|||||||
const server = await staticServer(), port = server.port;
|
const server = await staticServer(), port = server.port;
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
|
const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
|
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -64,7 +65,7 @@ async function checkArtwork(page, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [
|
for (const [name, mobile, viewport] of [
|
||||||
['desktop', false, { width: 1280, height: 860 }],
|
['desktop', false, { width: 1280, height: 860 }],
|
||||||
['touch', true, { width: 844, height: 390 }],
|
['touch', true, { width: 844, height: 390 }],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
||||||
const server = await staticServer(), errors = [];
|
const server = await staticServer(), errors = [];
|
||||||
@@ -51,7 +52,7 @@ async function artwork(page) {
|
|||||||
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
|
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
|
const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
|
||||||
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
|
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
|
||||||
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
|
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
|
||||||
|
|||||||
+389
-331
@@ -1,8 +1,11 @@
|
|||||||
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
|
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
|
||||||
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
||||||
// npm run browser
|
// npm run browser: le tre parti (--parte=principale, minigiochi, telefoni) girano insieme come processi separati, ognuna
|
||||||
|
// col suo server e il suo Chromium; la prova passa quasi tutto il tempo ad aspettare animazioni in tempo reale, e così le
|
||||||
|
// attese si sovrappongono. Una parte sola: node tests/browser.mjs --parte=minigiochi (--media-only: solo multimedia).
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
import { mkdirSync, readFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { solution, route } from '../src/story/solution.js';
|
import { solution, route } from '../src/story/solution.js';
|
||||||
@@ -12,14 +15,31 @@ import { checkMultimedia } from './multimedia.browser.mjs';
|
|||||||
|
|
||||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||||||
mkdirSync(OUT, { recursive: true });
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const PARTI = ['principale', 'minigiochi', 'telefoni'];
|
||||||
|
const PARTE = process.argv.find(a => a.startsWith('--parte='))?.slice('--parte='.length);
|
||||||
|
if (PARTE && !PARTI.includes(PARTE)) { console.error(`parte sconosciuta: ${PARTE} (${PARTI.join(', ')})`); process.exit(2); }
|
||||||
|
if (!PARTE && !process.argv.includes('--media-only')) {
|
||||||
|
const { spawn } = await import('node:child_process');
|
||||||
|
const t0 = Date.now();
|
||||||
|
const esiti = await Promise.all(PARTI.map(parte => new Promise(resolve => {
|
||||||
|
const p = spawn(process.execPath, [process.argv[1], `--parte=${parte}`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let resto = '';
|
||||||
|
const riga = chunk => { const righe = (resto + chunk).split('\n'); resto = righe.pop(); for (const r of righe) console.log(`[${parte}] ${r}`); };
|
||||||
|
p.stdout.on('data', riga); p.stderr.on('data', riga);
|
||||||
|
p.on('close', code => { if (resto) console.log(`[${parte}] ${resto}`); resolve({ parte, code, s: Math.round((Date.now() - t0) / 1000) }); });
|
||||||
|
})));
|
||||||
|
for (const e of esiti) console.log(`${e.code === 0 ? 'OK ' : 'FAIL'} ${e.parte} (${e.s} s)`);
|
||||||
|
process.exit(esiti.every(e => e.code === 0) ? 0 : 1);
|
||||||
|
}
|
||||||
const server = await staticServer(), PORT = server.port;
|
const server = await staticServer(), PORT = server.port;
|
||||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
const browser = await chromium.launch({ args: ARGS });
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
async function open(viewport, query = '') {
|
async function open(viewport, query = '') {
|
||||||
const page = await browser.newPage({ viewport, acceptDownloads: true });
|
const page = await browser.newPage({ viewport, acceptDownloads: true });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
|
// ERR_NETWORK_CHANGED è il PC che cambia rete a metà prova (Docker che crea o toglie una rete): non è il gioco.
|
||||||
|
page.on('console', m => m.type() === 'error' && !m.text().includes('ERR_NETWORK_CHANGED') && errors.push(m.text()));
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
|
try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
|
||||||
}
|
}
|
||||||
@@ -49,332 +69,8 @@ const hotspotCenter = async (page, id) => {
|
|||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima).
|
||||||
await checkMultimedia(browser, PORT, OUT, errors);
|
async function minigiochi() {
|
||||||
if (!process.argv.includes('--media-only')) {
|
|
||||||
const page = await open({ width: 1280, height: 860 });
|
|
||||||
// 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');
|
|
||||||
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
|
|
||||||
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
|
|
||||||
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` });
|
|
||||||
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
|
|
||||||
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
|
||||||
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
|
|
||||||
await page.waitForTimeout(350);
|
|
||||||
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
|
||||||
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
|
|
||||||
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
|
|
||||||
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
|
|
||||||
{
|
|
||||||
const muto = await chromium.launch(), p = await muto.newPage();
|
|
||||||
await p.goto(`http://127.0.0.1:${PORT}/`);
|
|
||||||
await p.waitForTimeout(800);
|
|
||||||
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
|
|
||||||
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
|
|
||||||
await p.waitForTimeout(5500);
|
|
||||||
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
|
|
||||||
await p.click('#splash');
|
|
||||||
assert.ok(await p.isHidden('#splash .splash-hint'));
|
|
||||||
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
|
||||||
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
|
|
||||||
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
|
|
||||||
await muto.close();
|
|
||||||
}
|
|
||||||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
|
||||||
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
|
|
||||||
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
|
|
||||||
{
|
|
||||||
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
|
||||||
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
|
|
||||||
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
|
|
||||||
await slow.addInitScript(() => {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const title = document.getElementById('title'), splash = document.getElementById('splash');
|
|
||||||
if (title && !title.hidden && splash) {
|
|
||||||
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
|
|
||||||
});
|
|
||||||
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
|
|
||||||
await slow.goto(`http://127.0.0.1:${PORT}/`);
|
|
||||||
await slow.waitForSelector('#splash');
|
|
||||||
await slow.waitForTimeout(300);
|
|
||||||
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
|
|
||||||
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
|
|
||||||
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
|
||||||
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
|
||||||
await slow.close();
|
|
||||||
}
|
|
||||||
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
|
||||||
await page.click('#title .open-credits');
|
|
||||||
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
|
||||||
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
|
|
||||||
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
|
||||||
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
|
|
||||||
await page.click('#credits .credit-face >> text=Mariano');
|
|
||||||
assert.ok(await page.isVisible('#face img'));
|
|
||||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
|
||||||
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
|
||||||
await page.click('#face');
|
|
||||||
assert.ok(await page.isHidden('#face'));
|
|
||||||
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
|
|
||||||
// foto, un tocco su una foto la mostra grande col nome del posto.
|
|
||||||
{
|
|
||||||
const track = page.locator('#credits-reel .track');
|
|
||||||
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
|
|
||||||
await page.mouse.move(5, 5);
|
|
||||||
const x0 = await track.evaluate(t => t.scrollLeft);
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
const x1 = await track.evaluate(t => t.scrollLeft);
|
|
||||||
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
|
|
||||||
await track.hover({ position: { x: 20, y: 20 } });
|
|
||||||
const x2 = await track.evaluate(t => t.scrollLeft);
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
|
|
||||||
await page.click('#credits-reel .arrow.next');
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
const x3 = await track.evaluate(t => t.scrollLeft);
|
|
||||||
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
|
|
||||||
const third = track.locator('img').nth(2);
|
|
||||||
const name = await third.getAttribute('alt');
|
|
||||||
await third.click();
|
|
||||||
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
|
|
||||||
assert.equal(await page.textContent('#face figcaption'), name);
|
|
||||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
|
||||||
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
|
|
||||||
assert.ok(await page.isVisible('#credits'));
|
|
||||||
}
|
|
||||||
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
|
||||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
|
||||||
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
|
||||||
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
|
|
||||||
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
|
|
||||||
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
|
|
||||||
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
|
|
||||||
await page.click('#new-game');
|
|
||||||
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
|
|
||||||
await page.waitForSelector('#rione:not([hidden])');
|
|
||||||
assert.equal(await page.locator('#rione-buttons button').count(), 4);
|
|
||||||
await page.screenshot({ path: `${OUT}/01a-rione.png` });
|
|
||||||
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
|
|
||||||
await page.click('#rione [data-rione="campomigliaio"]');
|
|
||||||
// L'introduzione: una riga per clic, poi comincia la partita.
|
|
||||||
await page.waitForSelector('#intro:not([hidden])');
|
|
||||||
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
|
||||||
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
|
|
||||||
await page.screenshot({ path: `${OUT}/01b-intro.png` });
|
|
||||||
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
|
||||||
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
|
||||||
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\?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');
|
|
||||||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
|
||||||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
|
||||||
await settle(page);
|
|
||||||
|
|
||||||
// Doppio clic su un punto della scena: Andrea ci va di corsa.
|
|
||||||
const r = await page.locator('#scene').boundingBox();
|
|
||||||
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
|
|
||||||
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
|
|
||||||
await settle(page);
|
|
||||||
// «?» mostra per qualche secondo cosa si può toccare.
|
|
||||||
await page.click('#spots-button');
|
|
||||||
await page.screenshot({ path: `${OUT}/02c-punti.png` });
|
|
||||||
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
|
|
||||||
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(150);
|
|
||||||
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
|
|
||||||
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
|
|
||||||
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
|
|
||||||
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
|
|
||||||
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
|
|
||||||
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForSelector('#pause:not([hidden])');
|
|
||||||
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
|
|
||||||
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
|
|
||||||
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
|
|
||||||
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
|
|
||||||
const savedFile = `${OUT}/partita.json`;
|
|
||||||
await download.saveAs(savedFile);
|
|
||||||
const before = await page.evaluate(() => game.snapshot());
|
|
||||||
await page.click('#to-title');
|
|
||||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
|
||||||
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
|
|
||||||
await page.waitForSelector('#load-error:not([hidden])');
|
|
||||||
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
|
|
||||||
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
|
|
||||||
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
|
|
||||||
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
|
|
||||||
lost.state.scene = 'luna';
|
|
||||||
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
|
|
||||||
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
|
|
||||||
assert.ok(await page.isVisible('#title'));
|
|
||||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
|
|
||||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
|
||||||
await page.setInputFiles('#load-file', savedFile);
|
|
||||||
await page.waitForSelector('#title', { state: 'hidden' });
|
|
||||||
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
|
|
||||||
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForSelector('#pause:not([hidden])');
|
|
||||||
await page.click('#resume');
|
|
||||||
assert.ok(await page.isHidden('#pause'));
|
|
||||||
await page.click('#pause-button');
|
|
||||||
await page.click('#to-title');
|
|
||||||
await page.waitForSelector('#continue:not([hidden])');
|
|
||||||
await page.click('#continue');
|
|
||||||
await settle(page);
|
|
||||||
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
|
||||||
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
|
|
||||||
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
|
|
||||||
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
|
|
||||||
await page.click('#mute');
|
|
||||||
await page.locator('#volume').fill('30');
|
|
||||||
await page.locator('#voice-volume').fill('70');
|
|
||||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
|
|
||||||
assert.equal(await page.evaluate(() => voices.volume), 0.7);
|
|
||||||
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
|
|
||||||
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
|
|
||||||
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
|
|
||||||
await hide(true);
|
|
||||||
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
|
|
||||||
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
|
|
||||||
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
|
|
||||||
await hide(false);
|
|
||||||
await page.click('#resume');
|
|
||||||
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
|
|
||||||
|
|
||||||
for (const [verb, target, item] of solution) {
|
|
||||||
if (verb === 'choose') {
|
|
||||||
await page.locator('#choices li', { hasText: target }).click();
|
|
||||||
} else {
|
|
||||||
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
|
||||||
if (item) {
|
|
||||||
await page.click(`#verbs [data-verb="${verb}"]`);
|
|
||||||
await page.click(`#inventory [data-item="${item}"]`);
|
|
||||||
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
|
||||||
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
|
|
||||||
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
|
|
||||||
else await clickScene(page, await hotspotCenter(page, target));
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(80);
|
|
||||||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
|
||||||
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
|
|
||||||
if (target === 'fessura' && item) {
|
|
||||||
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
|
|
||||||
const box = await page.locator('#scene').boundingBox();
|
|
||||||
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
|
|
||||||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
|
||||||
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
|
|
||||||
for (let n = 0; n < 200; n++) {
|
|
||||||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
|
||||||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
|
||||||
await page.waitForTimeout(30);
|
|
||||||
}
|
|
||||||
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
|
|
||||||
}
|
|
||||||
await page.click('#closeup-close');
|
|
||||||
}
|
|
||||||
await settle(page);
|
|
||||||
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
|
||||||
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
|
|
||||||
}
|
|
||||||
await page.waitForSelector('#ending:not([hidden])');
|
|
||||||
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
|
||||||
await page.click('#ending .open-credits');
|
|
||||||
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
|
|
||||||
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
|
||||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
|
|
||||||
await page.click('#close-credits');
|
|
||||||
assert.ok(await page.isHidden('#credits'));
|
|
||||||
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
|
||||||
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
|
||||||
|
|
||||||
// Una partita a metà riprende da dove era rimasta.
|
|
||||||
await page.click('#restart');
|
|
||||||
await settle(page);
|
|
||||||
for (const [, exit] of route('piazza', 'trebbio')) {
|
|
||||||
await clickScene(page, await hotspotCenter(page, exit));
|
|
||||||
await settle(page);
|
|
||||||
}
|
|
||||||
await page.click('#verbs [data-verb="talk"]');
|
|
||||||
await clickScene(page, await hotspotCenter(page, 'torre'));
|
|
||||||
await page.locator('#choices li', { hasText: 'bloccato' }).click();
|
|
||||||
await page.waitForFunction(() => game.speech?.who === 'castello');
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
|
|
||||||
await settle(page);
|
|
||||||
await page.locator('#choices li', { hasText: 'pace' }).click();
|
|
||||||
await settle(page);
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForSelector('#continue:not([hidden])');
|
|
||||||
await page.click('#continue');
|
|
||||||
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
|
||||||
await settle(page);
|
|
||||||
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
|
|
||||||
await page.keyboard.press('n');
|
|
||||||
assert.ok(await page.isVisible('#notebook'));
|
|
||||||
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
|
|
||||||
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
assert.ok(await page.isHidden('#notebook'));
|
|
||||||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
|
||||||
await page.click('#map-button');
|
|
||||||
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
|
||||||
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
|
||||||
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
|
||||||
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
|
|
||||||
await page.fill('#map-search', 'xyz');
|
|
||||||
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
|
||||||
await page.fill('#map-search', '');
|
|
||||||
await page.click('#map-search');
|
|
||||||
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
|
|
||||||
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
|
||||||
assert.ok(await page.isHidden('#map-none'));
|
|
||||||
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
|
||||||
assert.deepEqual(shown, ['piazza']);
|
|
||||||
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
assert.ok(await page.isHidden('#map'));
|
|
||||||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
|
||||||
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
|
||||||
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
|
||||||
await page.setViewportSize({ width: 560, height: 340 });
|
|
||||||
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
|
||||||
await page.click('#map-button');
|
|
||||||
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
|
||||||
await page.fill('#map-search', 'campo');
|
|
||||||
const found = await page.evaluate(() => {
|
|
||||||
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
|
||||||
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
|
||||||
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
|
||||||
});
|
|
||||||
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
|
||||||
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
|
||||||
await page.click('#close-map');
|
|
||||||
await page.setViewportSize({ width: 1280, height: 860 });
|
|
||||||
|
|
||||||
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
|
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
|
||||||
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
|
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
|
||||||
{
|
{
|
||||||
@@ -551,10 +247,36 @@ try {
|
|||||||
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
|
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
|
||||||
await b.close();
|
await b.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function telefoni() {
|
||||||
|
// La finestra di Andrea (1040×590: Firefox con le sue barre su un portatile): la scena era larga 604 px con 100 px
|
||||||
|
// vuoti sotto, ora prende l'altezza; i verbi stanno nei pulsanti; un dialogo a quattro risposte e uno a cinque
|
||||||
|
// non muovono la scena e stanno nel pannello.
|
||||||
|
{
|
||||||
|
const win = await open({ width: 1040, height: 590 });
|
||||||
|
await win.click('#splash'); await win.click('#new-game'); await win.click('#rione [data-rione="campomigliaio"]'); await win.click('#skip-intro');
|
||||||
|
await settle(win);
|
||||||
|
const box = async () => { const r = await win.locator('#stage').boundingBox(); return [r.x, r.y, r.width, r.height].map(Math.round); };
|
||||||
|
const b0 = await box();
|
||||||
|
assert.ok(b0[2] >= 820 && b0[1] >= 0, `a 1040×590 la scena è larga ${b0[2]} px e comincia a ${b0[1]}`);
|
||||||
|
assert.ok(await win.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre a 1040×590');
|
||||||
|
assert.ok(await win.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1040×590');
|
||||||
|
for (const id of ['giocatore_carte', 'arcu']) {
|
||||||
|
await win.evaluate(id => { game.dialogue = { id, node: 'start' }; }, id);
|
||||||
|
await win.waitForFunction(() => !document.getElementById('choices').hidden);
|
||||||
|
const n = await win.evaluate(() => game.options().length);
|
||||||
|
assert.deepEqual(await box(), b0, `la scena si muove col dialogo ${id}`);
|
||||||
|
assert.ok(await win.evaluate(() => { const c = document.getElementById('choices'); return c.scrollHeight <= c.clientHeight + 1; }), `le ${n} risposte di ${id} non stanno nel pannello`);
|
||||||
|
if (id === 'arcu') await win.screenshot({ path: `${OUT}/11-finestra-1040-dialogo.png` });
|
||||||
|
await win.evaluate(() => { game.dialogue = null; });
|
||||||
|
}
|
||||||
|
await win.screenshot({ path: `${OUT}/11-finestra-1040.png` });
|
||||||
|
await win.close();
|
||||||
|
}
|
||||||
const phone = await open({ width: 390, height: 844 });
|
const phone = await open({ width: 390, height: 844 });
|
||||||
await phone.click('#splash'); // un clic salta il cartello
|
await phone.click('#splash'); // un clic salta il cartello
|
||||||
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
|
assert.ok(await phone.waitForSelector('#title:not([hidden])', { timeout: 5000 }).then(() => true, () => false), 'il clic sul cartello porta al titolo');
|
||||||
await phone.click('#new-game');
|
await phone.click('#new-game');
|
||||||
await phone.click('#rione [data-rione="campomigliaio"]');
|
await phone.click('#rione [data-rione="campomigliaio"]');
|
||||||
await phone.click('#skip-intro');
|
await phone.click('#skip-intro');
|
||||||
@@ -619,9 +341,345 @@ try {
|
|||||||
await dbg.waitForTimeout(200);
|
await dbg.waitForTimeout(200);
|
||||||
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
|
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
|
||||||
await dbg.screenshot({ path: `${OUT}/10-debug.png` });
|
await dbg.screenshot({ path: `${OUT}/10-debug.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.argv.includes('--media-only')) await checkMultimedia(browser, PORT, OUT, errors);
|
||||||
|
else if (PARTE === 'minigiochi') { await minigiochi(); console.log('Browser OK: libro, bocce, tiri, pesca, biliardo.'); }
|
||||||
|
else if (PARTE === 'telefoni') { await checkMultimedia(browser, PORT, OUT, errors); await telefoni(); console.log('Browser OK: telefoni e debug.'); }
|
||||||
|
else {
|
||||||
|
const page = await open({ width: 1280, height: 860 });
|
||||||
|
// 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');
|
||||||
|
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
|
||||||
|
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
|
||||||
|
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` });
|
||||||
|
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
|
||||||
|
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||||
|
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
||||||
|
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
|
||||||
|
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
|
||||||
|
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
|
||||||
|
{
|
||||||
|
const muto = await chromium.launch(), p = await muto.newPage();
|
||||||
|
await p.goto(`http://127.0.0.1:${PORT}/`);
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
|
||||||
|
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
|
||||||
|
await p.waitForTimeout(5500);
|
||||||
|
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
|
||||||
|
await p.click('#splash');
|
||||||
|
assert.ok(await p.isHidden('#splash .splash-hint'));
|
||||||
|
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||||
|
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
|
||||||
|
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
|
||||||
|
await muto.close();
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||||||
|
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
|
||||||
|
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
|
||||||
|
{
|
||||||
|
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||||
|
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
|
||||||
|
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
|
||||||
|
await slow.addInitScript(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const title = document.getElementById('title'), splash = document.getElementById('splash');
|
||||||
|
if (title && !title.hidden && splash) {
|
||||||
|
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
|
||||||
|
});
|
||||||
|
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
|
||||||
|
await slow.goto(`http://127.0.0.1:${PORT}/`);
|
||||||
|
await slow.waitForSelector('#splash');
|
||||||
|
await slow.waitForTimeout(300);
|
||||||
|
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
|
||||||
|
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
|
||||||
|
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
||||||
|
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
||||||
|
await slow.close();
|
||||||
|
}
|
||||||
|
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
||||||
|
await page.click('#title .open-credits');
|
||||||
|
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
||||||
|
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
|
||||||
|
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
||||||
|
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
|
||||||
|
await page.click('#credits .credit-face >> text=Mariano');
|
||||||
|
assert.ok(await page.isVisible('#face img'));
|
||||||
|
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||||
|
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
||||||
|
await page.click('#face');
|
||||||
|
assert.ok(await page.isHidden('#face'));
|
||||||
|
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
|
||||||
|
// foto, un tocco su una foto la mostra grande col nome del posto.
|
||||||
|
{
|
||||||
|
const track = page.locator('#credits-reel .track');
|
||||||
|
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
|
||||||
|
await page.mouse.move(5, 5);
|
||||||
|
const x0 = await track.evaluate(t => t.scrollLeft);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const x1 = await track.evaluate(t => t.scrollLeft);
|
||||||
|
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
|
||||||
|
await track.hover({ position: { x: 20, y: 20 } });
|
||||||
|
const x2 = await track.evaluate(t => t.scrollLeft);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
|
||||||
|
await page.click('#credits-reel .arrow.next');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const x3 = await track.evaluate(t => t.scrollLeft);
|
||||||
|
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
|
||||||
|
const third = track.locator('img').nth(2);
|
||||||
|
const name = await third.getAttribute('alt');
|
||||||
|
await third.click();
|
||||||
|
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
|
||||||
|
assert.equal(await page.textContent('#face figcaption'), name);
|
||||||
|
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||||
|
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
|
||||||
|
assert.ok(await page.isVisible('#credits'));
|
||||||
|
}
|
||||||
|
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||||
|
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
||||||
|
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
||||||
|
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
|
||||||
|
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
|
||||||
|
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
|
||||||
|
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
|
||||||
|
await page.click('#new-game');
|
||||||
|
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
|
||||||
|
await page.waitForSelector('#rione:not([hidden])');
|
||||||
|
assert.equal(await page.locator('#rione-buttons button').count(), 4);
|
||||||
|
await page.screenshot({ path: `${OUT}/01a-rione.png` });
|
||||||
|
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
|
||||||
|
await page.click('#rione [data-rione="campomigliaio"]');
|
||||||
|
// L'introduzione: una riga per clic, poi comincia la partita.
|
||||||
|
await page.waitForSelector('#intro:not([hidden])');
|
||||||
|
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||||
|
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
|
||||||
|
await page.screenshot({ path: `${OUT}/01b-intro.png` });
|
||||||
|
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||||
|
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
||||||
|
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\?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');
|
||||||
|
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||||
|
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
// Doppio clic su un punto della scena: Andrea ci va di corsa.
|
||||||
|
const r = await page.locator('#scene').boundingBox();
|
||||||
|
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
|
||||||
|
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
|
||||||
|
await settle(page);
|
||||||
|
// «?» mostra per qualche secondo cosa si può toccare.
|
||||||
|
await page.click('#spots-button');
|
||||||
|
await page.screenshot({ path: `${OUT}/02c-punti.png` });
|
||||||
|
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
|
||||||
|
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
|
||||||
|
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
|
||||||
|
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
|
||||||
|
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
|
||||||
|
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
|
||||||
|
// Il gioco prende la finestra: a 1280×860 la scena non si ferma più ai 1084 px di una riserva fissa di 250 px sotto,
|
||||||
|
// la pagina non scorre e i verbi stanno interi nei pulsanti (la finestra di Andrea, 1040×590, è provata più sotto).
|
||||||
|
const stage0 = await page.locator('#stage').boundingBox();
|
||||||
|
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
|
||||||
|
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
|
||||||
|
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
|
||||||
|
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForSelector('#pause:not([hidden])');
|
||||||
|
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
|
||||||
|
assert.equal(await page.evaluate(() => document.getElementById('fullscreen').hidden), await page.evaluate(() => !document.fullscreenEnabled), 'il pulsante Schermo intero c\'è se il browser lo permette');
|
||||||
|
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
|
||||||
|
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
|
||||||
|
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
|
||||||
|
const savedFile = `${OUT}/partita.json`;
|
||||||
|
await download.saveAs(savedFile);
|
||||||
|
const before = await page.evaluate(() => game.snapshot());
|
||||||
|
await page.click('#to-title');
|
||||||
|
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||||
|
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
|
||||||
|
await page.waitForSelector('#load-error:not([hidden])');
|
||||||
|
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
|
||||||
|
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
|
||||||
|
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
|
||||||
|
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
|
||||||
|
lost.state.scene = 'luna';
|
||||||
|
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
|
||||||
|
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
|
||||||
|
assert.ok(await page.isVisible('#title'));
|
||||||
|
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
|
||||||
|
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||||
|
await page.setInputFiles('#load-file', savedFile);
|
||||||
|
await page.waitForSelector('#title', { state: 'hidden' });
|
||||||
|
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
|
||||||
|
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForSelector('#pause:not([hidden])');
|
||||||
|
await page.click('#resume');
|
||||||
|
assert.ok(await page.isHidden('#pause'));
|
||||||
|
await page.click('#pause-button');
|
||||||
|
await page.click('#to-title');
|
||||||
|
await page.waitForSelector('#continue:not([hidden])');
|
||||||
|
await page.click('#continue');
|
||||||
|
await settle(page);
|
||||||
|
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
||||||
|
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
|
||||||
|
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
|
||||||
|
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
|
||||||
|
await page.click('#mute');
|
||||||
|
await page.locator('#volume').fill('30');
|
||||||
|
await page.locator('#voice-volume').fill('70');
|
||||||
|
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
|
||||||
|
assert.equal(await page.evaluate(() => voices.volume), 0.7);
|
||||||
|
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
|
||||||
|
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
|
||||||
|
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
|
||||||
|
await hide(true);
|
||||||
|
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
|
||||||
|
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
|
||||||
|
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
|
||||||
|
await hide(false);
|
||||||
|
await page.click('#resume');
|
||||||
|
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
|
||||||
|
|
||||||
|
for (const [verb, target, item] of solution) {
|
||||||
|
if (verb === 'choose') {
|
||||||
|
await page.locator('#choices li', { hasText: target }).click();
|
||||||
|
} else {
|
||||||
|
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||||||
|
if (item) {
|
||||||
|
await page.click(`#verbs [data-verb="${verb}"]`);
|
||||||
|
await page.click(`#inventory [data-item="${item}"]`);
|
||||||
|
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
||||||
|
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
|
||||||
|
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
|
||||||
|
else await clickScene(page, await hotspotCenter(page, target));
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||||||
|
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
|
||||||
|
if (target === 'fessura' && item) {
|
||||||
|
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
|
||||||
|
const box = await page.locator('#scene').boundingBox();
|
||||||
|
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
|
||||||
|
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||||||
|
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
|
||||||
|
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
|
||||||
|
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||||||
|
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||||||
|
await page.waitForTimeout(30);
|
||||||
|
}
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
|
||||||
|
}
|
||||||
|
await page.click('#closeup-close');
|
||||||
|
}
|
||||||
|
await settle(page);
|
||||||
|
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
||||||
|
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
|
||||||
|
}
|
||||||
|
await page.waitForSelector('#ending:not([hidden])');
|
||||||
|
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||||||
|
await page.click('#ending .open-credits');
|
||||||
|
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
|
||||||
|
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||||
|
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
|
||||||
|
await page.click('#close-credits');
|
||||||
|
assert.ok(await page.isHidden('#credits'));
|
||||||
|
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
||||||
|
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
||||||
|
|
||||||
|
// Una partita a metà riprende da dove era rimasta.
|
||||||
|
await page.click('#restart');
|
||||||
|
await settle(page);
|
||||||
|
for (const [, exit] of route('piazza', 'trebbio')) {
|
||||||
|
await clickScene(page, await hotspotCenter(page, exit));
|
||||||
|
await settle(page);
|
||||||
|
}
|
||||||
|
await page.click('#verbs [data-verb="talk"]');
|
||||||
|
await clickScene(page, await hotspotCenter(page, 'torre'));
|
||||||
|
await page.locator('#choices li', { hasText: 'bloccato' }).click();
|
||||||
|
await page.waitForFunction(() => game.speech?.who === 'castello');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
|
||||||
|
await settle(page);
|
||||||
|
await page.locator('#choices li', { hasText: 'pace' }).click();
|
||||||
|
await settle(page);
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('#continue:not([hidden])');
|
||||||
|
await page.click('#continue');
|
||||||
|
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
||||||
|
await settle(page);
|
||||||
|
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
|
||||||
|
await page.keyboard.press('n');
|
||||||
|
assert.ok(await page.isVisible('#notebook'));
|
||||||
|
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
|
||||||
|
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.ok(await page.isHidden('#notebook'));
|
||||||
|
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||||||
|
await page.click('#map-button');
|
||||||
|
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
||||||
|
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
||||||
|
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
||||||
|
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
|
||||||
|
await page.fill('#map-search', 'xyz');
|
||||||
|
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
||||||
|
await page.fill('#map-search', '');
|
||||||
|
await page.click('#map-search');
|
||||||
|
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
|
||||||
|
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
||||||
|
assert.ok(await page.isHidden('#map-none'));
|
||||||
|
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
||||||
|
assert.deepEqual(shown, ['piazza']);
|
||||||
|
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.ok(await page.isHidden('#map'));
|
||||||
|
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||||||
|
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
||||||
|
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
||||||
|
await page.setViewportSize({ width: 560, height: 340 });
|
||||||
|
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
||||||
|
await page.click('#map-button');
|
||||||
|
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
||||||
|
await page.fill('#map-search', 'campo');
|
||||||
|
const found = await page.evaluate(() => {
|
||||||
|
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
||||||
|
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
||||||
|
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
||||||
|
});
|
||||||
|
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
||||||
|
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
||||||
|
await page.click('#close-map');
|
||||||
|
await page.setViewportSize({ width: 1280, height: 860 });
|
||||||
|
|
||||||
assert.deepEqual(errors, [], 'errori nella console');
|
assert.deepEqual(errors, [], 'errori nella console');
|
||||||
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
|
console.log(`Browser OK: soluzione completa, ripresa del salvataggio. Schermate in ${OUT}`);
|
||||||
}
|
}
|
||||||
assert.deepEqual(errors, [], 'errori nella console');
|
assert.deepEqual(errors, [], 'errori nella console');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-bruciate';
|
const out = process.env.OUT ?? '/tmp/regolo-bruciate';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-campana';
|
const out = process.env.OUT ?? '/tmp/regolo-campana';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-cerbottana';
|
const out = process.env.OUT ?? '/tmp/regolo-cerbottana';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Come si apre Chromium nelle prove nel browser (solo da node: helpers.mjs lo carica anche la pagina).
|
||||||
|
// Con una scheda video (/dev/dri o /dev/nvidia0, anche nei contenitori lanciati con --gpus) si disegna lì: una
|
||||||
|
// schermata costa ~45 ms invece di ~600 con swiftshader, e le prove girano 5-10 volte più in fretta. Senza, swiftshader.
|
||||||
|
// REGOLO_GPU=0 forza swiftshader, REGOLO_GPU=1 la scheda video.
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
export const GPU = process.env.REGOLO_GPU ? process.env.REGOLO_GPU === '1' : ['/dev/dri/renderD128', '/dev/nvidia0'].some(existsSync);
|
||||||
|
const GL = GPU ? ['--use-gl=angle', '--use-angle=gl-egl', '--ignore-gpu-blocklist', '--enable-gpu-rasterization']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
|
||||||
|
export const ARGS = ['--autoplay-policy=no-user-gesture-required', ...GL];
|
||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-condor';
|
const out = process.env.OUT ?? '/tmp/regolo-condor';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-flipper';
|
const out = process.env.OUT ?? '/tmp/regolo-flipper';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -12,7 +13,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
@@ -45,9 +46,10 @@ try {
|
|||||||
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
||||||
await touch('touchEnd', []);
|
await touch('touchEnd', []);
|
||||||
} else {
|
} else {
|
||||||
await page.mouse.move(...P(440, 320)); await page.mouse.down();
|
// Da tastiera con la freccia giù (il mouse tiene la molla come il dito, provato sul telefono).
|
||||||
|
await page.keyboard.down('ArrowDown');
|
||||||
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
||||||
await page.mouse.up();
|
await page.keyboard.up('ArrowDown');
|
||||||
}
|
}
|
||||||
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
|
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
|
||||||
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
|
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
|
||||||
@@ -83,6 +85,13 @@ try {
|
|||||||
await page.keyboard.up('ArrowLeft');
|
await page.keyboard.up('ArrowLeft');
|
||||||
await page.click('#resume');
|
await page.click('#resume');
|
||||||
await page.waitForFunction(() => document.querySelector('#pause').hidden);
|
await page.waitForFunction(() => document.querySelector('#pause').hidden);
|
||||||
|
// I due Shift: il sinistro alza la paletta sinistra, il destro la destra.
|
||||||
|
await page.keyboard.down('ShiftRight');
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.includes('key-ShiftRight') && !game.closeup.flippers.left.held.length);
|
||||||
|
await page.keyboard.down('ShiftLeft');
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.includes('key-ShiftLeft'));
|
||||||
|
await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft');
|
||||||
|
await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length);
|
||||||
}
|
}
|
||||||
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
||||||
const reward = await page.evaluate(async () => {
|
const reward = await page.evaluate(async () => {
|
||||||
|
|||||||
@@ -146,10 +146,14 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d
|
|||||||
m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click'));
|
m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click'));
|
||||||
const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length);
|
const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length);
|
||||||
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
|
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
|
||||||
|
assert.ok(m.key(c, 'ShiftLeft', true)); assert.ok(c.flippers.left.held.includes('key-ShiftLeft') && !c.flippers.right.held.length);
|
||||||
|
m.key(c, 'ShiftRight', true); assert.ok(c.flippers.right.held.includes('key-ShiftRight'));
|
||||||
|
m.key(c, 'ShiftLeft', false); m.key(c, 'ShiftRight', false); assert.ok(!c.flippers.left.held.length && !c.flippers.right.held.length);
|
||||||
assert.ok(!m.key(c, 'x', true));
|
assert.ok(!m.key(c, 'x', true));
|
||||||
assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale');
|
assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale');
|
||||||
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
|
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
|
||||||
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
|
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
|
||||||
|
const g = m.newFlipper(11); assert.ok(m.key(g, 'ArrowDown', true)); run(g, 0.3); assert.ok(g.holding, 'anche la freccia giù carica la molla'); m.key(g, 'ArrowDown', false); assert.ok(!m.waiting(g));
|
||||||
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
|
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
|
||||||
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
|
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-gara';
|
const out = process.env.OUT ?? '/tmp/regolo-gara';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -12,7 +13,7 @@ let browser;
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport, level] of [
|
for (const [name, mobile, viewport, level] of [
|
||||||
['touch', true, { width: 844, height: 390 }, 'seria'],
|
['touch', true, { width: 844, height: 390 }, 'seria'],
|
||||||
['portrait', true, { width: 390, height: 844 }, 'bona'],
|
['portrait', true, { width: 390, height: 844 }, 'bona'],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
|
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -12,7 +13,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||||
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
||||||
await page.goto(`http://127.0.0.1:${server.port}/`);
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-micro';
|
const out = process.env.OUT ?? '/tmp/regolo-micro';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -12,7 +13,7 @@ let browser;
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
for (const [name, mobile, viewport] of [
|
for (const [name, mobile, viewport] of [
|
||||||
['desktop', false, { width: 1280, height: 860 }],
|
['desktop', false, { width: 1280, height: 860 }],
|
||||||
['touch', true, { width: 844, height: 390 }],
|
['touch', true, { width: 844, height: 390 }],
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export async function checkMultimedia(browser, port, out, errors) {
|
|||||||
else await page.mouse.move(...await at(target));
|
else await page.mouse.move(...await at(target));
|
||||||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||||||
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
|
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
|
||||||
for (let n = 0; n < 200; n++) {
|
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
|
||||||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||||||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||||||
await page.waitForTimeout(25);
|
await page.waitForTimeout(25);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-risveglio';
|
const out = process.env.OUT ?? '/tmp/regolo-risveglio';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
// Sul computer la versione con la sfocatura vera, sul telefono (touch) quella leggera senza filtri.
|
// Sul computer la versione con la sfocatura vera, sul telefono (touch) quella leggera senza filtri.
|
||||||
for (const mobile of [false, true]) {
|
for (const mobile of [false, true]) {
|
||||||
const page = await browser.newPage(mobile ? { viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } : { viewport: { width: 1280, height: 860 } });
|
const page = await browser.newPage(mobile ? { viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } : { viewport: { width: 1280, height: 860 } });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
||||||
const server = await staticServer(), errors = [];
|
const server = await staticServer(), errors = [];
|
||||||
@@ -44,7 +45,7 @@ async function wait(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||||
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-smarronata';
|
const out = process.env.OUT ?? '/tmp/regolo-smarronata';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||||
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
||||||
await page.goto(`http://127.0.0.1:${server.port}/`);
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-telefono';
|
const out = process.env.OUT ?? '/tmp/regolo-telefono';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
@@ -11,7 +12,7 @@ const server = await staticServer();
|
|||||||
let browser;
|
let browser;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||||
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
||||||
await page.goto(`http://127.0.0.1:${server.port}/`);
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
|
||||||
const server = await staticServer(), errors = [];
|
const server = await staticServer(), errors = [];
|
||||||
@@ -46,7 +47,7 @@ async function wait(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||||
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { chromium } from 'playwright';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { staticServer } from './helpers.mjs';
|
import { staticServer } from './helpers.mjs';
|
||||||
|
import { ARGS } from './chromium.mjs';
|
||||||
|
|
||||||
const out = process.env.OUT ?? '/tmp/regolo-shots';
|
const out = process.env.OUT ?? '/tmp/regolo-shots';
|
||||||
mkdirSync(out, { recursive: true });
|
mkdirSync(out, { recursive: true });
|
||||||
const server = await staticServer();
|
const server = await staticServer();
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
browser = await chromium.launch({ args: ARGS });
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Le prove nel browser a sezioni (scripts/verify.mjs): ogni file del gioco finisce negli ingressi delle prove giuste,
|
||||||
|
// e l'impronta cambia solo se cambia un ingresso.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { SUITES, ENGINE, inputsOf, ignored, fingerprint, plan } from '../scripts/verify.mjs';
|
||||||
|
|
||||||
|
const FILES = ['index.html', 'style.css', 'src/main.js', 'src/version.js', 'package.json', 'package-lock.json', 'Makefile', 'README.md',
|
||||||
|
'src/engine/game.js', 'src/engine/render.js', 'src/engine/ambient.js', 'src/engine/bocce.js', 'src/engine/nuovo.js',
|
||||||
|
'src/engine/briscola.js', 'src/engine/briscola-render.js', 'src/engine/flipper.js', 'src/engine/corsa.js', 'src/story/piloti.js',
|
||||||
|
'src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'assets/closeups/micro-parcheggio.png',
|
||||||
|
'assets/closeups/gp/boccino.png', 'assets/muro/index.html', 'assets/video/briscola.mp4', 'docs/BRISCOLA.md', 'docs/STATO.md',
|
||||||
|
'tests/helpers.mjs', 'tests/chromium.mjs', 'tests/browser.mjs', 'tests/briscola.browser.mjs', 'tests/briscola.test.mjs', 'tests/story.test.mjs',
|
||||||
|
'scripts/verify.mjs', 'scripts/bump.sh', 'tools/slice_sprites.py', '.verify-ok.json'];
|
||||||
|
const suitesWith = path => Object.keys(SUITES).filter(n => inputsOf(n, FILES).includes(path));
|
||||||
|
const islands = Object.keys(SUITES).filter(n => !SUITES[n].core), cores = Object.keys(SUITES).filter(n => SUITES[n].core);
|
||||||
|
|
||||||
|
test('ogni prova nel browser di tests/ ha la sua voce, e una sola', () => {
|
||||||
|
const runs = Object.values(SUITES).map(s => s.run[0]);
|
||||||
|
for (const f of readdirSync('tests').filter(f => f.endsWith('.browser.mjs'))) {
|
||||||
|
if (f === 'multimedia.browser.mjs') continue; // la chiama la parte «telefoni» di browser.mjs
|
||||||
|
const n = runs.filter(r => r === `tests/${f}`).length;
|
||||||
|
assert.equal(n, f === 'browser.mjs' ? 3 : 1, `tests/${f} è in ${n} prove`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la briscola si riprova solo se cambia la briscola o il motore', () => {
|
||||||
|
assert.deepEqual(suitesWith('src/engine/briscola.js'), ['briscola']);
|
||||||
|
assert.deepEqual(suitesWith('src/engine/briscola-render.js'), ['briscola']);
|
||||||
|
assert.deepEqual(suitesWith('assets/video/briscola.mp4'), ['briscola']);
|
||||||
|
assert.deepEqual(suitesWith('tests/briscola.browser.mjs'), ['briscola']);
|
||||||
|
assert.deepEqual(suitesWith('docs/BRISCOLA.md'), [], 'i documenti non fanno rifare niente');
|
||||||
|
assert.ok(!inputsOf('briscola', FILES).includes('src/story/story.js'), 'la storia non fa riprovare la briscola');
|
||||||
|
assert.deepEqual(suitesWith('src/engine/corsa.js'), ['gara']);
|
||||||
|
assert.deepEqual(suitesWith('src/story/piloti.js'), ['gara']);
|
||||||
|
assert.deepEqual(suitesWith('assets/closeups/gp/boccino.png'), ['gara']);
|
||||||
|
assert.deepEqual(suitesWith('assets/muro/index.html'), ['telefono']);
|
||||||
|
assert.deepEqual(suitesWith('assets/closeups/micro-parcheggio.png'), ['micro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la storia, le scene, i minigiochi dentro la partita e un modulo nuovo fanno rifare la partita intera, non i minigiochi a sé', () => {
|
||||||
|
assert.deepEqual(suitesWith('tests/browser.mjs'), ['principale', 'minigiochi', 'telefoni'], 'il file di una prova conta solo per lei');
|
||||||
|
for (const path of ['src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'src/engine/bocce.js', 'src/engine/ambient.js', 'src/engine/nuovo.js']) {
|
||||||
|
assert.deepEqual(suitesWith(path), cores, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il motore comune fa rifare tutto', () => {
|
||||||
|
for (const path of ENGINE) assert.deepEqual(suitesWith(path), Object.keys(SUITES), path);
|
||||||
|
assert.ok(islands.length >= 10 && cores.length >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versione, package.json, Makefile, documenti, strumenti e test unitari non fanno rifare niente', () => {
|
||||||
|
for (const path of ['src/version.js', 'package.json', 'Makefile', 'README.md', 'docs/STATO.md', 'scripts/bump.sh', 'scripts/verify.mjs', 'tools/slice_sprites.py', 'tests/briscola.test.mjs', 'tests/story.test.mjs', '.verify-ok.json']) {
|
||||||
|
assert.ok(ignored(path), `${path} dovrebbe essere ignorato`);
|
||||||
|
assert.deepEqual(suitesWith(path), [], path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'impronta cambia solo se cambia un ingresso, e il piano dice quali", () => {
|
||||||
|
const hashes = new Map(FILES.map(p => [p, 'h-' + p]));
|
||||||
|
const before = plan(hashes, {});
|
||||||
|
assert.ok(before.every(p => !p.green), 'mai provate: tutte da rifare');
|
||||||
|
const ok = Object.fromEntries(before.map(p => [p.name, { fingerprint: p.fp, hashes: Object.fromEntries(p.inputs.map(f => [f, hashes.get(f)])) }]));
|
||||||
|
assert.ok(plan(hashes, ok).every(p => p.green), 'niente cambiato: tutte verdi');
|
||||||
|
hashes.set('docs/STATO.md', 'altro');
|
||||||
|
assert.ok(plan(hashes, ok).every(p => p.green), 'un documento non cambia niente');
|
||||||
|
hashes.set('src/engine/briscola.js', 'altro');
|
||||||
|
const after = plan(hashes, ok);
|
||||||
|
assert.deepEqual(after.filter(p => !p.green).map(p => p.name), ['briscola']);
|
||||||
|
assert.deepEqual(after.find(p => p.name === 'briscola').changed, ['src/engine/briscola.js']);
|
||||||
|
assert.notEqual(fingerprint(['a'], () => '1'), fingerprint(['a'], () => '2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la pipeline di Gitea prova le stesse prove, con lo stesso Playwright del progetto', () => {
|
||||||
|
const wf = readFileSync('.gitea/workflows/test.yml', 'utf8');
|
||||||
|
const listed = wf.match(/prova: \[([^\]]*)\]/)[1].split(',').map(s => s.trim());
|
||||||
|
assert.deepEqual([...listed].sort(), Object.keys(SUITES).sort());
|
||||||
|
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')).packages['node_modules/playwright'].version;
|
||||||
|
assert.match(wf, new RegExp(`mcr\\.microsoft\\.com/playwright:v${lock.replace(/\./g, '\\.')}-`), `immagine di Playwright diversa da ${lock}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Configurazione di act_runner (vedi docker-compose.yml accanto).
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 6
|
||||||
|
timeout: 30m
|
||||||
|
container:
|
||||||
|
network: ""
|
||||||
|
privileged: false
|
||||||
|
options: "--memory=4g"
|
||||||
|
valid_volumes: []
|
||||||
|
force_pull: false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Runner delle Gitea Actions di git.manzolo.it, sul PC di Andrea (dal 2026-11-01). Si crea e si avvia con
|
||||||
|
# `make runner` (scripts/runner.sh): registra il runner sul repo se manca e lo lascia acceso (riparte con Docker).
|
||||||
|
# I job girano in container fratelli tramite il socket Docker del PC; le credenziali del runner stanno fuori dal repo.
|
||||||
|
name: gitea-runner
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: gitea/act_runner:0.6.1
|
||||||
|
container_name: gitea-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CONFIG_FILE: /config.yaml
|
||||||
|
GITEA_INSTANCE_URL: https://git.manzolo.it
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
|
||||||
|
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-pc-andrea}
|
||||||
|
GITEA_RUNNER_LABELS: ubuntu-latest:docker://node:22-bookworm
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml:ro
|
||||||
|
- ${RUNNER_DATA:?}:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
Reference in New Issue
Block a user