Compare commits

...

14 Commits

Author SHA1 Message Date
Claude Agent
56d9340f96 fix(play.html): motor înainte de boot — elimină race „motor lipsă" în Brave
Cauză: boot-ul (inflate hash → __runGame()) rula într-un <script> ÎNAINTEA
celui care definea window.__runGame. În Brave, await-ul din inflate se rezolva
pe microtask înainte ca scriptul motor să fie parsat → __runGame undefined →
"Eroare internă: motor lipsă."

Fix în generator (campaignShell bootMode='hash'): definește window.__runGame
în primul <script>, apoi boot-ul (compressJs + TPL + inflate → MASTER →
__runGame()) în al doilea. Ordinea garantează că motorul există când boot-ul
rulează — fără injecție dinamică, fără dependență de timing. play.html regenerat
din playerHTML(). Test @share actualizat: verifică engine-before-boot, fără text/plain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:55:00 +00:00
Claude Agent
4d1774429a play.html: elimină injecția dinamică — motor în <script> normal + wrapper
Brave pica la orice injecție textContent/blob a scriptului (encoding bug).
Fix definitiv: scriptul motorului devine <script> normal cu wrapper
window.__runGame=function(){...}; loader-ul apelează __runGame() după
ce setează window.MASTER. Zero injecție dinamică, zero encoding issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:32:30 +00:00
Claude Agent
adc786ceec play.html: ASCII-ify codul motorului înainte de injecție (fix Brave)
Brave pica pe caractere non-ASCII (ă, ț, —, →) din comentariile
JS la parsare. Fix: replace /[^\x00-\x7F]/ → \uXXXX înainte de
appendChild — cod ASCII pur, imun la orice encoding mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:22:28 +00:00
Claude Agent
714b68d017 play.html: Blob URL injection pentru compatibilitate Brave
Brave bloca appendChild cu textContent (anti-fingerprinting / Shields).
Fix: script injectat via Blob URL (tratat ca extern), cu fallback la
textContent pentru browsere fără createObjectURL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:11:59 +00:00
Claude Agent
510581345a play.html: try/catch în loader + .nojekyll
- inflateFromBase64url: prinde JSON corupt cu mesaj clar
- appendChild: prinde SyntaxError de browser cu mesaj util
- null-check pe #run element (race condition paranoia)
- .nojekyll: previne Jekyll să proceseze fișierele

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:02:48 +00:00
Claude Agent
07664406ac docs: play.html este fisier generat — nota regenerare dupa modificari motoare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:38:10 +00:00
Claude Agent
25c6631e68 play.html: player universal generat (toate 5 motoare, boot din hash)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:37:25 +00:00
Claude Agent
00263829cc defaultState: baseUrl romfast.github.io; index.html: link player universal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:36:08 +00:00
Claude Agent
8998cdc10e README: link GitHub Pages + sectiune distribuie QR/link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:34:19 +00:00
Claude Agent
dba7fff7a2 Iterația 3: Joc-în-URL + QR (compresie, player hash, encoder QR, UI share)
Stage 1: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream deflate-raw,
offline, file://); `SNIP.compressJs` cu helpers inflate (doubled backslashes).

Stage 2: `campaignShell({tplJson,masterExpr,titleExpr,nStyles,bootMode})` refactor;
gameCampaign = wrapper subțire; bootMode='inline' (nop) | 'hash' (player).

Stage 3: `playerHTML()` — toate 5 motoare inline; boot async cu `(async function(){})()`
(fix: lipsea `function`, eroare Unexpected token '{' în Chromium); MASTER din
location.hash deflate-raw; orchestrator în <script type="text/plain" id="run">.

Stage 4: Encoder QR vanilla JS — GF(256), Reed-Solomon ECC L, byte mode, versiuni 1-22,
8 măști + penalty, BCH format/version. `makeQrSvg(text, opts)` → SVG.

Stage 5: UI builder — fieldset distribuie, #btnShare/#btnCopyLink/#btnDownloadPlayer/
#btnPrintQr, #qrBox, #qrCard (print A4). baseUrl în state, deleted din cleanState().

Tests: 41/41 (6 noi @share). Fix test player: necesită __ow.enterDoor(0) după btn-start
(overworld first, then enter room). Demo files: restaurate din git (configs hardcodate în @regresie).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 12:58:41 +00:00
Claude Agent
8fc8f8040f Adventure Mode v0 (E0-E6): ramificare per-raspuns in campanie
Flag opt-in `adventure` (default off) — zero regresie non-adventure.
E0: `adventure:false` in defaultState + checkbox builder + `ADVENTURE` in orchestrator.
E1: `_lastGiven` in libJS; `checkAnswer` captureaza raspunsul; `campaignDone`
    calculeaza cheia branch ('*'/text-tf/index-choice) si o adauga in payload nextRoom.
E2: `resolveBranch(idx,key)` + rutare nextRoom: 'end'→owExitUnlocked+showOverworld;
    numar→owUnlocked[dest]+showOverworld(dest). Non-adventure: comportament existent.
E3: `owCheckEnter` blocheaza usi incuiate (ADVENTURE&&!owUnlocked); exit via
    owExitUnlocked. `owRefreshDoors`: stil `.locked` (dim+lock). `__ow.state`:
    expune owUnlocked/owExitUnlocked.
E4: `saveProgress` adauga doneList+owUnlocked+owExitUnlocked+target; `tryResume`
    reconstruieste din doneList non-contiguu (nu bucla liniara 0..idx).
E5: `buildDiploma`: ADVENTURE&&!roomDone[i] → 'neexplorata' (nu stele inselatoare).
E6: Builder UI — `normalizePuzzle` garanteaza p.branch={}; `cleanState` clampa
    tintele+strip branch cand !adventure; `puzzleCard` afiseaza dropdown-uri
    ramificare per-puzzle (free=1, tf=2, choice=1/optiune); `data-fb` handler;
    `adventure` change → renderPuzzles().

Smoke 35/35 (4 teste noi: branch-jump, resume non-contiguu, regression, tf branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 21:08:21 +00:00
Claude Agent
023df382f0 Diploma A4 print-first (§Design pct.9): certificat la final campanie
Buton "Vezi diploma" pe finale (+ "Joaca din nou"). Overlay #diploma:
certificat A4 portret alb, chenar dublu accent, titlu serif (singurul),
numele copilului = cel mai mare element.

- buildDiploma(): rand de stele per camera (roomStars[], persistat in resume;
  camere sarite = 🔒 "sarita"), cuvant magic in dale (lacate pt sarite),
  footer = data + "creat de {creator}" + marcaj auriu "timpul a expirat"
- camp builder nou: creator ("Creat de")
- @media print izoleaza #diploma (rest visibility:hidden, margin 20mm,
  print-color-adjust:exact)
- exemplu-campanie.html regenerat

Smoke 31/31 (test nou "diploma": nume/titlu/stele/cuvant/creator/inapoi) +
screenshot scratch/diploma.png (A4, camera sarita, footer expirat).
Cluster T10/PR2 complet (D7 + Timer + Muzica + Diploma). Ramas Etapa 2: Adventure Mode v0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:20:59 +00:00
Claude Agent
d8cb515545 Muzica ambient T10: arpegiu calm care accelereaza sub 1 min
Opt-in din builder (checkbox music, default off). Orchestrator-only: parintele
detine AudioContext (reutilizeaza beep._ctx); camerele nu stiu de muzica.

- arpegiu pentatonica minora (oscilatoare sine scurte), tempo ~1.8x pe ultimul
  minut (legat de _deadline-ul Timer Calm); fara timer -> loop calm fara accelerare
- buton 🎵/🔇 in bara chrome (#btn-music)
- duck pe voce: voiceSay onstart/onend regleaza gain (vocea are prioritate)
- fallback fara AudioContext -> no-op, buton ascuns (zero penalizare)
- porneste la start + resume; stop la showFinale + toggle
- hook test window.__music; exemplu-campanie.html regenerat (ramane opt-in off)

Smoke 30/30 (test nou "muzica ambient": opt-in, start, tempo sub 1 min, duck, toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:12:32 +00:00
Claude Agent
b359bbe50a Timer Calm (T10 / §Design pct.10): ceas campanie opt-in
Ceas M:SS in bara chrome a campaniei. Opt-in din builder (camp "Timp limita
(minute)", default 0 = fara; cleanState coerce 0..120).

- porneste exact la "Incepe aventura" (intro necronometrat)
- deadline ABSOLUT in sessionStorage -> resume nu reseteaza ceasul
- sub 1 min -> auriu (.low); expirare -> ingheata 0:00 + marcaj discret
  (.expired), jocul curge nestingherit (zero penalizare, stelele raman)
- fara rosu pulsant (public copii) -> reduced-motion safe by default
- exemplu-campanie.html regenerat (ramane fara timer - opt-in, ca vocea)

Fundatie pentru muzica T10 (accelerare sub 1 min) + footer diploma.
Test nou (smoke 29/29): format M:SS, prag auriu, freeze la expirare,
jocul continua dupa expirare, resume pastreaza ceasul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:06:36 +00:00
11 changed files with 2925 additions and 102 deletions

0
.nojekyll Normal file
View File

View File

@@ -20,9 +20,10 @@ sursa de adevăr tehnică pentru agenți.
python3 -m http.server 8000 python3 -m http.server 8000
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md): # Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
npx playwright test tests/smoke.mjs # suita completă: 28/28 npx playwright test tests/smoke.mjs # suita completă: 41/41
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16 npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14 npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21
npx playwright test tests/smoke.mjs --grep @share # Iterația 3: 6
``` ```
## Durable Rules (repo-wide) ## Durable Rules (repo-wide)
@@ -30,11 +31,12 @@ npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14
- **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe - **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe
`file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/` `file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/`
sunt **gitignored** — doar dev tooling, nu fac parte din produs. sunt **gitignored** — doar dev tooling, nu fac parte din produs.
- **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~1960 linii), pe secțiuni - **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~3200 linii), pe secțiuni
comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`. comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`.
- **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare: - **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare:
`gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează `gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează
un string HTML complet, standalone. un string HTML complet, standalone. `playerHTML()` generează player universal (hash-mode, toate 5
motoare inline, MASTER din `location.hash` comprimat deflate-raw+base64url).
- **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`, - **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`,
`finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în `finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în
TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit. TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit.
@@ -45,6 +47,10 @@ npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14
- **Demo-urile sunt generate.** `exemplu-*.html` = jocuri exportate din builder, unul per stil. - **Demo-urile sunt generate.** `exemplu-*.html` = jocuri exportate din builder, unul per stil.
**NU le edita manual** — după modificări la motoare, regenerează prin export. `index.html` = doar **NU le edita manual** — după modificări la motoare, regenerează prin export. `index.html` = doar
landing care leagă builder-ul + demo-urile. landing care leagă builder-ul + demo-urile.
- **`play.html` este generat.** Player universal (toate 5 motoare inline, boot din hash). Generat cu
`playerHTML()` din builder și commitat în repo pentru GitHub Pages. **Regenerează după orice
modificare la motoare:** `node --input-type=module < /tmp/gen-player.mjs` (sau echivalent Playwright)
`git add play.html && git commit && git push github main`.
- **Stare.** Obiectul `state` (titlu, poveste, culoare, `style`, `puzzles`) se persistă în - **Stare.** Obiectul `state` (titlu, poveste, culoare, `style`, `puzzles`) se persistă în
`localStorage` sub cheia `escape-builder-v1`; export/import ca JSON. Editorul scrie via `data-g` `localStorage` sub cheia `escape-builder-v1`; export/import ca JSON. Editorul scrie via `data-g`
`onChange()` → persist + `refreshPreview()` (debounce 400ms) care setează `iframe.srcdoc`. `onChange()` → persist + `refreshPreview()` (debounce 400ms) care setează `iframe.srcdoc`.

View File

@@ -217,7 +217,10 @@ Mesajul creatorului
## 10. Diplomă — Certificat A4 Print-First (§Design pct. 9 — Etapa 2 / PR2) ## 10. Diplomă — Certificat A4 Print-First (§Design pct. 9 — Etapa 2 / PR2)
> Implementare în T10/PR2. Spec inclusă aici ca parte a contractului de design. > **LIVRAT** (2026-06-13). Overlay `#diploma`; buton „Vezi diploma →" pe finale + „Joacă din nou".
> `buildDiploma()` citește `roomStars`/`collected`/`skipped`/`MASTER`/`_timerExpired`. Câmp builder nou
> `creator` („Creat de"). `@media print` izolează `#diploma` (rest `visibility:hidden`). Test smoke
> „diploma" (nume/titlu/stele-per-cameră/cuvânt/creator/înapoi). Camere sărite = 🔒 (verificat vizual).
- **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)` - **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)`
- **Titlu:** DIPLOMĂ DE EVADARE" **singurul** element cu font serif (limbajul certificatelor) - **Titlu:** DIPLOMĂ DE EVADARE" **singurul** element cu font serif (limbajul certificatelor)
@@ -231,7 +234,10 @@ Mesajul creatorului
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2) ## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
> Implementare în T10/PR2. > **LIVRAT** (2026-06-13). Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără).
> Implementare: `#chrome-timer` în bara chrome; `startTimer/tickTimer/stopTimer`; deadline absolut
> în `sessionStorage` (`_DEADLINE_KEY`). Sub 1 min → `.low` (auriu); expirat → `.expired` (auriu, opac).
> Test smoke „timer calm" (format, gold, freeze, resume păstrează ceasul).
- Pornește **exact** la click Începe aventura" (intro necronometrat) - Pornește **exact** la click Începe aventura" (intro necronometrat)
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`) - Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)

View File

@@ -1,6 +1,30 @@
# Escape Room Builder # Escape Room Builder
Generator de jocuri escape room intr-un singur fisier HTML, fara backend, fara build. Acelasi set de puzzle-uri poate fi exportat in 5 stiluri de joc diferite. **[▶ Demo live](https://romfast.github.io/escape-builder/escape-builder.html)** · **[Player universal](https://romfast.github.io/escape-builder/play.html)**
Generator de jocuri escape room intr-un singur fisier HTML, fara backend, fara build. Acelasi set de puzzle-uri poate fi exportat in 5 stiluri de joc diferite sau ca **campanie** multi-camera cu harta.
## Distribuie prin link + QR
Builder-ul poate comprima jocul intr-un URL scurt si genera un cod QR printabil:
1. Adauga puzzle-uri in editor
2. Apasa **„Generează QR / link"** — apare QR-ul + URL-ul complet
3. Trimite URL-ul sau printeaza cardul QR (buton „Printează cardul QR")
4. Jucatorul deschide link-ul pe telefon — campania porneste instant
URL-ul pointeaza spre player-ul universal hostat pe GitHub Pages:
`https://romfast.github.io/escape-builder/play.html#<joc-comprimat>`
Jocul calatoreste comprimat in URL (deflate-raw + base64url); playerul il decodeaza local,
fara server, fara baza de date. 12+ puzzle-uri incap in ~636 bytes.
> **`play.html` este un fișier generat** — conține toate 5 motoare de joc inline.
> Dupa modificari la motoare, regenereaza si recommita:
> ```bash
> node --input-type=module < /tmp/gen-player.mjs # sau din builder: playerHTML()
> git add play.html && git commit -m "regen play.html" && git push github main
> ```
## Folosire ## Folosire
@@ -13,12 +37,11 @@ cd /workspace/escape-builder
python3 -m http.server 8000 python3 -m http.server 8000
``` ```
Apoi navigheaza la `http://<IP-server>:8000/escape-builder.html` (in containerul curent: `http://10.0.20.171:8000/escape-builder.html`).
- **Stanga**: editor — titlu, poveste, culoare, **stil joc**, puzzle-uri (raspuns liber / adevarat-fals / variante), indiciu si litera per puzzle. - **Stanga**: editor — titlu, poveste, culoare, **stil joc**, puzzle-uri (raspuns liber / adevarat-fals / variante), indiciu si litera per puzzle.
- **Dreapta**: preview live — jocul exact cum va arata, jucabil direct in pagina. - **Dreapta**: preview live — jocul exact cum va arata, jucabil direct in pagina.
- **Exporta jocul HTML**: descarca un joc standalone pe care il trimiti pe telefon/email; merge offline. - **Exporta jocul HTML**: descarca un joc standalone pe care il trimiti pe telefon/email; merge offline.
- **Salveaza / Incarca JSON**: pastreaza proiectul ca fisier ca sa-l reiei mai tarziu. - **Salveaza / Incarca JSON**: pastreaza proiectul ca fisier ca sa-l reiei mai tarziu.
- **Generează QR / link**: comprima jocul intr-un URL + afiseaza cod QR printabil.
Proiectul curent se salveaza automat in `localStorage` la fiecare modificare. Proiectul curent se salveaza automat in `localStorage` la fiecare modificare.
@@ -41,12 +64,13 @@ Proiectul curent se salveaza automat in `localStorage` la fiecare modificare.
## Testare ## Testare
Suita de teste Playwright (smoke + campanie), fara server, direct pe `file://`: Suita de teste Playwright (smoke + campanie + share), fara server, direct pe `file://`:
```bash ```bash
npx playwright test tests/smoke.mjs # toata suita (26/26) npx playwright test tests/smoke.mjs # toata suita (41/41)
npx playwright test tests/smoke.mjs --grep @regresie npx playwright test tests/smoke.mjs --grep @regresie
npx playwright test tests/smoke.mjs --grep @campanie npx playwright test tests/smoke.mjs --grep @campanie
npx playwright test tests/smoke.mjs --grep @share
``` ```
Detalii harness in `tests/AGENTS.md`. Detalii harness in `tests/AGENTS.md`.

102
TODOS.md
View File

@@ -18,7 +18,7 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2
- [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26. - [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26.
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit). **PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 LIVRAT — vezi §dedicată mai jos.) Rămas din Etapa 2: Adventure Mode v0. (D7 + Timer Calm + Muzică T10 + Diplomă LIVRATE — vezi §§ mai jos.)
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT ### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`): Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
@@ -96,12 +96,40 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială)
## Post-PR1 (după ship-ul campaniei) ## Post-PR1 (după ship-ul campaniei)
### Muzică accelerată la timer (PR2 / T10) ### [x] Diplomă A4 print-first (§Design pct.9) — LIVRAT (2026-06-13)
- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut. Certificat A4 portret, fundal alb, chenar dublu accent, titlu serif „DIPLOMĂ DE EVADARE" (singurul serif),
- Ownership: părintele deține AudioContext; camerele nu știu de muzi. numele copilului = cel mai mare element. Overlay `#diploma`; buton „Vezi diploma →" pe finale (+ „Joa
- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve). din nou"). `buildDiploma()` randează: rând de stele per cameră (★★★/★★☆; camere sărite = 🔒 „sărită"),
- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan. cuvântul magic în dăle (aceeași iconografie ca finalul, lacăte pentru sărite), footer = dată +
- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10). „creat de {creator}" + marcaj auriu „timpul a expirat" (dacă `_timerExpired`). Câmp builder nou `creator`.
Per-cameră `roomStars[]` (persistat în resume). `@media print` izolează `#diploma` (rest `visibility:hidden`,
`margin:20mm`, `print-color-adjust:exact`). Verificat: smoke 31/31 (test nou „diploma") + screenshot
(`scratch/diploma.png`: A4, 🔒 cameră sărită, footer expirat). Rămas din Etapa 2: doar Adventure Mode v0.
### [x] Timer Calm (§Design pct.10 / T10) — LIVRAT (2026-06-13)
Ceas M:SS în bara chrome a campaniei. Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără;
`cleanState` coerce la întreg 0..120). Pornește la „Începe aventura" (intro necronometrat); deadline
ABSOLUT în `sessionStorage` (`_DEADLINE_KEY`) → resume-ul (reload mid-campanie) NU resetează ceasul.
Sub 1 minut → auriu (`.low`); la expirare îngheață pe `0:00` + marcaj discret (`.expired`, auriu opac),
jocul curge nestingherit (zero penalizare, stelele rămân). Fără roșu pulsant (public copii) → reduced-motion
safe by default. `exemplu-campanie.html` regenerat (rămâne fără timer — opt-in, ca vocea). Verificat:
smoke 29/29 (test nou „timer calm": format M:SS, prag auriu, freeze la expirare, jocul continuă, resume
păstrează ceasul). Commit: (acest commit). Următorul: muzică T10 (accelerare sub 1 min — depinde de timer).
### [x] Muzică ambient accelerată la timer (PR2 / T10) — LIVRAT (2026-06-13)
Opt-in din builder (checkbox `music`, default off). Orchestrator-only: părintele deține AudioContext
(reutilizează `beep._ctx`, deblocat de gestul global); camerele NU știu de muzică. Arpegiu calm pe
pentatonică minoră (`_mTick`, oscilatoare sine scurte la ~520ms); tempo **accelerează** spre ~1.8×
pe ultimul minut (`musicTempoFactor`, legat de `_deadline`-ul Timer Calm). Buton 🎵/🔇 în bara chrome
(`#btn-music`). Edge-uri tratate:
- **Duck pe voce:** `voiceSay` setează `u.onstart→duckMusic(true)` / `onend|onerror→duckMusic(false)`;
`voiceCancel` și el unduck. Vocea are prioritate (gain muzică × 0.22 cât timp vorbește).
- **Fallback fără AudioContext:** tot în `try/catch` → no-op, buton ascuns (zero penalizare).
- pornește la „Începe aventura" + la resume; se oprește la `showFinale` (+ toggle).
- fără timer → tempo rămâne 1.0 (loop calm, fără accelerare).
Hook test `window.__music` (`tempo()`, `state()`). `exemplu-campanie.html` regenerat (rămâne fără
muzică — opt-in, ca vocea). Verificat: smoke 30/30 (test nou „muzica ambient": opt-in, start, tempo
crește sub 1 min, duck, toggle). Următorul roadmap: Diplomă (§Design pct.9) + Adventure Mode v0.
### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2) ### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2)
Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox
@@ -187,22 +215,56 @@ Referință: §Design pct. 13 (TD5, PR2); D19 din plan.
--- ---
## Iterația 2 — Adventure Mode v0 ### [x] Adventure Mode v0 — LIVRAT (2026-06-13)
*(decizie office-hours: fundația contractului de azi e infrastructura directă)* Opt-in flag `adventure` (default off) → campanie cu ramificare per-răspuns. Zero regresie non-adventure.
- Contract de montare (`nextRoom`, `roomReady`, `roomError`) se refolosesc as-is. **E0**`adventure: false` în `defaultState()`; checkbox `data-gb="adventure"` în builder (lângă voice/music);
- Motoarele noi (orice stil) implementează aceleași 3 puncte + `parent.beep`. `var ADVENTURE = !!MASTER.adventure` în orchestrator.
- `gameCampaign` se extinde cu ramificare: `if (answer === 'left') nextRoom({dir: 'left'})`.
- Builder UI: adaugă câmpul "ramificare" per puzzle; drag & drop între camere.
- Referință: design doc §NOT in scope "Adventure Mode v0".
## Iterația 3 — Joc-în-URL + QR **E1**`_lastGiven` în libJS; `checkAnswer` setează `_lastGiven` pe succes; `campaignDone()` calculează
*(depinde de măsurarea dimensiunii JSON comprimate)* cheia branch (`'*'` free, text pentru tf, index string pentru choice) și o trimite în payload `nextRoom`.
- `gameHTML(cfg)` → URL data: sau LZW/gzip → QR code printabil. **E2**`resolveBranch(idx, key)`: non-adventure→liniar; adventure→`p.branch[key]` (fallback `branch['*']`,
- Open Question 2 din design doc: câte puzzle-uri încap în 2KB (URL QR L)? apoi liniar idx+1); 'end'/out-of-range→'end'. `nextRoom` pe ramura ADVENTURE: 'end'→`owExitUnlocked=true`+
- Alternative: GitHub Pages export automat; sau link scurt cu backend minimal. `showOverworld` cu exit deblocat; număr→`owUnlocked[dest]=true`+`owTargetIdx=dest`+`showOverworld(dest)`.
- Referință: design doc §NOT in scope "Joc-în-URL + QR".
**E3**`owCheckEnter`: blocat dacă `ADVENTURE && !owUnlocked[d.idx]`; exit folosește `owExitUnlocked` în
loc de `owAllDone()`. `owRefreshDoors`: stilul `.locked` (dim+🔒) pentru ușile nedeblcate; hint/exit
folosesc `owExitUnlocked`. `window.__ow.state`: adaugă `owUnlocked`/`owExitUnlocked`.
**E4**`saveProgress`: adaugă `doneList`, `owUnlocked`, `owExitUnlocked`, `target`. `tryResume`: pe
ADVENTURE reconstruiește din `doneList` (non-contiguu), nu bucla liniară `0..saved.idx`.
**E5**`buildDiploma`: camerele `ADVENTURE && !roomDone[i]` → „neexplorată" (nu ☆☆☆ înșelător).
**E6** — Builder UI: `normalizePuzzle` garantează `p.branch={}`; `cleanState` clampa țintele + strip
`branch` când `!adventure`; `puzzleCard` afișează dropdown-uri ramificare per-puzzle (free=1, tf=2,
choice=1/opțiune); `data-fb`/`data-bkey` handler în input listener; `adventure` change → `renderPuzzles()`.
Verificat: smoke 35/35 (4 teste noi: branch-jump, resume non-contiguu, regression non-adventure, tf branch).
## Iterația 3 — Joc-în-URL + QR ✅ LIVRAT (2026-06-14)
**Scope livrat:**
- [x] **Stage 1 — Compresie URL**: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream native,
offline, `file://`). `CS_OK` guard. `SNIP.compressJs` cu helpers inflate (doubled backslashes).
- [x] **Stage 2 — Refactor `campaignShell`**: parametrizat `bootMode='inline'|'hash'`. Zero schimbare
de comportament pentru inline (35/35 smoke). `chrome-title` + `document.title` setate din MASTER.
- [x] **Stage 3 — `playerHTML()` + boot din hash**: player universal (toate 5 motoare); async IIFE
(corect `(async function(){...})()`) decomprimă hash → setează MASTER → apendează orchestratorul
din `<script type="text/plain" id="run">`. No-hash → "Niciun joc în acest link."
- [x] **Stage 4 — Encoder QR**: GF(256), Reed-Solomon, byte mode, ECC L, auto-versiune 1-22,
selecție mască + BCH format/version. `makeQrSvg(text, opts)` → SVG string sau `null`.
- [x] **Stage 5 — UI builder**: fieldset „Distribuie (link+QR)", `#btnShare`/`#btnCopyLink`/
`#btnDownloadPlayer`/`#btnPrintQr`, `#qrBox`, `#qrCard` (print A4). `baseUrl` în state
(deleted din `cleanState()` → nu intră în payload). Butoane disabled dacă `!CS_OK`.
- [x] **Stage 6 — Docs**: 41/41 smoke, TODOS/AGENTS actualizate.
**DEFER** (fast-follow): Import-din-hash în builder (`escape-builder.html#hash` → editabil).
Reutilizează inflate+Save/Load; adaugă cale async la boot-ul builder (azi sincron).
Verificat: smoke 41/41. Capabilitate: 12+ puzzle-uri → ~636B → QR v10 L; 30+ puzzle-uri → ~750B.
Scan manual cu telefon real: TODO (notat în HANDOFF).
--- ---

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@
<p>Builder-ul + cate un joc demo exportat in fiecare stil.</p> <p>Builder-ul + cate un joc demo exportat in fiecare stil.</p>
<a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a> <a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a>
<a href="exemplu-campanie.html" style="border-color:#a78bfa;background:rgba(167,139,250,.12)">🗺️ Campanie multi-stil <span>hartă overworld: mergi la fiecare ușă, intri într-o cameră într-un alt stil, aduni litere → cuvântul magic</span></a> <a href="exemplu-campanie.html" style="border-color:#a78bfa;background:rgba(167,139,250,.12)">🗺️ Campanie multi-stil <span>hartă overworld: mergi la fiecare ușă, intri într-o cameră într-un alt stil, aduni litere → cuvântul magic</span></a>
<a href="play.html" style="border-color:#34d399;background:rgba(52,211,153,.10)">▶ Player universal <span>deschide un joc comprimat din URL (#hash) — trimite link-ul sau scanează QR-ul generat din builder</span></a>
<a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a> <a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a>
<a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a> <a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a>
<a href="exemplu-arcade.html">Arcade Bomberman <span>sageti / WASD + bombe; sparge cutiile, evita dusmanii, usi cu intrebari, cufar = scaparea</span></a> <a href="exemplu-arcade.html">Arcade Bomberman <span>sageti / WASD + bombe; sparge cutiile, evita dusmanii, usi cu intrebari, cufar = scaparea</span></a>

1059
play.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec
până la ecranul final, fără erori de consolă. până la ecranul final, fără erori de consolă.
## Ownership ## Ownership
- `tests/smoke.mjs` — unicul fișier de teste (~28 teste). - `tests/smoke.mjs` — unicul fișier de teste (~41 teste).
- `playwright.config.mjs` (la root, **gitignored**) — config dev. - `playwright.config.mjs` (la root, **gitignored**) — config dev.
## Local Contracts ## Local Contracts
@@ -13,25 +13,30 @@ până la ecranul final, fără erori de consolă.
zero-dependențe. Instalarea dev e o singură dată: `npm i -D @playwright/test && npx playwright install chromium`. zero-dependențe. Instalarea dev e o singură dată: `npm i -D @playwright/test && npx playwright install chromium`.
- **Fără npm scripts** — se rulează direct cu `npx`. - **Fără npm scripts** — se rulează direct cu `npx`.
- **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie - **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`. HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`. Testele `@share` scriu
player HTML temp în `tests/.tmp-player*.html` (deleted în `finally`).
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`; - **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
fiecare test asertează `errors.length === 0` la final. fiecare test asertează `errors.length === 0` la final.
- **Tag-uri:** `@regresie` (15 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML + - **Tag-uri:** `@regresie` (16), `@campanie` (21), `@share` (6 — Iterația 3):
bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` (12 — intro→hartă→camere→final, resume, - `@share compresie round-trip` — deflate/inflate builder
cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10, - `@share QR structural` — makeQrSvg SVG valid
a11y tap/aria/reduced-motion, navigare overworld). - `@share playerHTML()` — structura HTML player
- **Status țintă: 28/28 PASS.** - `@share player porneste din hash` — campanie 1 cameră din URL hash; folosește `__ow.enterDoor(0)`
- `@share player fara hash` — mesaj „Niciun joc"
- `@share share UI` — butoane disabled fără CompressionStream
- **Status țintă: 41/41 PASS.**
## Work Guidance ## Work Guidance
- După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă - După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă
adaugi/schimbi un stil, `@campanie` pentru contractul de montare. adaugi/schimbi un stil, `@campanie` pentru contractul de montare, `@share` pentru Iterația 3.
- Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori. - Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori.
## Verification ## Verification
```bash ```bash
npx playwright test tests/smoke.mjs # 28/28 npx playwright test tests/smoke.mjs # 41/41
npx playwright test tests/smoke.mjs --grep @regresie npx playwright test tests/smoke.mjs --grep @regresie # 16
npx playwright test tests/smoke.mjs --grep @campanie npx playwright test tests/smoke.mjs --grep @campanie # 21
npx playwright test tests/smoke.mjs --grep @share # 6
``` ```
## Child DOX Index ## Child DOX Index

View File

@@ -671,6 +671,183 @@ test.describe('Campanie E2E @campanie', () => {
expect(errors, errors.join('\n')).toHaveLength(0); expect(errors, errors.join('\n')).toHaveLength(0);
}); });
test('timer calm — porneste la start, auriu sub 1 min, ingheata la expirare, resume pastreaza ceasul (T10/§Design pct.10) @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
cfg.timerMin = 1;
const tmpPath = await writeCampaignHtml(page, cfg, 'timer');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Intro necronometrat — ceasul e ascuns pana la start
expect(await gp.locator('#chrome-timer').isVisible(), 'timer ascuns pe intro').toBe(false);
await gp.locator('#btn-start').click();
// Dupa start: vizibil, format M:SS, aproape de 1:00
const timer = gp.locator('#chrome-timer');
await expect(timer).toBeVisible();
await expect(timer).toHaveText(/^\d:\d\d$/);
const t0 = await timer.textContent();
const sec0 = (+t0.split(':')[0]) * 60 + (+t0.split(':')[1]);
expect(sec0, 'ceasul porneste ~1:00').toBeGreaterThan(50);
expect(sec0).toBeLessThanOrEqual(60);
// Deadline ABSOLUT salvat in sessionStorage (resume pastreaza ceasul)
const dl = await gp.evaluate(() => +sessionStorage.getItem(_DEADLINE_KEY));
expect(dl, 'deadline absolut in sessionStorage').toBeGreaterThan(Date.now());
// Sub 1 minut → auriu (.low). Manipulam deadline determinist.
await gp.evaluate(() => { _deadline = Date.now() + 5000; tickTimer(); });
await expect(timer).toHaveText('0:05');
await expect(timer).toHaveClass(/low/);
// Expirare → ingheata pe 0:00 + .expired; jocul curge nestingherit
await gp.evaluate(() => { _deadline = Date.now() - 1000; tickTimer(); });
await expect(timer).toHaveText('0:00');
await expect(timer).toHaveClass(/expired/);
// Jocul continua dupa expirare (zero penalizare): rezolva camera 0
await enterRoom(gp, 0);
await solveRoom(gp, 'classic', 'r1');
await waitOverworld(gp);
// Resume pastreaza ceasul: suprascriem deadline-ul cu o valoare cunoscuta, reload
await gp.evaluate(() => { sessionStorage.setItem(_DEADLINE_KEY, String(Date.now() + 8000)); });
await gp.reload();
await gp.waitForLoadState('domcontentloaded');
await gp.waitForFunction(
() => window.__ow && window.__ow.state.active &&
document.getElementById('overworld')?.classList.contains('show'),
null, { timeout: 5000 }
);
// Ceasul reia de la deadline-ul salvat (~0:08), NU resetat la 1:00
await expect(gp.locator('#chrome-timer')).toBeVisible();
const tR = await gp.locator('#chrome-timer').textContent();
const secR = (+tR.split(':')[0]) * 60 + (+tR.split(':')[1]);
expect(secR, 'resume reia ceasul (nu reset la 60)').toBeLessThan(55);
expect(secR).toBeGreaterThan(0);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors, errors.join('\n')).toHaveLength(0);
});
test('muzica ambient — opt-in, porneste la start, tempo accelereaza sub 1 min, duck pe voce, toggle (T10)',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
cfg.music = true;
cfg.timerMin = 1; /* timer pornit → tempo poate accelera */
const tmpPath = await writeCampaignHtml(page, cfg, 'music');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Butonul de muzica vizibil (opt-in activ); muzica inca neporita
await expect(gp.locator('#btn-music')).toBeVisible();
expect(await gp.evaluate(() => window.__music.state().playing), 'inca neporita pe intro').toBe(false);
await gp.locator('#btn-start').click();
// Dupa start: muzica ruleaza, buton apasat
await gp.waitForFunction(() => window.__music.state().playing === true, null, { timeout: 4000 });
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'true');
// Tempo: 1.0 cand >60s ramase; creste progresiv sub 1 min (citit determinist)
const tempos = await gp.evaluate(() => {
const f = window.__music.tempo;
_deadline = Date.now() + 90000; const t90 = f();
_deadline = Date.now() + 30000; const t30 = f();
_deadline = Date.now() + 1000; const t1 = f();
return { t90, t30, t1 };
});
expect(tempos.t90).toBeCloseTo(1, 1);
expect(tempos.t30, 'accelereaza sub 1 min').toBeGreaterThan(tempos.t90);
expect(tempos.t1, 'mai rapid spre expirare').toBeGreaterThan(tempos.t30);
// Duck: vocea are prioritate → atenueaza muzica
const ducked = await gp.evaluate(() => { duckMusic(true); return window.__music.state().duck; });
const unducked = await gp.evaluate(() => { duckMusic(false); return window.__music.state().duck; });
expect(ducked, 'duck activ < 1').toBeLessThan(1);
expect(unducked, 'duck dezactivat = 1').toBe(1);
// Toggle off din buton → se opreste
await gp.locator('#btn-music').click();
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'false');
expect(await gp.evaluate(() => window.__music.state().playing), 'oprit dupa toggle').toBe(false);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors, errors.join('\n')).toHaveLength(0);
});
test('diploma — „Vezi diploma" arata certificat A4 cu nume/stele-per-camera/cuvant/creator, inapoi (§Design pct.9)',
async ({ page }) => {
test.setTimeout(120000);
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
cfg.player = 'Maria';
cfg.creator = 'Doamna Ana';
const tmpPath = await writeCampaignHtml(page, cfg, 'diploma');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
for (let i = 0; i < 3; i++) {
await enterRoom(gp, i);
await solveRoom(gp, 'classic', 'r' + (i + 1));
}
await gp.waitForFunction(() => document.getElementById('finale')?.classList.contains('show'),
null, { timeout: 10000 });
// Buton „Vezi diploma" → diploma vizibila, finale ascunsa
await gp.locator('#btn-diploma').click();
await expect(gp.locator('#diploma')).toBeVisible();
await expect(gp.locator('#finale')).not.toBeVisible();
// Numele copilului (cel mai mare element) + titlul jocului
await expect(gp.locator('#dipl-name')).toHaveText('Maria');
await expect(gp.locator('#dipl-game')).toContainText('Test Campanie');
// Rand de stele per camera: 3 randuri, fiecare cu ★ (rezolvate, nu sarite)
await expect(gp.locator('#dipl-rooms .dipl-room')).toHaveCount(3);
const firstRoom = await gp.locator('#dipl-rooms .dipl-room .rstars').first().innerText();
expect(firstRoom).toMatch(/[★☆]{3}/);
// Cuvantul magic in dale (3 litere colectate, A B C)
await expect(gp.locator('#dipl-word span')).toHaveCount(3);
// Footer: data + „creat de Doamna Ana"
await expect(gp.locator('#dipl-footer')).toContainText('creat de Doamna Ana');
// Butonul de print exista (nu il apasam — window.print blocheaza headless)
await expect(gp.locator('#dipl-print')).toBeVisible();
// Inapoi → finale din nou
await gp.locator('#dipl-back').click();
await expect(gp.locator('#finale')).toBeVisible();
await expect(gp.locator('#diploma')).not.toBeVisible();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Test 3: Camera moartă — timeout 4s → skip-banner + cod eroare // Test 3: Camera moartă — timeout 4s → skip-banner + cod eroare
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -1273,4 +1450,365 @@ test.describe('Campanie E2E @campanie', () => {
expect(errors, errors.join('\n')).toHaveLength(0); expect(errors, errors.join('\n')).toHaveLength(0);
}); });
// ─────────────────────────────────────────────────────────────────────
// Adventure Mode tests (E0-E6)
// ─────────────────────────────────────────────────────────────────────
/** Helper: genereaza cfg de campanie cu adventure ON. */
function adventureCfg(puzzles) {
return {
title: 'Test Adventure', player: 'Tester', color: '#6d28d9',
style: 'campaign', charName: 'Alex',
story: 'Aventura de test.',
finalMessage: 'Ai terminat aventura!',
adventure: true,
puzzles
};
}
test('adventure — branch-jump: room0→2 (sare room1), room2→exit, diploma neexplorata @campanie',
async ({ page }) => {
test.setTimeout(90000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-jump');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Enter room 0 (door 0 unlocked in adventure)
await enterRoom(gp, 0);
await solveRoom(gp, 'classic', 'r0');
// After solving room 0: overworld with door 2 unlocked, door 1 locked
await waitOverworld(gp);
const stAfter0 = await gp.evaluate(() => window.__ow.state);
expect(stAfter0.owUnlocked[2], 'door 2 trebuie deblocata dupa room0').toBeTruthy();
expect(stAfter0.owUnlocked[1], 'door 1 trebuie sa ramana incuiata').toBeFalsy();
expect(stAfter0.doors[0].solved, 'room 0 trebuie sa fie done').toBe(true);
// Door 1 should be locked — entering it should be blocked (stay in overworld)
await gp.evaluate(() => window.__ow.enterDoor(1));
await gp.waitForTimeout(300);
const stLocked = await gp.evaluate(() => window.__ow.state);
expect(stLocked.active, 'harta trebuie sa ramana activa cand usa e incuiata').toBe(true);
// Enter room 2 (unlocked)
await enterRoom(gp, 2);
await solveRoom(gp, 'classic', 'r2');
// After solving room 2: exit should be unlocked
await waitOverworld(gp);
const stAfter2 = await gp.evaluate(() => window.__ow.state);
expect(stAfter2.owExitUnlocked, 'exit trebuie deblocat dupa room2→end').toBe(true);
// Enter exit → finale
await gp.evaluate(() => window.__ow.enterExit());
await gp.waitForFunction(
() => document.getElementById('finale')?.classList.contains('show'),
null, { timeout: 5000 }
);
// Open diploma → camera 1 should be "neexplorata"
await gp.locator('#btn-diploma').click();
const diplomaText = await gp.locator('#dipl-rooms').innerText();
expect(diplomaText).toMatch(/neexplorat/i);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure — resume non-contiguu: room0 done → reload → room0 done + usa2 deblocata + usa1 incuiata @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-resume');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Solve room 0 → branches to room 2, skips room 1
await enterRoom(gp, 0);
await solveRoom(gp, 'classic', 'r0');
await waitOverworld(gp);
// Reload — tryResume trebuie sa reconstituie starea non-contigua
await gp.reload();
await gp.waitForLoadState('domcontentloaded');
// Asteapta overworld activ (resume, nu intro)
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
const stResume = await gp.evaluate(() => window.__ow.state);
expect(stResume.doors[0].solved, 'room 0 trebuie sa fie done dupa resume').toBe(true);
expect(stResume.owUnlocked[2], 'usa 2 trebuie deblocata dupa resume').toBeTruthy();
expect(stResume.owUnlocked[1], 'usa 1 trebuie sa ramana incuiata dupa resume').toBeFalsy();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure off — regresia non-adventure: toate usile intrabile in orice ordine @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
// adventure:false (default) — toate ușile deblocate, orice ordine
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-off');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// In non-adventure: can enter door 1 first (not door 0)
await waitOverworld(gp);
await gp.evaluate(() => window.__ow.enterDoor(1));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
// overworld became inactive (we entered a room) — confirms door 1 was enterable
const stAfter = await gp.evaluate(() => window.__ow.state);
expect(stAfter.active, 'harta trebuie sa fie inactiva dupa intrarea in room1').toBe(false);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure — branch tf: raspuns Adevarat→2, Fals→1 deblocheza usa corecta @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'tf', question: 'E adevarat?', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { Adevarat: 2, Fals: 1 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: {} },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: {} }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-tf');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Enter room 0 (tf puzzle, classic engine: buttons in #answers)
await waitOverworld(gp);
await gp.evaluate(() => window.__ow.enterDoor(0));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
const ifl = gp.frameLocator('#room-frame');
await ifl.locator('#btnStart').click();
// Click "Adevarat" (correct answer → branch key 'Adevarat' → should unlock door 2)
await ifl.locator('#answers button:text("Adevarat")').click();
await gp.waitForTimeout(1200); // animatie next()
await waitOverworld(gp);
const st = await gp.evaluate(() => window.__ow.state);
expect(st.owUnlocked[2], 'Adevarat→2: usa 2 trebuie deblocata').toBeTruthy();
expect(st.owUnlocked[1], 'Adevarat→2: usa 1 trebuie sa ramana incuiata').toBeFalsy();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
});
// ═══════════════════════════════════════════════════════════════════════
// SECTIUNEA 5 — SHARE (link + QR + player.html) @share
// ═══════════════════════════════════════════════════════════════════════
test.describe('Share: link + QR + player @share', () => {
test('@share compresie round-trip: inflate(deflate(s)) === s', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const ok = await page.evaluate(async () => {
if (typeof deflateToBase64url !== 'function') return 'missing deflate';
if (typeof inflateFromBase64url !== 'function') return 'missing inflate';
const s = JSON.stringify({ title: 'Test', puzzles: [{ question: 'x', answer: '42' }] });
const compressed = await deflateToBase64url(s);
if (typeof compressed !== 'string' || compressed.length === 0) return 'empty compressed';
const decompressed = await inflateFromBase64url(compressed);
return decompressed === s ? 'ok' : 'mismatch';
});
expect(ok, 'round-trip result').toBe('ok');
expect(errors).toHaveLength(0);
});
test('@share QR structural: makeQrSvg produce SVG valid', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const result = await page.evaluate(() => {
if (typeof makeQrSvg !== 'function') return { err: 'makeQrSvg missing' };
const svg = makeQrSvg('https://example.com/play.html#abc123');
if (!svg) return { err: 'null result' };
return { hasViewBox: svg.includes('viewBox'), hasPath: svg.includes('<path'), len: svg.length };
});
expect(result.err, 'error').toBeUndefined();
expect(result.hasViewBox, 'viewBox').toBe(true);
expect(result.hasPath, '<path').toBe(true);
expect(result.len, 'svg length').toBeGreaterThan(100);
expect(errors).toHaveLength(0);
});
test('@share playerHTML() genereaza HTML cu inflate + motor inainte de boot + TPL', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const result = await page.evaluate(() => {
if (typeof playerHTML !== 'function') return { err: 'playerHTML missing' };
const html = playerHTML();
/* Motorul (window.__runGame) trebuie definit ÎNAINTE ca boot-ul să-l apeleze, ca să nu
* existe race de parsare (Brave dădea „motor lipsă" când await-ul din inflate se rezolva
* pe microtask înainte ca scriptul motor să fie parsat). */
return {
hasInflate: html.includes('inflateFromBase64url'),
hasRunGame: html.includes('window.__runGame=function'),
noLegacyInjection: !html.includes('text/plain'),
engineBeforeBoot: html.indexOf('window.__runGame=function') < html.indexOf('inflateFromBase64url(h)'),
hasTPL: html.includes('var TPL'),
len: html.length
};
});
expect(result.err, 'error').toBeUndefined();
expect(result.hasInflate, 'inflate helper').toBe(true);
expect(result.hasRunGame, 'window.__runGame definit').toBe(true);
expect(result.noLegacyInjection, 'fara injectie dinamica text/plain').toBe(true);
expect(result.engineBeforeBoot, 'motorul definit inaintea boot-ului').toBe(true);
expect(result.hasTPL, 'var TPL').toBe(true);
expect(result.len).toBeGreaterThan(5000);
expect(errors).toHaveLength(0);
});
test('@share player porneste din hash — campanie 1 camera, final vizibil', async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const { playerHtml, hash } = await page.evaluate(async () => {
const cfg = {
title: 'Test Player', player: '', color: '#6d28d9', style: 'campaign', creator: '',
charName: 'Alex', voice: false, music: false, adventure: false, timerMin: 0,
puzzles: [{ title: 'P1', type: 'free', question: 'Cat fac 1+1?', answer: '2',
tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: {} }],
story: 'Povestea', finalMessage: 'Bravo!'
};
const compressed = await deflateToBase64url(JSON.stringify(cfg));
return { playerHtml: playerHTML(), hash: compressed };
});
const tmpPath = join(ROOT, 'tests', '.tmp-player.html');
writeFileSync(tmpPath, playerHtml);
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
const consoleLogs = [];
gp.on('console', m => consoleLogs.push(m.type() + ': ' + m.text()));
try {
await gp.goto('file://' + tmpPath + '#' + hash);
await gp.waitForFunction(() => !!window.MASTER, { timeout: 10000 }).catch(e => { throw new Error('MASTER not set. Console: ' + consoleLogs.slice(0,5).join(' | ')); });
const title = await gp.evaluate(() => window.MASTER.title);
expect(title, 'MASTER.title').toBe('Test Player');
await gp.locator('#btn-start').click();
/* after start, overworld is shown; navigate to room 0 */
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
await gp.evaluate(() => window.__ow.enterDoor(0));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
const ifl = gp.frameLocator('#room-frame');
await ifl.locator('#btnStart').click();
await ifl.locator('#answers input[type=text]').fill('2');
await ifl.locator('#answers button:text("Verifica")').click();
await gp.waitForTimeout(1200);
await gp.waitForFunction(() => {
const fin = document.getElementById('finale');
return fin && fin.classList.contains('show');
}, { timeout: 10000 });
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors).toHaveLength(0);
});
test('@share player fara hash — afiseaza mesaj niciun joc', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const playerHtml = await page.evaluate(() => playerHTML());
const tmpPath = join(ROOT, 'tests', '.tmp-player-empty.html');
writeFileSync(tmpPath, playerHtml);
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
await gp.waitForTimeout(600);
const txt = await gp.locator('#intro-title').textContent({ timeout: 3000 }).catch(() => '');
expect(txt, 'mesaj fara hash').toContain('Niciun joc');
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors).toHaveLength(0);
});
test('@share share UI: butoane share disabled fara CompressionStream', async ({ page }) => {
await page.addInitScript(() => {
delete window.CompressionStream;
delete window.DecompressionStream;
});
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const shareDisabled = await page.locator('#btnShare').isDisabled();
const copyDisabled = await page.locator('#btnCopyLink').isDisabled();
expect(shareDisabled, 'btnShare disabled').toBe(true);
expect(copyDisabled, 'btnCopyLink disabled').toBe(true);
const dlEnabled = await page.locator('#btnDownloadPlayer').isEnabled();
expect(dlEnabled, 'btnDownloadPlayer enabled').toBe(true);
expect(errors).toHaveLength(0);
});
}); });