Compare commits

..

11 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
9 changed files with 2219 additions and 75 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ă: 31/31 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: 17 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: 17
- **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: 17
- **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

@@ -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`.

View File

@@ -215,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).
--- ---

View File

@@ -83,6 +83,32 @@
#addPuzzle { width: 100%; padding: 10px; border-style: dashed; color: var(--muted); } #addPuzzle { width: 100%; padding: 10px; border-style: dashed; color: var(--muted); }
#addPuzzle:hover { color: var(--accent); } #addPuzzle:hover { color: var(--accent); }
input[type=color] { border: 1px solid var(--line); border-radius: 7px; height: 34px; width: 100%; padding: 2px; background: #fff; } input[type=color] { border: 1px solid var(--line); border-radius: 7px; height: 34px; width: 100%; padding: 2px; background: #fff; }
/* Share section */
.share-url { width: 100%; font: 12px/1.4 ui-monospace,monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px; background: #f9fafb; color: var(--ink); resize: none; }
.share-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.share-btns button { flex: 1; min-width: 110px; font-size: 12px; padding: 6px 10px; }
#qrBox svg { display: block; margin: 10px auto 0; max-width: 180px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
/* QR Card print */
#qrCard {
display: none; position: fixed; inset: 0; z-index: 9999;
background: #fff; color: #111;
font-family: system-ui, sans-serif;
flex-direction: column; align-items: center; justify-content: center;
padding: 30mm 20mm; gap: 12px; text-align: center;
}
#qrCard.show { display: flex; }
#qrCard .qr-title { font-size: 22px; font-weight: 900; margin: 0; }
#qrCard .qr-instr { font-size: 13px; color: #555; max-width: 70mm; line-height: 1.5; }
#qrCard .qr-url { font-family: ui-monospace,monospace; font-size: 9px; color: #555; word-break: break-all; max-width: 100mm; margin-top: 4px; }
#qrCard .qr-for { font-size: 14px; font-weight: 700; }
#qrCard svg { width: 60mm; height: 60mm; }
#qrCard .qr-back { margin-top: 16px; font-size: 12px; }
@media print {
body * { visibility: hidden; }
#qrCard, #qrCard * { visibility: visible; }
#qrCard { position: fixed; inset: 0; display: flex !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
#qrCard .qr-back { display: none; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -138,6 +164,7 @@
<textarea id="gFinal" data-g="finalMessage" rows="2"></textarea> <textarea id="gFinal" data-g="finalMessage" rows="2"></textarea>
<label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala &mdash; citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label> <label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala &mdash; citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label>
<label class="ck"><input type="checkbox" id="gMusic" data-gb="music"> Muzica de fundal &mdash; arpegiu calm care accelereaza sub 1 minut (doar in Campanie; buton de oprire in bara)</label> <label class="ck"><input type="checkbox" id="gMusic" data-gb="music"> Muzica de fundal &mdash; arpegiu calm care accelereaza sub 1 minut (doar in Campanie; buton de oprire in bara)</label>
<label class="ck"><input type="checkbox" id="gAdventure" data-gb="adventure"> Mod aventura &mdash; raspunsul decide urmatoarea camera (ramificare); configureaza per-puzzle mai jos (doar in Campanie)</label>
<label>Timp limita (minute, 0 = fara) &mdash; ceas calm in bara, doar in Campanie</label> <label>Timp limita (minute, 0 = fara) &mdash; ceas calm in bara, doar in Campanie</label>
<input type="number" id="gTimer" data-g="timerMin" min="0" max="120" step="1" placeholder="0"> <input type="number" id="gTimer" data-g="timerMin" min="0" max="120" step="1" placeholder="0">
<div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div> <div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div>
@@ -151,6 +178,21 @@
<div id="puzzleList"></div> <div id="puzzleList"></div>
<button id="addPuzzle">+ Adauga puzzle</button> <button id="addPuzzle">+ Adauga puzzle</button>
</fieldset> </fieldset>
<fieldset id="fsShare">
<legend>Distribuie (link + QR)</legend>
<label>URL baza player (GitHub Pages)</label>
<input type="text" id="gBaseUrl" data-g="baseUrl" placeholder="https://USERNAME.github.io/escape-builder/play.html">
<div class="help" style="margin-bottom:8px">Inlocuieste USERNAME cu contul tau GitHub. <a href="https://pages.github.com/" target="_blank" rel="noopener">Cum activezi GitHub Pages &rarr;</a></div>
<div class="share-btns">
<button id="btnShare">Genereaza QR / link</button>
<button id="btnCopyLink">Copiaza link</button>
<button id="btnDownloadPlayer">Descarca player.html</button>
<button id="btnPrintQr">Printeaza cardul QR</button>
</div>
<div id="qrBox"></div>
<textarea id="shareUrl" class="share-url" rows="2" readonly placeholder="Link-ul va aparea aici dupa generare..."></textarea>
</fieldset>
</section> </section>
<section id="previewPane"> <section id="previewPane">
@@ -163,6 +205,16 @@
</section> </section>
</main> </main>
<!-- Card QR print (vizibil doar la print sau la preview) -->
<div id="qrCard" role="document" aria-label="Card QR de distribuit">
<p class="qr-title" id="qrCardTitle"></p>
<div id="qrCardSvg"></div>
<p class="qr-instr">Scaneaza codul QR cu telefonul<br>pentru a juca escape room-ul!</p>
<p class="qr-url" id="qrCardUrl"></p>
<p class="qr-for" id="qrCardFor"></p>
<button class="qr-back" id="btnQrBack">Inchide previzualizarea</button>
</div>
<script> <script>
'use strict'; 'use strict';
@@ -184,7 +236,9 @@ const defaultState = () => ({
charName: 'Alex', charName: 'Alex',
voice: false, voice: false,
music: false, music: false,
adventure: false,
timerMin: 0, timerMin: 0,
baseUrl: 'https://romfast.github.io/escape-builder/play.html',
story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.', story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.',
finalMessage: 'Felicitari! Ai gasit comoara!', finalMessage: 'Felicitari! Ai gasit comoara!',
puzzles: [ puzzles: [
@@ -207,6 +261,7 @@ function normalizePuzzle(p) {
if (typeof p.letter !== 'string') p.letter = ''; if (typeof p.letter !== 'string') p.letter = '';
if (!validStyles.includes(p.style || '')) p.style = ''; if (!validStyles.includes(p.style || '')) p.style = '';
if (typeof p.style === 'undefined') p.style = ''; if (typeof p.style === 'undefined') p.style = '';
if (typeof p.branch !== 'object' || Array.isArray(p.branch) || p.branch === null) p.branch = {};
return p; return p;
} }
@@ -239,6 +294,23 @@ function renderWord() {
$('#finalWord').textContent = word || ' '; $('#finalWord').textContent = word || ' ';
} }
function choiceOptsBuilder(p) {
return (p.choices || '').split('\n').map(l => l.trim()).filter(Boolean)
.map(o => o.charAt(0) === '*' ? o.slice(1).trim() : o);
}
function branchTargetSelect(p, pi, key, n) {
const branch = p.branch || {};
const cur = branch[key] !== undefined ? String(branch[key]) : '';
let opts = `<option value="" ${cur === '' ? 'selected' : ''}>Liniar (urmatoarea)</option>`;
for (let k = 0; k < n; k++) {
if (k === pi) continue;
opts += `<option value="${k}" ${cur === String(k) ? 'selected' : ''}>Camera ${k + 1}</option>`;
}
opts += `<option value="end" ${cur === 'end' ? 'selected' : ''}>Sfarsit</option>`;
return `<select data-fb data-bkey="${esc(key)}">${opts}</select>`;
}
function puzzleCard(p, i) { function puzzleCard(p, i) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'puzzle' + (p._closed ? ' closed' : ''); div.className = 'puzzle' + (p._closed ? ' closed' : '');
@@ -304,6 +376,18 @@ function puzzleCard(p, i) {
<input type="text" data-f="letter" maxlength="1" value="${esc(p.letter)}"> <input type="text" data-f="letter" maxlength="1" value="${esc(p.letter)}">
</div> </div>
</div> </div>
${state.style === 'campaign' && state.adventure ? `
<div style="margin-top:10px;padding:10px 0 0;border-top:1px solid rgba(255,255,255,.1)">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#a78bfa;font-weight:700;margin-bottom:6px">Ramificare (Adventure Mode)</div>
${p.type === 'free' ? `
<div class="row"><div><label>Daca raspunde corect &rarr; mergi la</label>${branchTargetSelect(p, i, '*', state.puzzles.length)}</div></div>` : ''}
${p.type === 'tf' ? `
<div class="row"><div><label>Daca <strong>Adevarat</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, 'Adevarat', state.puzzles.length)}</div></div>
<div class="row"><div><label>Daca <strong>Fals</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, 'Fals', state.puzzles.length)}</div></div>` : ''}
${p.type === 'choice' ? choiceOptsBuilder(p).map((opt, ci) => `
<div class="row"><div><label>Daca <strong>${esc(opt)}</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, String(ci), state.puzzles.length)}</div></div>`).join('') : ''}
<div class="help" style="color:#fbbf24">&#9888; Reordonarea sau stergerea puzzle-urilor poate invalida ramificarile &mdash; verifica-le dupa!</div>
</div>` : ''}
</div>`; </div>`;
return div; return div;
} }
@@ -328,10 +412,23 @@ document.querySelectorAll('[data-g]').forEach(el => {
}); });
document.querySelectorAll('[data-gb]').forEach(el => { document.querySelectorAll('[data-gb]').forEach(el => {
el.addEventListener('change', () => { state[el.dataset.gb] = el.checked; onChange(); }); el.addEventListener('change', () => {
state[el.dataset.gb] = el.checked;
if (el.dataset.gb === 'adventure') renderPuzzles(); /* re-render: branch dropdowns apar/dispar */
onChange();
});
}); });
puzzleList.addEventListener('input', e => { puzzleList.addEventListener('input', e => {
/* branch key selects (adventure mode) */
if (e.target.hasAttribute('data-fb')) {
const bkey = e.target.dataset.bkey;
const i = +e.target.closest('.puzzle').dataset.i;
if (!state.puzzles[i].branch) state.puzzles[i].branch = {};
state.puzzles[i].branch[bkey] = e.target.value;
onChange();
return;
}
const f = e.target.dataset.f; const f = e.target.dataset.f;
if (!f) return; if (!f) return;
const i = +e.target.closest('.puzzle').dataset.i; const i = +e.target.closest('.puzzle').dataset.i;
@@ -341,6 +438,7 @@ puzzleList.addEventListener('input', e => {
card.querySelector('.head .t').textContent = state.puzzles[i].title || state.puzzles[i].question || 'Puzzle fara titlu'; card.querySelector('.head .t').textContent = state.puzzles[i].title || state.puzzles[i].question || 'Puzzle fara titlu';
} }
if (f === 'type') { state.puzzles[i]._closed = false; renderPuzzles(); } if (f === 'type') { state.puzzles[i]._closed = false; renderPuzzles(); }
if (f === 'choices') renderPuzzles(); /* re-render: branch dropdowns per opțiune */
onChange(); onChange();
}); });
@@ -416,11 +514,27 @@ $('#btnReload').addEventListener('click', refreshPreview);
function cleanState() { function cleanState() {
const s = JSON.parse(JSON.stringify(state)); const s = JSON.parse(JSON.stringify(state));
delete s.baseUrl; /* metadata de share — nu intră în payload/QR/JSON exportat */
s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */ s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */
s.puzzles.forEach(p => { const nP = s.puzzles.length;
s.puzzles.forEach((p, pi) => {
delete p._closed; delete p._closed;
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */ /* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
p.letter = (p.letter || '').replace(/[^A-Za-z0-9]/g, '').charAt(0); p.letter = (p.letter || '').replace(/[^A-Za-z0-9]/g, '').charAt(0);
if (!s.adventure) {
delete p.branch; /* strip branch când adventure e off */
} else {
/* clamp ținte out-of-range → '' (liniar) */
const br = p.branch || {};
Object.keys(br).forEach(k => {
const v = br[k];
if (v !== '' && v !== 'end') {
const n = +v;
if (isNaN(n) || n < 0 || n >= nP) br[k] = '';
}
});
p.branch = br;
}
}); });
return s; return s;
} }
@@ -438,6 +552,355 @@ function download(name, content, mime) {
setTimeout(() => URL.revokeObjectURL(a.href), 5000); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
} }
/* compresie URL (Iteratia 3) */
const CS_OK = typeof CompressionStream !== 'undefined' && typeof DecompressionStream !== 'undefined';
function bytesToB64url(bytes) {
const chunks = [];
for (let i = 0; i < bytes.length; i += 8192) {
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 8192)));
}
return btoa(chunks.join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function b64urlToBytes(str) {
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
async function deflateToBase64url(str) {
const bytes = new TextEncoder().encode(str);
const cs = new CompressionStream('deflate-raw');
const writer = cs.writable.getWriter();
writer.write(bytes);
writer.close();
const buf = await new Response(cs.readable).arrayBuffer();
return bytesToB64url(new Uint8Array(buf));
}
async function inflateFromBase64url(str) {
const bytes = b64urlToBytes(str);
const ds = new DecompressionStream('deflate-raw');
const writer = ds.writable.getWriter();
writer.write(bytes);
writer.close();
const buf = await new Response(ds.readable).arrayBuffer();
return new TextDecoder().decode(buf);
}
/* QR encoder (Iteratia 3, doar builder) — byte mode, ECC L, versiuni 1-22, 8 masti + penalti */
(function(){
/* GF(256) cu polinom primitiv 0x11d */
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
(function(){
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x; LOG[x] = i; x = x < 128 ? x << 1 : (x << 1) ^ 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
function gmul(a, b) { return a && b ? EXP[LOG[a] + LOG[b]] : 0; }
/* Reed-Solomon: generator poly de grad n */
function rsGenerator(n) {
let g = [1];
for (let i = 0; i < n; i++) {
const ng = new Array(g.length + 1).fill(0);
for (let j = 0; j < g.length; j++) { ng[j] ^= g[j]; ng[j+1] ^= gmul(g[j], EXP[i]); }
g = ng;
}
return g;
}
function rsRemainder(data, gen) {
const rem = new Array(gen.length - 1).fill(0);
for (const byte of data) {
const factor = byte ^ rem.shift(); rem.push(0);
for (let j = 0; j < rem.length; j++) rem[j] ^= gmul(gen[j+1], factor);
}
return rem;
}
/* Tabele versiune 1-22, ECC L
[dataCodewords, ecCodewordsPerBlock, group1Blocks, group1Words, group2Blocks, group2Words] */
const VER_TABLE = [
null, /* v0 placeholder */
[19,7,1,19,0,0],[34,10,1,34,0,0],[55,15,1,55,0,0],[80,20,2,40,0,0],
[108,26,2,54,0,0],[136,18,2,44,0,0],[156,20,4,32,0,0],[194,24,4,40,0,0],
[232,30,4,36,2,36],[274,18,6,36,2,32],[324,20,6,36,4,32],[370,24,6,36,4,36],
[428,26,8,37,1,37],[461,30,8,40,1,38],[523,22,10,40,2,38],[589,24,12,40,2,37],
[647,28,16,37,0,0],[721,30,12,38,6,37],[795,28,17,35,1,35],[861,28,19,35,2,34],
[932,28,16,38,6,38],[1006,30,17,36,6,36],
];
/* Centru alignment patterns per versiune */
const ALIGN_POS = [
[],[],[6,18],[6,22],[6,26],[6,30],[6,34],
[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],
[6,26,46,66],[6,26,48,70],[6,30,50,70],[6,30,54,74],[6,30,56,74],
[6,34,56,76],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,74,102],
];
/* BCH pentru format info (generator 0x537, 10 bit) */
function bchFormat(data) {
let d = data << 10;
for (let i = 4; i >= 0; i--) if (d & (1 << (i+10))) d ^= (0x537 << i);
return d;
}
/* BCH pentru versiune info (generator 0x1f25, 12 bit, v>=7) */
function bchVersion(ver) {
let d = ver << 12;
for (let i = 5; i >= 0; i--) if (d & (1 << (i+12))) d ^= (0x1f25 << i);
return d;
}
/* Construieste matricea QR */
function buildMatrix(ver) {
const size = ver * 4 + 17;
const mat = Array.from({length: size}, () => new Int8Array(size).fill(-1));
const res = Array.from({length: size}, () => new Uint8Array(size)); /* 1=rezervat */
function setFinder(r, c) {
for (let dr = -1; dr <= 7; dr++) for (let dc = -1; dc <= 7; dc++) {
const rr = r + dr, cc = c + dc;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
res[rr][cc] = 1;
const inFinder = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
mat[rr][cc] = inFinder ? ((dr===0||dr===6||dc===0||dc===6||
(dr>=2&&dr<=4&&dc>=2&&dc<=4)) ? 1 : 0) : 0;
}
}
setFinder(0,0); setFinder(0,size-7); setFinder(size-7,0);
/* Timing */
for (let i = 8; i < size-8; i++) {
mat[6][i] = mat[i][6] = (i & 1) ? 0 : 1;
res[6][i] = res[i][6] = 1;
}
/* Dark module */
mat[4*ver+9][8] = 1; res[4*ver+9][8] = 1;
/* Alignment patterns */
const apos = ALIGN_POS[ver] || [];
for (const ar of apos) for (const ac of apos) {
if ((ar===6&&ac===6)||(ar===6&&ac===apos[apos.length-1])||(ar===apos[apos.length-1]&&ac===6)) continue;
for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) {
res[ar+dr][ac+dc] = 1;
mat[ar+dr][ac+dc] = (Math.abs(dr)===2||Math.abs(dc)===2||(dr===0&&dc===0)) ? 1 : 0;
}
}
/* Rezervare format + versiune */
const fmtCells = [[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],[8,7],[8,8],
[7,8],[5,8],[4,8],[3,8],[2,8],[1,8],[0,8],
[size-1,8],[size-2,8],[size-3,8],[size-4,8],[size-5,8],[size-6,8],[size-7,8],[size-8,8],
[8,size-8],[8,size-7],[8,size-6],[8,size-5],[8,size-4],[8,size-3],[8,size-2],[8,size-1]];
for (const [r,c] of fmtCells) { res[r][c] = 1; mat[r][c] = 0; }
if (ver >= 7) {
for (let i=0;i<6;i++) for (let j=0;j<3;j++) {
res[i][size-11+j]=1; mat[i][size-11+j]=0;
res[size-11+j][i]=1; mat[size-11+j][i]=0;
}
}
return { mat, res, size };
}
function encodeData(text, ver) {
const vtbl = VER_TABLE[ver];
const [, ecPB, g1b, g1w, g2b, g2w] = vtbl;
const totalData = vtbl[0];
const bytes = new TextEncoder().encode(text);
/* Bit buffer */
const bits = [];
const pushBits = (v, n) => { for (let i=n-1;i>=0;i--) bits.push((v>>i)&1); };
pushBits(0b0100, 4); /* mode: byte */
pushBits(bytes.length, 8); /* char count (v1-9: 8 bits) — v10-26: 16 bits */
/* For v10+: char count is 16 bits in byte mode. Versions we use (1-22) span both ranges.
Versions 1-9: 8-bit count; 10-26: 16-bit count. */
/* Recalculate: already pushed 8 bits for v<=9; for v>=10 we need 16-bit. Fix: */
bits.length = 0;
pushBits(0b0100, 4);
if (ver <= 9) pushBits(bytes.length, 8); else pushBits(bytes.length, 16);
for (const b of bytes) pushBits(b, 8);
/* Terminator + pad to byte boundary */
for (let i=0;i<4&&bits.length<totalData*8;i++) bits.push(0);
while (bits.length % 8) bits.push(0);
/* Pad codewords */
const pads = [0xEC, 0x11]; let pi = 0;
while (bits.length < totalData * 8) { pushBits(pads[pi++ % 2], 8); }
/* Convert to bytes */
const data = [];
for (let i=0;i<bits.length;i+=8) data.push(bits.slice(i,i+8).reduce((a,b)=>a*2+b,0));
/* Interleave blocks */
const blocks = [];
let off = 0;
for (let i=0;i<g1b+g2b;i++) {
const w = (i<g1b) ? g1w : g2w;
blocks.push(data.slice(off, off+w)); off += w;
}
const gen = rsGenerator(ecPB);
const ecBlocks = blocks.map(b => rsRemainder(b, gen));
const out = [];
const maxLen = Math.max(...blocks.map(b=>b.length));
for (let i=0;i<maxLen;i++) for (const b of blocks) if (i<b.length) out.push(b[i]);
for (let i=0;i<ecPB;i++) for (const eb of ecBlocks) out.push(eb[i]);
return out;
}
function placeData(mat, res, data, size) {
const bits = [];
for (const b of data) for (let i=7;i>=0;i--) bits.push((b>>i)&1);
let bi = 0, up = true;
for (let col = size-1; col >= 0; col -= 2) {
if (col === 6) col--;
for (let ri = 0; ri < size; ri++) {
const r = up ? size-1-ri : ri;
for (let dc = 0; dc <= 1; dc++) {
const c = col - dc;
if (!res[r][c] && bi < bits.length) { mat[r][c] = bits[bi++]; }
}
}
up = !up;
}
}
const MASK_FNS = [
(r,c)=>(r+c)%2===0,(r,c)=>r%2===0,(r,c)=>c%3===0,(r,c)=>(r+c)%3===0,
(r,c)=>((Math.floor(r/2)+Math.floor(c/3))%2===0),(r,c)=>(r*c)%2+(r*c)%3===0,
(r,c)=>((r*c)%2+(r*c)%3)%2===0,(r,c)=>((r+c)%2+(r*c)%3)%2===0
];
function applyMask(mat, res, size, mi) {
const fn = MASK_FNS[mi];
const m = mat.map(r=>r.slice());
for (let r=0;r<size;r++) for (let c=0;c<size;c++) if (!res[r][c]) m[r][c] ^= fn(r,c)?1:0;
return m;
}
function writeFormat(m, size, maskIdx) {
/* ECC L = 01 */
const raw = (0b01 << 3) | maskIdx;
const fmtBits = ((raw << 10) | bchFormat(raw)) ^ 0x5412;
const seq = [[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],[8,7],[8,8],
[7,8],[5,8],[4,8],[3,8],[2,8],[1,8],[0,8]];
const seqR = [[size-1,8],[size-2,8],[size-3,8],[size-4,8],[size-5,8],[size-6,8],[size-7,8],
[8,size-8],[8,size-7],[8,size-6],[8,size-5],[8,size-4],[8,size-3],[8,size-2],[8,size-1]];
for (let i=0;i<15;i++) {
const bit = (fmtBits >> (14-i)) & 1;
m[seq[i][0]][seq[i][1]] = bit;
m[seqR[i][0]][seqR[i][1]] = bit;
}
}
function writeVersion(m, size, ver) {
if (ver < 7) return;
const raw = ver;
const verBits = (raw << 12) | bchVersion(raw);
for (let i=0;i<18;i++) {
const bit = (verBits >> i) & 1;
const r = Math.floor(i/3), c = size-11+i%3;
m[r][c] = bit; m[c][r] = bit;
}
}
function penaltyScore(m, size) {
let score = 0;
/* Rule 1: 5+ in a row */
for (let r=0;r<size;r++) {
for (let isCol=0;isCol<2;isCol++) {
let run=1, prev=isCol?m[0][r]:m[r][0];
for (let i=1;i<size;i++) {
const v = isCol?m[i][r]:m[r][i];
if (v===prev) { run++; if(run===5) score+=3; else if(run>5) score++; }
else { run=1; prev=v; }
}
}
}
/* Rule 2: 2x2 blocks */
for (let r=0;r<size-1;r++) for (let c=0;c<size-1;c++) {
const v=m[r][c]; if(v===m[r+1][c]&&v===m[r][c+1]&&v===m[r+1][c+1]) score+=3;
}
/* Rule 3: finder-like patterns */
const p1=[1,0,1,1,1,0,1,0,0,0,0], p2=[0,0,0,0,1,0,1,1,1,0,1];
for (let r=0;r<size;r++) for (let c=0;c<=size-11;c++) {
let h1=true,h2=true,v1=true,v2=true;
for (let k=0;k<11;k++) {
if(m[r][c+k]!==p1[k]) h1=false; if(m[r][c+k]!==p2[k]) h2=false;
if(m[c+k][r]!==p1[k]) v1=false; if(m[c+k][r]!==p2[k]) v2=false;
}
if(h1||h2) score+=40; if(v1||v2) score+=40;
}
/* Rule 4: dark module ratio */
let dark=0;
for (let r=0;r<size;r++) for (let c=0;c<size;c++) if(m[r][c]) dark++;
const ratio = Math.abs(dark*100/(size*size)-50);
score += Math.floor(ratio/5)*10;
return score;
}
function makeQrSvg(text, opts={}) {
opts = Object.assign({ quietZone: 4 }, opts);
const bytes = new TextEncoder().encode(text);
const n = bytes.length;
/* Trova minimum version ECC L */
let ver = 0;
for (let v=1; v<=22; v++) {
if (VER_TABLE[v] && VER_TABLE[v][0] >= n + (v<=9?3:4)) { ver=v; break; }
}
if (!ver) return null; /* text too long */
const data = encodeData(text, ver);
const { mat, res, size } = buildMatrix(ver);
placeData(mat, res, data, size);
/* Alege masca cu penalti minim */
let bestMask = 0, bestScore = Infinity;
for (let mi=0;mi<8;mi++) {
const m2 = applyMask(mat, res, size, mi);
writeFormat(m2, size, mi);
writeVersion(m2, size, ver);
const sc = penaltyScore(m2, size);
if (sc < bestScore) { bestScore=sc; bestMask=mi; }
}
const finalMat = applyMask(mat, res, size, bestMask);
writeFormat(finalMat, size, bestMask);
writeVersion(finalMat, size, ver);
/* Genereaza SVG cu un singur <path> (run-uri per rand) */
const qz = opts.quietZone;
const total = size + 2*qz;
let d = '';
for (let r=0;r<size;r++) {
let run=0, startC=-1;
for (let c=0;c<=size;c++) {
const on = c<size && finalMat[r][c]===1;
if (on && run===0) { startC=c; run=1; }
else if (on) run++;
else if (run>0) {
d += `M${startC+qz},${r+qz}h${run}v1h-${run}z`;
run=0;
}
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" shape-rendering="crispEdges"><rect width="${total}" height="${total}" fill="#fff"/><path fill="#000" d="${d}"/></svg>`;
}
window.makeQrSvg = makeQrSvg;
})();
/* ---------- preview ---------- */ /* ---------- preview ---------- */
let timer = null; let timer = null;
@@ -722,13 +1185,14 @@ function starsFor(att, hint){ return (hint || att >= 2) ? 1 : (att === 1 ? 2 : 3
function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += L.toUpperCase(); } return w; } function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += L.toUpperCase(); } return w; }
function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); } function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; } function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); } var _lastGiven = '';
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); var ok = norm(given) !== '' && norm(given) === norm(exp); if(ok){ _lastGiven = given; } return ok; }
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} } function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } } function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } } function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
/* Contract de finalizare a camerei — un singur loc pentru payload-ul parent.nextRoom /* Contract de finalizare a camerei — un singur loc pentru payload-ul parent.nextRoom
(înlocuiește duplicatele din showFinal/finale; D7). Citește totalStars + finalWord() la apel. */ (înlocuiește duplicatele din showFinal/finale; D7). Citește totalStars + finalWord() la apel. */
function campaignDone(){ if(CFG._campaign){ try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:finalWord().charAt(0)}); }catch(e){} } } function campaignDone(){ if(CFG._campaign){ try{ var p = CFG.puzzles[0]; var bkey = '*'; if(p.type === 'tf'){ bkey = _lastGiven || 'Adevarat'; } else if(p.type === 'choice'){ var opts = choiceOpts(p); var bi = opts.findIndex(function(o){ return norm(o) === norm(_lastGiven); }); bkey = String(bi >= 0 ? bi : 0); } parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:finalWord().charAt(0), branch:bkey}); }catch(e){} } }
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } }; window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
if(CFG._campaign){ if(CFG._campaign){
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */ /* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
@@ -859,6 +1323,26 @@ SNIP.hudJs = `function hudLetters(isSolved){
} }
}`; }`;
/* helperi inflate pt. player (player-side, backslash dublu pentru regex) */
SNIP.compressJs = `function bytesToB64url(bytes){
var chunks=[];
for(var i=0;i<bytes.length;i+=8192)chunks.push(String.fromCharCode.apply(null,bytes.subarray(i,i+8192)));
return btoa(chunks.join('')).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'');
}
function b64urlToBytes(str){
var b64=str.replace(/-/g,'+').replace(/_/g,'/');
var bin=atob(b64);var bytes=new Uint8Array(bin.length);
for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return bytes;
}
async function inflateFromBase64url(str){
var bytes=b64urlToBytes(str);
var ds=new DecompressionStream('deflate-raw');
var writer=ds.writable.getWriter();writer.write(bytes);writer.close();
var buf=await new Response(ds.readable).arrayBuffer();
return new TextDecoder().decode(buf);
}`;
/* ---------- motor: terminal retro ---------- */ /* ---------- motor: terminal retro ---------- */
function gameTerminal(cfg) { function gameTerminal(cfg) {
@@ -1583,28 +2067,49 @@ roomReady();
* json = JSON.stringify(cfg).replace(/</g,'\\u003c') — D6. * json = JSON.stringify(cfg).replace(/</g,'\\u003c') — D6.
*/ */
function gameCampaign(cfg) { function campaignShell({ tplJson, masterExpr, titleExpr, nStyles, bootMode }) {
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point']; /* bootMode 'hash': motorul se DEFINEȘTE întâi (window.__runGame), boot-ul îl apelează după.
* Ordinea garantează că __runGame există când boot-ul rulează — fără injecție dinamică și fără
* race de parsare (await-ul din inflate se poate rezolva pe microtask înainte ca un <script>
* ulterior să fie parsat; în Brave asta dădea „motor lipsă"). */
const _scriptOpen = bootMode === 'hash'
? `<script>
window.__runGame=function(){
var MASTER=window.MASTER;`
: `<script>
var TPL = ${tplJson};
var MASTER = ${masterExpr};`;
/* Template-uri per stil: fiecare motor generat O dată cu sentinel __CFG__ */ /* Închiderea: pentru 'hash' închide funcția __runGame, apoi emite scriptul de boot
const stylesNeeded = new Set(cfg.puzzles.map((p, i) => p.style || ROTATION[i % ROTATION.length])); * (inflate hash → window.MASTER → window.__runGame()). Pentru 'inline' doar închide scriptul. */
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint }; const _scriptClose = bootMode === 'hash'
const templates = {}; ? `};
for (const style of stylesNeeded) { <\/script>
templates[style] = (engines[style] || gameClassic)('__TEMPLATE__'); <script>
} ${SNIP.compressJs}
var TPL = ${tplJson};
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c'); (async function(){
const masterJson = JSON.stringify(cfg).replace(/</g, '\\u003c'); var h=location.hash.slice(1);
const nRooms = cfg.puzzles.length; if(!h){document.getElementById('intro-title').textContent='Niciun joc în acest link.';return;}
const nStyles = stylesNeeded.size; try { window.MASTER=JSON.parse(await inflateFromBase64url(h)); }
catch(e){ document.getElementById('intro-title').textContent='Link invalid sau corupt. Regenerează QR-ul din builder.'; return; }
if(typeof window.__runGame!=='function'){document.getElementById('intro-title').textContent='Eroare internă: motor lipsă.';return;}
try { window.__runGame(); }
catch(e){ document.getElementById('intro-title').textContent='Eroare joc: '+e.message; }
})();
<\/script>
</body>
</html>`
: `<\/script>
</body>
</html>`;
return `<!doctype html> return `<!doctype html>
<html lang="ro"> <html lang="ro">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(cfg.title)}</title> <title>${titleExpr}</title>
<style> <style>
/* /*
* ASCII DIAGRAM — contractul parent.*: * ASCII DIAGRAM — contractul parent.*:
@@ -1680,6 +2185,7 @@ body {
.ow-door { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; color: #fff; border-radius: 7px; background: #e11d48; box-shadow: 0 2px 8px rgba(0,0,0,.5); } .ow-door { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; color: #fff; border-radius: 7px; background: #e11d48; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
.ow-door.solved { background: var(--c-gold); color: #3a2606; } .ow-door.solved { background: var(--c-gold); color: #3a2606; }
.ow-door.target { box-shadow: 0 0 0 3px #a78bfa, 0 2px 10px rgba(167,139,250,.6); } .ow-door.target { box-shadow: 0 0 0 3px #a78bfa, 0 2px 10px rgba(167,139,250,.6); }
.ow-door.locked { background: #374151; filter: grayscale(1) brightness(.65); opacity: .7; cursor: not-allowed; }
.ow-exit { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; border-radius: 7px; background: #3b2a63; filter: grayscale(1) brightness(.7); } .ow-exit { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; border-radius: 7px; background: #3b2a63; filter: grayscale(1) brightness(.7); }
.ow-exit.open { background: #166534; filter: none; box-shadow: 0 0 14px #22c55e; } .ow-exit.open { background: #166534; filter: none; box-shadow: 0 0 14px #22c55e; }
.ow-player { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; transition: left .1s linear, top .1s linear; z-index: 3; } .ow-player { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; transition: left .1s linear, top .1s linear; z-index: 3; }
@@ -1860,7 +2366,7 @@ body {
<body> <body>
<div id="chrome"> <div id="chrome">
<span id="chrome-title">${esc(cfg.title)}</span> <span id="chrome-title">${titleExpr}</span>
<div class="sp"></div> <div class="sp"></div>
<span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span> <span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span>
<button id="btn-music" type="button" aria-label="Muzica de fundal" hidden>&#127925;</button> <button id="btn-music" type="button" aria-label="Muzica de fundal" hidden>&#127925;</button>
@@ -1932,7 +2438,7 @@ body {
</div> </div>
</div> </div>
<script> ${_scriptOpen}
/* /*
* ASCII DIAGRAM — contractul parent.*: * ASCII DIAGRAM — contractul parent.*:
* ┌──────────── orchestrator (window) ─────────────────────────┐ * ┌──────────── orchestrator (window) ─────────────────────────┐
@@ -1945,8 +2451,6 @@ body {
* roomDone[idx]=true după primul nextRoom → duplicatele ignorate. * roomDone[idx]=true după primul nextRoom → duplicatele ignorate.
* Timeout 4s → skipRoom → aceeași compoziție de coridor (D5). * Timeout 4s → skipRoom → aceeași compoziție de coridor (D5).
*/ */
var TPL = ${tplJson};
var MASTER = ${masterJson};
var ROTATION = ['classic','terminal','arcade','chat','point']; var ROTATION = ['classic','terminal','arcade','chat','point'];
var TOKEN = '__CFG__'; var TOKEN = '__CFG__';
@@ -1962,6 +2466,9 @@ var activeIdx = -1;
var activeWindow = null; var activeWindow = null;
var readyTimer = null; var readyTimer = null;
var roomDone = {}; var roomDone = {};
var ADVENTURE = !!MASTER.adventure;
var owUnlocked = {0: true}; /* ușile deblocate în adventure mode */
var owExitUnlocked = false;
/* ----- Resume — safeStore (D3) + djb2 hash (D11) ----- */ /* ----- Resume — safeStore (D3) + djb2 hash (D11) ----- */
function djb2(s){ function djb2(s){
@@ -1973,7 +2480,14 @@ var _RESUME_KEY = 'esc-camp-' + djb2(JSON.stringify(MASTER));
function safeGet(){ try{ return JSON.parse(sessionStorage.getItem(_RESUME_KEY)); }catch(e){ return null; } } function safeGet(){ try{ return JSON.parse(sessionStorage.getItem(_RESUME_KEY)); }catch(e){ return null; } }
function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } } function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } }
function saveProgress(){ function saveProgress(){
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), roomStars: roomStars.slice(), skipped: skipped }); var payload = { idx: activeIdx, totalStars: totalStars, collected: collected.slice(), roomStars: roomStars.slice(), skipped: skipped };
if(ADVENTURE){
payload.doneList = Object.keys(roomDone).map(Number);
payload.owUnlocked = owUnlocked;
payload.owExitUnlocked = owExitUnlocked;
payload.target = owTargetIdx;
}
safeSet(payload);
} }
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} } function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} }
@@ -2173,6 +2687,20 @@ window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — gua
/* ----- parent.* API ----- */ /* ----- parent.* API ----- */
/* Calculează camera țintă după răspuns (adventure mode).
Returnează număr (idx cameră) sau 'end'. Non-adventure → idx+1. */
function resolveBranch(idx, key){
if(!ADVENTURE) return idx + 1;
var p = MASTER.puzzles[idx];
var br = p && p.branch;
var t = br ? (br[key] !== undefined ? br[key] : br['*']) : undefined;
if(t === undefined || t === '') t = idx + 1; /* fallback liniar */
if(t === 'end') return 'end';
t = +t;
if(isNaN(t) || t < 0 || t >= N) return 'end';
return t;
}
window.nextRoom = function(data){ window.nextRoom = function(data){
/* Guard: doar de la camera activă (D5) */ /* Guard: doar de la camera activă (D5) */
if(!activeWindow || frameEl.contentWindow !== activeWindow){ if(!activeWindow || frameEl.contentWindow !== activeWindow){
@@ -2189,10 +2717,24 @@ window.nextRoom = function(data){
var letter = String(data.letter || '').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase(); var letter = String(data.letter || '').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase();
if(letter) collected.push(letter); if(letter) collected.push(letter);
setDot(idx,'done'); setDot(idx,'done');
saveProgress();
console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter); console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter);
var next = idx + 1; if(ADVENTURE){
if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); } var dest = resolveBranch(idx, data.branch || '*');
if(dest === 'end'){
owExitUnlocked = true;
saveProgress();
showOverworld(idx, data); /* overworld cu exit deblocat — player merge la steag */
} else {
owUnlocked[dest] = true;
owTargetIdx = dest;
saveProgress();
showOverworld(dest, data);
}
} else {
saveProgress();
var next = idx + 1;
if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); }
}
}; };
window.roomReady = function(idx){ window.roomReady = function(idx){
@@ -2327,6 +2869,7 @@ function buildDiploma(){
var lab = document.createElement('span'); lab.textContent = 'Camera ' + (i+1); var lab = document.createElement('span'); lab.textContent = 'Camera ' + (i+1);
var val = document.createElement('span'); var val = document.createElement('span');
if(skipped[i]){ val.className = 'rskip'; val.textContent = '\\uD83D\\uDD12 s\\u0103rit\\u0103'; } if(skipped[i]){ val.className = 'rskip'; val.textContent = '\\uD83D\\uDD12 s\\u0103rit\\u0103'; }
else if(ADVENTURE && !roomDone[i]){ val.className = 'rskip'; val.textContent = '\\u2014 neexplorat\\u0103'; }
else { val.className = 'rstars'; val.textContent = _starStr(roomStars[i]||0); } else { val.className = 'rstars'; val.textContent = _starStr(roomStars[i]||0); }
row.appendChild(lab); row.appendChild(val); rooms.appendChild(row); row.appendChild(lab); row.appendChild(val); rooms.appendChild(row);
} }
@@ -2416,15 +2959,18 @@ function owRefreshDoors(){
owDoors.forEach(function(d){ owDoors.forEach(function(d){
var el = document.getElementById('ow-door-' + d.idx); if (!el) return; var el = document.getElementById('ow-door-' + d.idx); if (!el) return;
var done = !!roomDone[d.idx], isSkip = !!skipped[d.idx]; var done = !!roomDone[d.idx], isSkip = !!skipped[d.idx];
el.className = 'ow-door' + (done ? ' solved' : '') + (!done && d.idx === owTargetIdx ? ' target' : ''); var locked = ADVENTURE && !owUnlocked[d.idx] && !done;
el.className = 'ow-door' + (done ? ' solved' : '') + (!done && !locked && d.idx === owTargetIdx ? ' target' : '') + (locked ? ' locked' : '');
if (isSkip) el.textContent = '\\ud83d\\udd12'; if (isSkip) el.textContent = '\\ud83d\\udd12';
else if (done) el.textContent = (MASTER.puzzles[d.idx].letter || '').trim().toUpperCase() || '\\u2713'; else if (done) el.textContent = (MASTER.puzzles[d.idx].letter || '').trim().toUpperCase() || '\\u2713';
else if (locked) el.textContent = '\\ud83d\\udd12';
else el.textContent = (d.idx + 1); else el.textContent = (d.idx + 1);
}); });
var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (owAllDone() ? ' open' : ''); var exitOpen = ADVENTURE ? owExitUnlocked : owAllDone();
document.getElementById('ow-hint').textContent = owAllDone() var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (exitOpen ? ' open' : '');
? 'Toate camerele rezolvate! Mergi la steag \\ud83c\\udfc1 ca să evadezi.' document.getElementById('ow-hint').textContent = exitOpen
: 'Mergi la ușa următoare (săgeți / WASD / butoane).'; ? 'Toate camerele rezolvate! Mergi la steag \\ud83c\\udfc1 ca s\\u0103 evadezi.'
: 'Mergi la u\\u015fa urm\\u0103toare (s\\u0103ge\\u021bi / WASD / butoane).';
} }
function owCenter(){ function owCenter(){
@@ -2448,8 +2994,15 @@ function owMove(dc, dr){
} }
function owCheckEnter(){ function owCheckEnter(){
for (var i = 0; i < owDoors.length; i++){ var d = owDoors[i]; if (owPlayer.col === d.col && owPlayer.row === d.row){ if (!roomDone[d.idx]) owEnterDoor(d.idx); return; } } for (var i = 0; i < owDoors.length; i++){
if (owPlayer.col === owExit.col && owPlayer.row === owExit.row && owAllDone()){ owActive = false; showFinale(); } var d = owDoors[i];
if (owPlayer.col === d.col && owPlayer.row === d.row){
if (!roomDone[d.idx] && (!ADVENTURE || owUnlocked[d.idx])) owEnterDoor(d.idx);
return;
}
}
var canExit = ADVENTURE ? owExitUnlocked : owAllDone();
if (owPlayer.col === owExit.col && owPlayer.row === owExit.row && canExit){ owActive = false; clearProgress(); showFinale(); }
} }
function owEnterDoor(idx){ if (!owActive) return; /* idempotență — a doua intrare ignorată (T4/D4) */ owActive = false; mountRoom(idx); } function owEnterDoor(idx){ if (!owActive) return; /* idempotență — a doua intrare ignorată (T4/D4) */ owActive = false; mountRoom(idx); }
@@ -2482,7 +3035,7 @@ document.querySelectorAll('#ow-dpad button[data-d]').forEach(function(b){
/* Hooks pentru teste (conduc harta fără tastatură) */ /* Hooks pentru teste (conduc harta fără tastatură) */
window.__ow = { window.__ow = {
get state(){ return { player: { col: owPlayer.col, row: owPlayer.row }, target: owTargetIdx, active: owActive, allDone: owAllDone(), doors: owDoors.map(function(d){ return { idx: d.idx, col: d.col, row: d.row, solved: !!roomDone[d.idx] }; }) }; }, get state(){ return { player: { col: owPlayer.col, row: owPlayer.row }, target: owTargetIdx, active: owActive, allDone: owAllDone(), owUnlocked: owUnlocked, owExitUnlocked: owExitUnlocked, doors: owDoors.map(function(d){ return { idx: d.idx, col: d.col, row: d.row, solved: !!roomDone[d.idx] }; }) }; },
enterDoor: function(i){ var d = owDoors[i]; if (d){ owPlayer.col = d.col; owPlayer.row = d.row; owRenderPlayer(); owCheckEnter(); } }, enterDoor: function(i){ var d = owDoors[i]; if (d){ owPlayer.col = d.col; owPlayer.row = d.row; owRenderPlayer(); owCheckEnter(); } },
enterExit: function(){ owPlayer.col = owExit.col; owPlayer.row = owExit.row; owRenderPlayer(); owCheckEnter(); } enterExit: function(){ owPlayer.col = owExit.col; owPlayer.row = owExit.row; owRenderPlayer(); owCheckEnter(); }
}; };
@@ -2491,12 +3044,16 @@ owBuild();
/* ----- Intro ----- */ /* ----- Intro ----- */
document.getElementById('intro-title').textContent = MASTER.title; document.getElementById('intro-title').textContent = MASTER.title;
document.getElementById('chrome-title').textContent = MASTER.title;
document.title = MASTER.title || document.title;
var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
document.getElementById('intro-story').textContent = _introStory; document.getElementById('intro-story').textContent = _introStory;
document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic'; document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic';
document.getElementById('btn-start').onclick = function(){ document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0); clearProgress(); owResetPlayer();
owUnlocked = {0: true}; owExitUnlocked = false; /* reset adventure state la start nou */
showOverworld(0);
startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */ startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */
startMusic(); /* muzica ambient pornește odată cu aventura (T10) */ startMusic(); /* muzica ambient pornește odată cu aventura (T10) */
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */ voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
@@ -2549,23 +3106,120 @@ buildDots();
roomStars = saved.roomStars || []; roomStars = saved.roomStars || [];
skipped = saved.skipped || {}; skipped = saved.skipped || {};
Object.keys(skipped).forEach(function(k){ roomDone[+k] = true; setDot(+k,'done'); }); Object.keys(skipped).forEach(function(k){ roomDone[+k] = true; setDot(+k,'done'); });
/* repornim pe hartă, la ușa camerei next */ if(ADVENTURE){
/* Adventure: restore non-contiguous done list (nu bucla liniară 0..idx) */
var dl = saved.doneList || [saved.idx];
dl.forEach(function(i){ if(i >= 0 && i < N){ roomDone[i] = true; setDot(i,'done'); } });
owUnlocked = saved.owUnlocked || {0: true};
owExitUnlocked = !!saved.owExitUnlocked;
var target = (typeof saved.target === 'number' && saved.target >= 0 && saved.target < N) ? saved.target : 0;
owResetPlayer(); showOverworld(target);
startTimer(); startMusic();
return;
}
/* Non-adventure: bucla liniară (comportament existent) */
var resumeIdx = saved.idx + 1; var resumeIdx = saved.idx + 1;
/* marchează ușile deja rezolvate pe hartă (resume) */
for(var di=0; di<=saved.idx; di++){ roomDone[di] = true; setDot(di,'done'); } for(var di=0; di<=saved.idx; di++){ roomDone[di] = true; setDot(di,'done'); }
if(resumeIdx >= N){ if(resumeIdx >= N){
/* ultima cameră deja terminată — mergi direct la final */
showFinale(); return; showFinale(); return;
} }
owResetPlayer(); showOverworld(resumeIdx); owResetPlayer(); showOverworld(resumeIdx);
startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */ startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
startMusic(); /* resume → reia muzica (T10) */ startMusic(); /* resume → reia muzica (T10) */
})(); })();
<\/script> ${_scriptClose}`;
</body>
</html>`;
} }
function gameCampaign(cfg) {
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
const stylesNeeded = new Set(cfg.puzzles.map((p, i) => p.style || ROTATION[i % ROTATION.length]));
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
const templates = {};
for (const style of stylesNeeded) {
templates[style] = (engines[style] || gameClassic)('__TEMPLATE__');
}
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
const masterJson = JSON.stringify(cfg).replace(/</g, '\\u003c');
const nStyles = stylesNeeded.size;
return campaignShell({ tplJson, masterExpr: masterJson, titleExpr: esc(cfg.title), nStyles, bootMode: 'inline' });
}
function playerHTML() {
/* Player universal: toate 5 template-uri inline, MASTER vine din hash comprimat */
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
const templates = {};
for (const style of ROTATION) {
templates[style] = engines[style]('__TEMPLATE__');
}
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
const nStyles = ROTATION.length; /* toate 5 stiluri disponibile */
return campaignShell({ tplJson, masterExpr: '', titleExpr: 'Escape Room Player', nStyles, bootMode: 'hash' });
}
/* ---------- distribuie (link + QR) ---------- */
$('#btnShare').disabled = !CS_OK;
$('#btnCopyLink').disabled = !CS_OK;
if (!CS_OK) {
const t = 'Browserul nu suportă CompressionStream (necesar pentru compresie URL)';
$('#btnShare').title = t; $('#btnCopyLink').title = t;
}
$('#btnShare').addEventListener('click', async () => {
if (state.puzzles.length === 0) { alert('Adaugă cel puțin un puzzle înainte de a genera QR-ul!'); return; }
const payload = await deflateToBase64url(JSON.stringify(cleanState()));
const baseUrl = (state.baseUrl || '').replace(/#.*$/, '');
const url = baseUrl + '#' + payload;
$('#shareUrl').value = url;
const svg = makeQrSvg ? makeQrSvg(url) : null;
if (!svg) {
$('#qrBox').innerHTML = '<p style="color:var(--danger);font-size:12px">URL prea lung pentru QR (încearcă un domeniu mai scurt sau mai puține puzzle-uri).</p>';
} else {
$('#qrBox').innerHTML = svg;
/* Populate QR card for print */
$('#qrCardTitle').textContent = state.title || 'Escape Room';
$('#qrCardFor').textContent = state.player ? 'Pentru: ' + state.player : '';
$('#qrCardUrl').textContent = url;
$('#qrCardSvg').innerHTML = svg;
}
});
$('#btnCopyLink').addEventListener('click', async () => {
const url = $('#shareUrl').value;
if (!url) { alert('Generează mai întâi link-ul cu butonul "Generează QR / link".'); return; }
try {
await navigator.clipboard.writeText(url);
const btn = $('#btnCopyLink');
const old = btn.textContent;
btn.textContent = 'Copiat!';
setTimeout(() => { btn.textContent = old; }, 1500);
} catch (_) {
/* fallback pentru file:// unde clipboard API e blocat */
const ta = document.createElement('textarea');
ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (_2) { alert('Copiază manual: ' + url); }
document.body.removeChild(ta);
}
});
$('#btnDownloadPlayer').addEventListener('click', () => {
download('player.html', playerHTML(), 'text/html');
});
$('#btnPrintQr').addEventListener('click', () => {
const svg = $('#qrCardSvg').innerHTML;
if (!svg) { alert('Generează mai întâi QR-ul cu butonul "Generează QR / link".'); return; }
$('#qrCard').classList.add('show');
window.print();
$('#qrCard').classList.remove('show');
});
$('#btnQrBack').addEventListener('click', () => {
$('#qrCard').classList.remove('show');
});
/* ---------- start ---------- */ /* ---------- start ---------- */
renderGlobals(); renderGlobals();

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 (~31 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` (16 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML + - **Tag-uri:** `@regresie` (16), `@campanie` (21), `@share` (6 — Iterația 3):
stil top-level invalid la import + bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` - `@share compresie round-trip` — deflate/inflate builder
(17 — intro→hartă→camere→final, resume, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, - `@share QR structural` — makeQrSvg SVG valid
audio S1, voce/narațiune D10, a11y tap/aria/reduced-motion, navigare overworld, timer calm T10, muzica ambient T10, diploma A4). - `@share playerHTML()` — structura HTML player
- **Status țintă: 31/31 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 # 31/31 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

@@ -1450,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);
});
}); });