diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3d6436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +package.json +package-lock.json +playwright.config.mjs +test-results/ +.playwright-mcp/ +.gstack/ +scratch/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ca5435 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Ce este + +Generator de jocuri escape room: un singur fișier HTML (`escape-builder.html`), fără backend, fără build, fără dependențe. Același set de puzzle-uri se exportă în 5 stiluri de joc diferite (clasic/quiz, terminal retro, arcade pixel, story chat, point-and-click). + +## Dezvoltare + +Nu există build sau lint. Produsul (HTML files) e vanilla HTML/CSS/JS, zero-dependențe. + +```bash +# Verificare: deschide direct în browser (merge de pe file://) +# sau servește local: +python3 -m http.server 8000 +``` + +Testarea manuală: deschide `escape-builder.html`, schimbă "Stil joc" și verifică preview-ul live (iframe), apoi exportă și verifică jocul standalone. Testele automate → vezi `## Testing`. + +## Arhitectură + +Toată aplicația trăiește în `escape-builder.html`, organizată în secțiuni comentate (`stare`, `editor`, `preview`, `template-urile jocului exportat`): + +- **Stare**: obiectul `state` (titlu, poveste, culoare, `style`, listă `puzzles`), persistat automat în `localStorage` sub cheia `escape-builder-v1`. Export/import ca JSON. +- **Editor** (stânga): formularele scriu direct în `state` via `data-g`; orice modificare cheamă `onChange()` → persist + refresh preview cu debounce 400ms. +- **Preview** (dreapta): `refreshPreview()` setează `iframe.srcdoc = gameHTML(cleanState())` — preview-ul este exact jocul exportat, jucabil. +- **Generare joc**: `gameHTML(cfg)` face dispatch pe `cfg.style` către cele 5 motoare: `gameClassic`, `gameTerminal`, `gameArcade`, `gameChat`, `gamePoint`. Fiecare motor returnează un string HTML complet, standalone (jocul exportat merge offline). + +Cod partajat între motoare (injectat în HTML-ul generat): +- `libJS(cfg)`: `CFG` (config serializat), `norm` (normalizare răspuns: fără diacritice/majuscule, virgulă→punct), `checkAnswer`, `starsFor` (3/2/1 stele după încercări/indiciu), `finalWord` (literele puzzle-urilor formează cuvântul final), `beep` (WebAudio), `confetti`. +- `SNIP.*`: fragmente de template partajate — `baseCss`, modal de întrebare (`modalCss/Html/Js`, folosit de arcade și point) și ecranul final (`finalCss/Html/Js`). + +Tipuri de puzzle: `free` (răspuns liber), `tf` (adevărat/fals), `choice` (variante pe linii separate în `choices`, cea corectă prefixată cu `*`). + +## Testing + +Harness Playwright în `tests/smoke.mjs`. Instalare o singură dată: + +```bash +npm install # instalează @playwright/test (devDependency) +npx playwright install chromium +``` + +Rulare: + +```bash +npm run test:regresie # regresie — exemplu-*.html rezolvate până la final + edge cases +npm run test:campanie # campanie E2E — rulează după ce integrator anunță gata +npm test # suita completă (regresie + campanie) + +# sau direct: +npx playwright test tests/smoke.mjs --grep "@regresie" +npx playwright test tests/smoke.mjs +``` + +**Baseline curent (pre-campanie):** 13/13 `@regresie` trec. Testele `@campanie` sunt marcate `skip` — se activează după implementarea `gameCampaign`. + +## Atenție la editare + +- Motoarele de joc sunt template literals mari — backslash-urile din codul generat trebuie dublate (`\\u0300`, `\\n`), iar codul generat folosește `var`/`function` clasic intenționat. +- O schimbare în `libJS`/`SNIP` afectează toate cele 5 motoare; verifică fiecare stil în preview. +- `exemplu-*.html` sunt jocuri demo exportate din builder (câte unul per stil). Nu le edita manual — după modificări la motoare, regenerează-le prin exportul din builder. +- `index.html` e doar pagina de landing care leagă builder-ul și demo-urile. +- `package.json` + `node_modules/` sunt **doar dev tooling** (Playwright). Produsul (fișierele HTML) rămâne zero-dependențe — merge offline de pe `file://`. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..91c4afb --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,342 @@ +# DESIGN.md — Escape Room Builder: Campania Multi-Stil + +Contract de design pentru PR1 (Etapa 1 + Tooling). Aprobat la design review 2026-06-12 (scor 6/10 → 9/10). +Integratorul consultă acest fișier ca sursă unică de adevăr pentru toate deciziile vizuale și de interacțiune. + +--- + +## 1. Tokens de design + +Orchestratorul declară o dată în `:root` al `gameCampaign`. **Toate suprafețele de campanie consumă DOAR aceste variabile** — nu hardcodezi culori în coridor sau diplomă. + +```css +:root { + --c-bg: #0d0620; /* fundal global campanie */ + --c-surface: #221440; /* carduri, panouri */ + --c-accent: ; /* accentul creatorului — suprascris la runtime */ + --c-gold: #fbbf24; /* stele, crescendo, reward */ + --c-line: rgba(255,255,255,.18);/* borduri subtile */ + --c-ink: #fff; /* text principal */ +} +``` + +**Note intenționate (nu se „repară"):** +- Violetul default (`#6d28d9`) și `system-ui` sunt deliberate — accentul aparține creatorului via `cfg.color`; webfonturile sunt imposibile pe `file://`. +- `@media print` suprascrie tokens pentru diplomă (fundal alb, borduri `--c-accent`). + +--- + +## 2. Intro — Scenă-poster (§Design pct. 1) + +**Structură (de sus în jos):** +1. Titlul jocului — cel mai mare element (`font-size: clamp(28px,6vw,48px)`, `font-weight: 900`) +2. „Salut, {nume}!" + povestea — secundar, `max-width: 60ch`, `color: rgba(255,255,255,.8)` +3. Promisiunea: „{N} camere · {S} stiluri · 1 cuvânt magic" — `font-size: 13px`, `color: rgba(255,255,255,.5)` +4. UN singur buton „Începe aventura" — full-width, `background: var(--c-accent)` + +**Reguli:** +- Intro necronometrat (timer-ul din E2 pornește la click „Începe aventura"). +- Fundalul folosește `--c-bg`; limbajul cardului final existent (fundal închis, accent creator). +- Povestea întreagă se spune O singură dată, aici. Camerele individuale NU repetă povestea. + +--- + +## 3. Coridor — Ierarhie vizuală (§Design pct. 2 + mockup B aprobat) + +``` +┌──────────────────────────────────────┐ +│ CHROME (48px / 40px mobil) │ ← unica sursă de progres global +│ ● ● ● ● ● (puncte camere) 0:00 │ +├──────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ litera câștigată │ ← strip compact: recompensa camerei +│ │ ★★★ + L │ stele camerei │ +│ └─────────────┘ │ +│ │ +│ ╔═══════════╗ │ ← ușa DOMINĂ ecranul (estetica stilului următor) +│ ║ UȘADOOR ║ │ min-height: 40% din viewport +│ ╚═══════════╝ │ +│ │ +│ ┌──────────────────────────────┐ │ ← buton full-width, activ IMEDIAT +│ │ Deschide ușa → │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +**Reguli:** +- Strip-ul (litera + stele) = **doar recompensa camerei curente**. +- Ușa este elementul erou — nu pune text lung lângă ea. +- Butonul „Deschide ușa" este activ **imediat** la montarea coridorului. Copilul deține ritmul. +- Butonul se dezactivează după primul click (idempotență, §T4). + +--- + +## 4. Progres — Un singur owner (§Design pct. 3) + +**Bara chrome = UNICA sursă de progres global.** + +``` +● ● ● ◉ ○ (puncte: parcurse=--c-gold, curentă=--c-accent, viitoare=stinse) +aria-label="Camera 3 din 5" +``` + +- Motoarele individuale **nu randează** progres de campanie (nu arată numărul camerei sau câte mai sunt). +- Strip-ul coridorului arată **doar** recompensa camerei (litera + stele). Nu total. +- `aria-label` obligatoriu pe containerul cu puncte (§A11y pct. 13). + +--- + +## 5. Tranziția = Ușa (§Design pct. 4) + +**Flow la click „Deschide ușa":** + +``` +click buton + → ușa primește .opening → animație scale+fade ~250ms (transform-origin: left center) + → coridorul rămâne pe ecran până la roomReady + → [>1.5s fără roomReady] → puncte de încărcare discrete PE ușă (nu modal separat) + → [4s fără roomReady] → skip automat (→ §5 Skip in-fiction) + → roomReady primit → fade-in camera ~200ms (zero flash) +``` + +**CSS animație deschidere (din `scratch/doors.html`):** +```css +@keyframes door-open { + 0% { transform: scale(1) rotateY(0deg); opacity: 1; } + 50% { transform: scale(1.06) rotateY(-30deg); opacity: .8; } + 100% { transform: scale(.85) rotateY(-90deg); opacity: 0; } +} +.door-STIL.opening { + animation: door-open .25s cubic-bezier(.4,0,1,1) forwards; + transform-origin: left center; perspective: 600px; +} +``` + +**`prefers-reduced-motion`:** `.opening { animation: none; opacity: 0; }` — stările finale apar direct. + +--- + +## 6. Skip In-Fiction — Ușa Înțepenită (§Design pct. 5) + +**Camera moartă = aceeași compoziție de coridor cu ușa înțepenită.** + +Aceeași structură ca §3, dar: +- Ușa primește clasa `.stuck` (grayscale + brightness 0.6) + badge `.door-lock` (🔒) +- Titlu: **„Ușa asta e înțepenită!"** (nu „Eroare" sau text tehnic) +- Buton primar: **„Sari la camera următoare"** +- Cod tehnic (stil·idx) — monospace mic, `color: rgba(255,255,255,.3)` — jos, fotografiabil + +```html + +
+
+ 🔒 +
+

Ușa asta e înțepenită!

+ + terminal·2 +
+``` + +**`roomError` post-ready** → același ecran (același handler, aceeași UI). +**Pe final/diplomă** → camera sărită = ușă înțepenită + 0 stele + dală goală (fără literă). + +--- + +## 7. Ritm + Crescendo (§Design pct. 6) + +- **Buget total animație per coridor: < 1s** (paralel, nu serial) +- **Butonul activ IMEDIAT** — copilul nu așteaptă animații +- **Ultimul coridor** = crescendo: + - Ușa finală mai mare (`.crescendo` class — scale 1.2 + glow per stil) + - Text suplimentar: **„Ultima cameră!"** (`font-weight: 700`, `color: var(--c-gold)`) + - Buton: „Deschide ultima ușă" (nu just „Deschide ușa") + +--- + +## 8. Cele 5 Uși — Spec CSS/SVG (§Design pct. 7) + +**Fișier de referință:** `scratch/doors.html` — vizualizare live cu toate stările. +**3 stări per ușă:** `.normal` (implicit) / `.stuck` (grayscale+lacăt) / `.crescendo` (scale+glow). + +### Door 1 — Classic +``` +Ușă-card albă · colțuri rotunjite (border-radius: 10px) · „?" auriu centrat +Crescendo: chenar auriu (box-shadow: 0 0 0 3px var(--c-gold)) +``` + +### Door 2 — Terminal +``` +Dreptunghi negru · ramă verde fosforescent (border: 2px solid #39ff6e, glow 14px) +Cursor _ clipind (animation: dt-blink 1s step-end infinite) +Scanlines (::before, repeating-linear-gradient) +Stuck: border-color: #444, cursor animation: none +Crescendo: double-ring + glow intensificat +``` + +### Door 3 — Arcade +``` +Pixel-art chunky: border: 4px solid #4ade80 + box-shadow: inset 0 0 0 4px #166534, 0 0 0 4px #0a1606 +Mâner galben pătrat (::after, 10×10px, no border-radius) +image-rendering: pixelated +Crescendo: outer ring #4ade80 + glow verde +``` + +### Door 4 — Chat +``` +Siluetă telefon: gradient albastru (#1d4ed8→#1e3a8a), border-radius: 12px +Screen dark cu bule de mesaj (NPC: #1e40af stânga, player: #3b82f6 dreapta) +Home bar (22px, rgba(255,255,255,.3)) +Crescendo: box-shadow: 0 0 0 2px #3b82f6 + glow albastru +``` + +### Door 5 — Point +``` +SVG: ușă de scenă din lemn (#7c4f2c), 2 panouri superioare (rgba(0,0,0,.22)) +Clanță rotundă (#f3cf6d), lupă (cerc + linie diagonală, stroke: #f3cf6d, width 3) +Crescendo: filter drop-shadow(0 0 12px rgba(243,207,109,.6)) +``` + +--- + +## 9. Final ≠ Diplomă (§Design pct. 8) + +**Ecranul final celebrează DOAR.** Nu afișează diploma. + +``` +Evadare reușită! +★★★★★ (stele totale) +[D] [A] [N] [C] [E] ← cuvântul magic, literă-cu-literă, 0.18s delay +Mesajul creatorului +[Joacă din nou] [Vezi diploma →] ← buton secundar „Vezi diploma" (Etapa 2) +``` + +**Camera sărită** = dală goală cu 🔒 (fără literă, fără animație flip). + +--- + +## 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. + +- **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)` +- **Titlu:** „DIPLOMĂ DE EVADARE" — **singurul** element cu font serif (limbajul certificatelor) +- **Ierarhie:** Numele copilului = cel mai mare element pe pagină +- **Rând de stele per cameră** — camerele sărite = 🔒 +- **Cuvântul magic** în dăle, aceeași iconografie ca finalul +- **Footer:** dată + „creat de {creator}" + marcaj discret „timpul a expirat" (dacă e cazul) +- **`@media print`:** doar diploma, `margin: 20mm`, `print-color-adjust: exact` pe accente, `background: white` + +--- + +## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2) + +> Implementare în T10/PR2. + +- Pornește **exact** la click „Începe aventura" (intro necronometrat) +- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`) +- **Sub 1 minut** → devine auriu (`color: var(--c-gold)`) — NU roșu pulsant (public copii) +- La **expirare** → îngheață pe `0:00` + marcaj text discret; jocul curge nestingherit (zero penalizare, stelele rămân) +- **Deadline absolut** în `sessionStorage` — resume-ul nu resetează ceasul + +--- + +## 12. Buget Vertical Mobil (§Design pct. 11) + +``` +Chrome: 48px desktop / 40px la max-width: 600px (doar puncte + timer, fără etichete) +Camerele: height = calc(100dvh - ) (dvh pentru mobile address bar) +Arcade: max-height pe canvas → scalare automată să încapă în viewport +``` + +**Assert obligatoriu în `tests/smoke.mjs`:** +```js +// La viewport 320×568, niciun element nu depășește documentElement.scrollWidth +assert(document.documentElement.scrollWidth <= 320, 'overflow orizontal 320px') +``` + +**Reguli:** +- ZERO scroll orizontal la 320×568 (iPhone SE) +- Fără `overflow-x: auto` pe coridor sau cameră — rezolvă problema, nu o ascunde + +--- + +## 13. Mod Cameră — Regulă Funcțională (§Design pct. 12 — închide D14) + +**În campanie, fiecare motor ascunde:** (a) titlul jocului, (b) progresul global propriu, (c) restart-ul propriu. +**Părintele deține** toate acestea (bara chrome). Motoarele **păstrează** HUD-ul de gameplay și identitatea stilului. + +| Motor | Ascunde (în campanie) | Păstrează | +|----------|---------------------------------|----------------------------------| +| Classic | `h1` + bara de progres + contor | Întrebări, stele per puzzle | +| Terminal | Titlul mare (linia banner) | Antetul de 1 linie, comenzi | +| Arcade | `h1` | HUD chei/stele cameră, canvas | +| Chat | Subtitlul jocului | Header-ul personajului, bule | +| Point | `h1` | Hint-ul de scenă, obiectele SVG | + +**Implementare:** când `CFG._campaign` există, motorul adaugă clasa `.campaign-mode` pe `body`. +CSS: `.campaign-mode h1, .campaign-mode .game-progress { display: none; }` (specific per motor). + +--- + +## 14. A11y Baseline — Suprafețe Noi (§Design pct. 13) + +Aplicabil pe toate suprafețele adăugate în PR1 (coridor, chrome, intro, skip, final). +**Nu este audit al motoarelor existente** — acela e în `TODOS.md`. + +| Criteriu | Specificație | +|----------|-------------| +| Ținte tap | Minim 44×44px (buton „Deschide ușa", puncte chrome, buton skip) | +| Contrast text | `rgba(255,255,255,.7)` pe `--c-bg` ≥ 4.5:1 — text secundar | +| Focus | Buton „Deschide ușa" focusabil + activabil cu Enter | +| ARIA | `aria-label="Camera X din Y"` pe containerul punctelor de progres | +| Motion | `@media (prefers-reduced-motion: reduce)` dezactivează: confetti, flipin, animația ușii — stările finale apar direct | + +--- + +## 15. Tabel Stări per Suprafață + +| Suprafață | Loading | Empty | Error | Success | Partial | +|-----------|---------|-------|-------|---------|---------| +| Coridor → cameră | ușa .opening; puncte pe ușă >1.5s | — | ușă .stuck + skip (§6) | fade-in cameră 200ms | — | +| Builder (campanie) | — | 0 puzzle: mesaj ghidant, export blocat | stil invalid: avertisment + rotație | preview rulează | — | +| Resume | — | — | hash invalid → restart silențios de la intro | coridorul camerei curente | refresh mid-campanie → coridor | +| Final | — | — | camere sărite: 🔒 + dală goală | stele + cuvânt + confetti | cuvânt cu dăle-lacăt | +| Skip camera | — | — | roomError (oricând) → ecran stuck | — | 0 stele, fără literă, cod eroare | + +--- + +## 16. Storyboard Emoțional + +| Pas | Copilul | Simte | Susținut de | +|-----|---------|-------|-------------| +| Intro | vede titlul + promisiunea | curiozitate | §2 (poster) | +| Camera 1 | joacă primul stil | concentrare | §12 (mod cameră discret), chrome neutru | +| Coridor 1 | primește litera, vede ușa nouă | mândrie + anticipare | §3, §5, §8 | +| Camerele 2..N-1 | stil nou de fiecare dată | varietate, „ce urmează?" | rotație + ritual <1s (§7) | +| Ultimul coridor | ușa mai mare, „Ultima cameră!" | crescendo | §7 (crescendo) | +| Final | cuvântul magic se dezvăluie | triumf | §9 (final celebrare pură) | +| Diplomă (E2) | o printează cu părintele | mândrie fizică | §10 (A4 print-first) | + +--- + +## 17. NOT in Scope (cu motiv) + +| Funcție | Motiv excludere | +|---------|-----------------| +| Audit a11y motoare existente | Post-PR1, sub harness Playwright — în `TODOS.md` | +| Timer implementare | PR2/Etapa 2 (T10) | +| Diplomă implementare | PR2/Etapa 2 (T10) | +| Auto-advance la coridor | Respins (6C): agenția copilului primează la momentul-recompensă | +| Overlay tehnic de skip | Respins (5B): eșecul rămâne în ficțiune (ușă înțepenită) | +| Diploma pe ecranul final | Respins (3B): one job per section | + +--- + +## 18. Fișiere de Referință + +| Artefact | Locație | Conținut | +|----------|---------|---------| +| Uși (cod) | `scratch/doors.html` | 5 uși CSS/SVG × 3 stări, animație deschidere, tokens | +| Mockup aprobat | `~/.gstack/projects/romfast-escape-builder/designs/coridor-campanie-20260612/` | wireframes.html, coridor-3-variante.png (varianta B), approved.json | +| Plan complet | `~/.claude/plans/home-claude-claude-plans-propune-alte-u-replicated-papert.md` | §Design, task-uri, review reports | diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..7ef36e2 --- /dev/null +++ b/TODOS.md @@ -0,0 +1,65 @@ +# TODOS — Escape Room Builder + +Backlog post-PR1 și note tehnice pentru iterațiile viitoare. +Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2026-06-12-campania-multi-stil.md` + +--- + +## Post-PR1 (după ship-ul campaniei) + +### Muzică accelerată la timer (PR2 / T10) +- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut. +- Ownership: părintele deține AudioContext; camerele nu știu de muzică. +- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve). +- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan. +- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10). + +### Edge case-uri voce (SpeechSynthesis) — PR2 +- `speechSynthesis.getVoices()` poate fi gol sincron → ascultă `voiceschanged`. +- Fără voce `ro-*` → fallback la vocea default (nu crash, nu tăcere). +- Voce activă mid-cameră → `speechSynthesis.cancel()` la demontare cameră (pater deține). +- `parent.voiceSay(text)` = no-op în jocurile simple (funcția nu există) → guard `typeof parent.voiceSay === 'function'`. +- Referință: D10 din plan; E2 Etapa 2 pct. 3. + +### Unificarea `finale()` din terminal pe `SNIP.finalJs` (PR2 primul pas) +- Astăzi terminalul are propria funcție `finale()` (escape-builder.html:863) care NU folosește `SNIP.finalJs`. +- Migrarea pe SNIP.finalJs deblochează ramura `_campaign` uniformă pentru toate cele 5 motoare. +- Prim pas al Etapei 2 (D7): migrarea `gameClassic` pe `libJS+SNIP` → regresie manuală pe classic. +- Referință: planul §Etapa 2 pct. 1; D7. + +### Audit a11y motoare existente (post-PR1, sub harness Playwright) +- **Ținte tap ≥ 44px**: dpad arcade, butoane tf/choice, butonul "Trimite" din chat. +- **Contrast ≥ 4.5:1**: text terminal dim (`#1f9c4a` pe `#04130a` — verifică), hint text clasic. +- **`@media (prefers-reduced-motion: reduce)`**: dezactivează `pop`, `flip`, `flipin`, `shake`, `confetti`, `bin` — stările finale apar direct. +- **Focus & Enter**: "Deschide ușa" (campanie) focusabil + Enter; dpad arcade accesibil cu keyboard. +- **`aria-label` pe progres**: bara chrome din campanie (`aria-label="Camera X din Y"`). +- Referință: §Design pct. 13 (TD5, PR2); D19 din plan. + +--- + +## Iterația 2 — Adventure Mode v0 +*(decizie office-hours: fundația contractului de azi e infrastructura directă)* + +- Contract de montare (`nextRoom`, `roomReady`, `roomError`) se refolosesc as-is. +- Motoarele noi (orice stil) implementează aceleași 3 puncte + `parent.beep`. +- `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 +*(depinde de măsurarea dimensiunii JSON comprimate)* + +- `gameHTML(cfg)` → URL data: sau LZW/gzip → QR code printabil. +- Open Question 2 din design doc: câte puzzle-uri încap în 2KB (URL QR L)? +- Alternative: GitHub Pages export automat; sau link scurt cu backend minimal. +- Referință: design doc §NOT in scope "Joc-în-URL + QR". + +--- + +## Known improvements (oricând) + +- **`updateHud` duplicat**: arcade linia 1003 și point linia 1283 au funcții identice → consolidat în `SNIP` (T8 din plan, igienă PR1). +- **`persist()` fără try/catch**: builder-ul poate crăpa pe storage plin → guard (D12, T8). +- **`esc(L)` la inserția innerHTML din point** (:1274): un `<` în câmpul `letter` strică scena SVG (D13, T8). +- **Validare 0 puzzle-uri**: export și preview blocate cu mesaj ghidant (T5). +- **Stil invalid la import JSON**: avertisment în builder + rotație automată (T5, D8). diff --git a/escape-builder.html b/escape-builder.html index 74a63c8..3cefea9 100644 --- a/escape-builder.html +++ b/escape-builder.html @@ -118,6 +118,7 @@ +
@@ -160,6 +161,9 @@ const STORAGE_KEY = 'escape-builder-v1'; +const CAMPAIGN_ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point']; +const CAMPAIGN_STYLE_NAMES = { classic: 'Clasic', terminal: 'Terminal Retro', arcade: 'Arcade Pixel', chat: 'Story Chat', point: 'Point-and-Click' }; + const defaultState = () => ({ title: 'Comoara ascunsa', player: '', @@ -175,15 +179,32 @@ const defaultState = () => ({ ] }); -const blankPuzzle = () => ({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '' }); +function normalizePuzzle(p) { + const validTypes = ['free', 'tf', 'choice']; + const validStyles = ['', 'classic', 'terminal', 'arcade', 'chat', 'point']; + if (typeof p.title !== 'string') p.title = ''; + if (!validTypes.includes(p.type)) p.type = 'free'; + if (typeof p.question !== 'string') p.question = ''; + if (typeof p.answer !== 'string') p.answer = ''; + if (p.tfAnswer !== 'Adevarat' && p.tfAnswer !== 'Fals') p.tfAnswer = 'Adevarat'; + if (typeof p.choices !== 'string') p.choices = ''; + if (typeof p.hint !== 'string') p.hint = ''; + if (typeof p.letter !== 'string') p.letter = ''; + if (!validStyles.includes(p.style || '')) p.style = ''; + if (typeof p.style === 'undefined') p.style = ''; + return p; +} + +const blankPuzzle = () => normalizePuzzle({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '', style: '' }); let state = Object.assign(defaultState(), load() || {}); +if (Array.isArray(state.puzzles)) state.puzzles = state.puzzles.map(normalizePuzzle); function load() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) { return null; } } function persist() { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { /* quota/private mode — continuă fără autosave (D12) */ } } /* ---------- editor ---------- */ @@ -227,7 +248,20 @@ function puzzleCard(p, i) {
+ ${state.style === 'campaign' ? ` +
+ + +
` : ''} + ${state.style === 'campaign' && p.style && !CAMPAIGN_STYLE_NAMES[p.style] ? `
Stil necunoscut "${esc(p.style)}" — se foloseste Auto
` : ''} ${p.type === 'free' ? ` @@ -269,7 +303,11 @@ function esc(s) { /* evenimente editor */ document.querySelectorAll('[data-g]').forEach(el => { - el.addEventListener('input', () => { state[el.dataset.g] = el.value; onChange(); }); + el.addEventListener('input', () => { + state[el.dataset.g] = el.value; + if (el.dataset.g === 'style') renderPuzzles(); /* re-render: style selector per card apare/dispare */ + onChange(); + }); }); puzzleList.addEventListener('input', e => { @@ -336,6 +374,7 @@ $('#fileLoad').addEventListener('change', e => { const data = JSON.parse(txt); if (!Array.isArray(data.puzzles)) throw new Error('format'); state = Object.assign(defaultState(), data); + state.puzzles = state.puzzles.map(normalizePuzzle); renderGlobals(); renderPuzzles(); onChange(); } catch (err) { alert('Fisierul nu este un proiect valid de escape room.'); @@ -345,6 +384,7 @@ $('#fileLoad').addEventListener('change', e => { }); $('#btnExport').addEventListener('click', () => { + if (state.puzzles.length === 0) { alert('Adauga cel putin un puzzle inainte de export!'); return; } download(slug(state.title) + '.html', gameHTML(cleanState()), 'text/html'); }); @@ -352,7 +392,11 @@ $('#btnReload').addEventListener('click', refreshPreview); function cleanState() { const s = JSON.parse(JSON.stringify(state)); - s.puzzles.forEach(p => delete p._closed); + s.puzzles.forEach(p => { + delete p._closed; + /* 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); + }); return s; } @@ -379,18 +423,25 @@ function onChange() { } function refreshPreview() { - $('#frame').srcdoc = gameHTML(cleanState()); + if (state.puzzles.length === 0) { + $('#frame').srcdoc = '
🚪

Adaugă cel puțin un puzzle
ca să vezi preview-ul.

'; + return; + } + const previewCfg = cleanState(); + if (previewCfg.style === 'campaign') previewCfg._noResume = true; /* preview nu reia niciodată (D3) */ + $('#frame').srcdoc = gameHTML(previewCfg); } /* ---------- template-urile jocului exportat ---------- */ function gameHTML(cfg) { - const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint }; + const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint, campaign: gameCampaign }; return (engines[cfg.style] || gameClassic)(cfg); } function gameClassic(cfg) { - const json = JSON.stringify(cfg).replace(/ @@ -609,6 +660,11 @@ function check(given, expected) { function next() { idx++; if (idx < CFG.puzzles.length) { renderPuzzle(); return; } + if(CFG._campaign){ + var L = ''; for(var ci=0;ci `; @@ -666,7 +725,8 @@ function beep(ok) { /* ---------- biblioteca comuna pentru motoarele de joc ---------- */ function libJS(cfg) { - const json = JSON.stringify(cfg).replace(/
`; SNIP.finalJs = `function showFinal(){ + if(CFG._campaign){ + var L = finalWord().charAt(0); + try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L}); }catch(e){} + return; + } el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \\u2605'; var w = finalWord(); var bw = el('fWord'); bw.innerHTML = ''; for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); } @@ -848,7 +921,10 @@ function echo(text, cls){ var d = document.createElement('div'); d.className = ' function collected(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += solved[i] ? L.toUpperCase() + ' ' : '_ '; } return w.trim() || '(niciuna)'; } var bar = '=============================================='; -say([bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.'], '', nextPuzzle); +var introLines = CFG._campaign + ? [bar, ' ' + CFG.title.toUpperCase(), bar, 'Comenzi: INDICIU, AJUTOR. Scrie raspunsul si apasa Enter.'] + : [bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.']; +say(introLines, '', nextPuzzle); function nextPuzzle(){ idx++; attempts = 0; hintUsed = false; @@ -862,6 +938,13 @@ function nextPuzzle(){ function finale(){ done = true; + if(CFG._campaign){ + var s = totalStars; var L = finalWord().charAt(0); + say(['>> CAMERA REZOLVATA! Stele: ' + s + (L ? ' | Litera: ' + L : '')], 'ok', function(){ + try{ parent.nextRoom({idx:CFG._campaign.idx, stars:s, letter:L}); }catch(e){} + }); + return; + } var w = finalWord().split('').join(' '); var lines = [' ', bar, ' E V A D A R E R E U S I T A', bar, 'Stele: ' + totalStars + ' / ' + (CFG.puzzles.length * 3)]; if (w) lines.push('Cuvantul magic: ' + w); @@ -904,6 +987,7 @@ cmd.addEventListener('keydown', function(e){ say(['>> ACCES RESPINS. Mai incearca.'], 'bad'); } }); +roomReady(); <\/script> `; @@ -1047,6 +1131,7 @@ document.querySelectorAll('#dpad button').forEach(function(b){ ${SNIP.modalJs} ${SNIP.finalJs} updateHud(); draw(); +roomReady(); <\/script> `; @@ -1182,8 +1267,12 @@ function next(){ seq([(p.title ? p.title + '. ' : '') + p.question], function(){ setComposer(p); }); } -seq(['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']), next); +var chatIntro = CFG._campaign + ? ['Camera ' + (CFG._campaign.idx + 1) + ' din ' + CFG._campaign.total + '. Sa incepem!'] + : ['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']); +seq(chatIntro, next); ${SNIP.finalJs} +roomReady(); <\/script> `; @@ -1296,6 +1385,568 @@ function updateHud(){ ${SNIP.modalJs} ${SNIP.finalJs} updateHud(); +roomReady(); +<\/script> + +`; +} + +/* ---------- motor: campanie multi-stil ---------- + * + * ASCII DIAGRAM — contractul parent.*: + * ┌──────────── orchestrator (window) ─────────────────────────┐ + * │ nextRoom({idx,stars,letter}) ← camera.parent.nextRoom │ + * │ roomReady(idx) ← camera.parent.roomReady │ + * │ roomError(idx,msg) ← camera.parent.roomError │ + * │ beep(ok) ← camera.parent.beep │ + * └────────────────────────────────────────────────────────────┘ + * idx activ deținut de orchestrator; frame stale ignorate (D5). + * nextRoom acceptat o singură dată per idx (idempotență, D4). + * Timeout 4s fără roomReady → skip cameră (T3). + * srcdoc = TPL[stil].replace(TOKEN, fn) — funcție nu string (D1). + * json = JSON.stringify(cfg).replace(/ 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(/ + + + + +${esc(cfg.title)} + + + + +
+ ${esc(cfg.title)} +
+
+
+ +
+ + +
+

+

+

+ +
+ +
+
+
+
Litera câștigată
+
+
+
+
+
+
+ +
+ +
+

⚠️ Ușa asta e înțepenită!

+
+

Camera nu a răspuns. Poți sări la cea următoare.

+
+ +
+ +
+

🏆 Evadare reușită!

+
+

Cuvântul magic:

+
+

+
+
+ + \ No newline at end of file diff --git a/exemplu-campanie.html b/exemplu-campanie.html new file mode 100644 index 0000000..2076ce2 --- /dev/null +++ b/exemplu-campanie.html @@ -0,0 +1,527 @@ + + + + + +Comoara ascunsa + + + + +
+ Comoara ascunsa +
+
+
+ +
+ + +
+

+

+

+ +
+ +
+
+
+
Litera câștigată
+
+
+
+
+
+
+ +
+ +
+

⚠️ Ușa asta e înțepenită!

+
+

Camera nu a răspuns. Poți sări la cea următoare.

+
+ +
+ +
+

🏆 Evadare reușită!

+
+

Cuvântul magic:

+
+

+
+
+ + + + \ No newline at end of file diff --git a/exemplu-chat.html b/exemplu-chat.html index 35d5596..767a0b1 100644 --- a/exemplu-chat.html +++ b/exemplu-chat.html @@ -59,7 +59,7 @@ \ No newline at end of file diff --git a/exemplu-clasic.html b/exemplu-clasic.html index 3084a74..e23efe7 100644 --- a/exemplu-clasic.html +++ b/exemplu-clasic.html @@ -96,7 +96,7 @@ \ No newline at end of file diff --git a/exemplu-point.html b/exemplu-point.html index e11ff21..29dddfa 100644 --- a/exemplu-point.html +++ b/exemplu-point.html @@ -73,7 +73,7 @@ \ No newline at end of file diff --git a/exemplu-terminal.html b/exemplu-terminal.html index e4bc319..6a5539f 100644 --- a/exemplu-terminal.html +++ b/exemplu-terminal.html @@ -25,7 +25,7 @@
>
\ No newline at end of file diff --git a/index.html b/index.html index 1682c14..704ee00 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,7 @@

Escape Room Builder

Builder-ul + cate un joc demo exportat in fiecare stil.

Builder editor + preview live; schimba "Stil joc" si vezi transformarea pe loc + 🗺️ Campanie multi-stil 3 puzzle-uri × 3 stiluri diferite — ușa ca erou, coridor cu litera, cuvântul magic Clasic (quiz) carduri secventiale cu progres si litere Terminal retro text adventure CRT; scrie raspunsul, INDICIU, LITERE Arcade pixel sageti / WASD; usi incuiate, cufar final diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..53222ae --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,700 @@ +// @ts-check +/** + * tests/smoke.mjs — Escape Room Builder smoke & regression tests + * + * Setup (o singura data): + * npm install + * npx playwright install chromium + * + * Rulare: + * npx playwright test tests/smoke.mjs # suita completa + * npx playwright test tests/smoke.mjs --grep @regresie # regresie (baseline, acum) + * npx playwright test tests/smoke.mjs --grep @campanie # campanie (dupa integrator) + * npm test # alias pentru suita completa + * + * @see CLAUDE.md § Testing + */ + +import { test, expect } from '@playwright/test'; +import { writeFileSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +/** Converteste cale relativa la file:// URL. */ +const fileURL = (name) => 'file://' + join(ROOT, name); + +/** + * Ataseaza listeneri de erori si returneaza array-ul de erori. + * Test-ul trebuie sa asserteze `errors.length === 0` la final. + */ +function trackErrors(page) { + const errors = []; + page.on('console', msg => { + if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`); + }); + page.on('pageerror', err => errors.push(`[pageerror] ${err.message}`)); + return errors; +} + +/** + * Asteapta si rezolva un puzzle in modalul comun (motoarele arcade / point). + * Presupune ca modalul e deja vizibil. + */ +async function solveModal(page, puzzle) { + await expect(page.locator('#mOverlay')).toBeVisible({ timeout: 5000 }); + if (puzzle.type === 'free') { + await page.locator('#mAnswers input[type=text]').fill(puzzle.answer); + await page.locator('#mAnswers button:not(.mhint):not(.mclose)').first().click(); + } else if (puzzle.type === 'tf') { + await page.locator(`#mAnswers button:text("${puzzle.tfAnswer}")`).click(); + } else { + // choice: gaseste varianta corecta (prefixata cu *) + const correct = puzzle.choices.split('\n') + .find(l => l.trim().startsWith('*'))?.replace(/^\*/, '').trim() ?? ''; + await page.locator(`#mAnswers button:text("${correct}")`).click(); + } + // Modalul se inchide dupa ~750ms animatie de success + await page.waitForFunction( + () => document.getElementById('mOverlay')?.style.display !== 'flex', + { timeout: 3000 } + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 1 — REGRESIE: fiecare exemplu-*.html rezolvat pana la final +// +// Contractul: diff-ul campaniei modifica finalul tuturor celor 5 stiluri +// (ramura _campaign in SNIP.finalJs + finale() terminal + final classic). +// Aceste teste verifica ca finalul existent NU este stricat. +// +// Ruleaza ACUM ca baseline contra exemplu-*.html curente. +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Regresie exemplu-*.html @regresie', () => { + + test('exemplu-clasic.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-clasic.html')); + + // Start game + await page.locator('#btnStart').click(); + await page.waitForSelector('#sGame.on', { timeout: 3000 }); + + // Puzzle 1: raspuns liber "56" + await page.locator('#answers input[type=text]').fill('56'); + await page.locator('#answers button:text("Verifica")').click(); + await page.waitForTimeout(1100); // asteapta animatia si next() + + // Puzzle 2: adevarat/fals "Adevarat" + await page.locator('#answers button:text("Adevarat")').click(); + await page.waitForTimeout(1100); + + // Puzzle 3: variante "Paris" + await page.locator('#answers button:text("Paris")').click(); + await page.waitForTimeout(1200); + + // Ecranul final trebuie sa fie vizibil + await expect(page.locator('#sFinal')).toHaveClass(/on/, { timeout: 3000 }); + // Cuvantul magic "DAR" afişat ca litere individuale + const bigword = page.locator('#bigword'); + await expect(bigword).toContainText('D'); + await expect(bigword).toContainText('A'); + await expect(bigword).toContainText('R'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-terminal.html — rezolvat pana la ecranul final', async ({ page }) => { + // Animatia de typing poate fi lenta; marim timeout-ul pentru acest test + test.setTimeout(120000); + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-terminal.html')); + + // Asteapta ca intro-ul sa termine animatia de typing si primul puzzle sa apara. + // Nota: { timeout } in waitForFunction merge ca al doilea argument FARA arg intermediar. + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[1/'), + null, { timeout: 20000 } + ); + + // Puzzle 1: raspuns liber "56" + // Folosim .press() pe locator (nu keyboard global) pentru focus garantat. + await page.locator('#cmd').fill('56'); + await page.locator('#cmd').press('Enter'); + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('ACCES PERMIS'), + null, { timeout: 15000 } + ); + + // Asteapta puzzle 2 + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[2/'), + null, { timeout: 15000 } + ); + + // Puzzle 2: adevarat/fals "Adevarat" + await page.locator('#cmd').fill('Adevarat'); + await page.locator('#cmd').press('Enter'); + await page.waitForFunction( + () => (document.getElementById('out')?.textContent?.match(/ACCES PERMIS/g) ?? []).length >= 2, + null, { timeout: 15000 } + ); + + // Asteapta puzzle 3 + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[3/'), + null, { timeout: 15000 } + ); + + // Puzzle 3: variante - "1" = Paris (prima optiune numerotata) + await page.locator('#cmd').fill('1'); + await page.locator('#cmd').press('Enter'); + + // Asteapta textul de finale. + // IMPORTANT: textul e "E V A D A R E R E U S I T A" (litere cu spatii intre ele)! + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('E V A D A R E'), + null, { timeout: 20000 } + ); + + // Cuvantul magic "DAR" apare ca "D A R" (spaced) — verifica fiecare litera + const outText = await page.locator('#out').innerText(); + expect(outText).toMatch(/D\s+A\s+R/); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-arcade.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-arcade.html')); + + // Asteapta initializarea canvas + API-urilor de puzzle + await page.waitForFunction( + () => typeof openPuzzle !== 'undefined' && typeof onDoorSolved !== 'undefined', + { timeout: 5000 } + ); + + const puzzles = await page.evaluate(() => CFG.puzzles); + + // Rezolva fiecare puzzle prin API-ul modal (simuleaza interactiunea cu usile) + for (let i = 0; i < puzzles.length; i++) { + await page.evaluate((idx) => openPuzzle(idx, onDoorSolved), i); + await solveModal(page, puzzles[i]); + } + + // Toate puzzle-urile rezolvate → trigger final (simuleaza ajungerea la cufar) + await page.evaluate(() => showFinal()); + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-chat.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-chat.html')); + + // Asteapta ca intro-ul (Salut + poveste + "Ma ajuti?") sa termine + // si primul puzzle (free) sa apara cu input in composer + await page.waitForFunction( + () => document.getElementById('composer')?.querySelector('input') !== null, + { timeout: 20000 } + ); + + // Puzzle 1: raspuns liber "56" + await page.locator('#composer input').fill('56'); + await page.locator('#composer button:not(.chip)').first().click(); // "Trimite" + // Asteapta confirmarea "Asta era!" + await page.waitForFunction( + () => document.getElementById('msgs')?.textContent?.includes('Asta era'), + { timeout: 12000 } + ); + + // Puzzle 2: tf — asteapta chip-ul "Adevarat" + await page.waitForFunction( + () => { + const c = document.getElementById('composer'); + return c && [...c.querySelectorAll('button.chip')] + .some(b => b.textContent.trim() === 'Adevarat'); + }, + { timeout: 15000 } + ); + await page.locator('#composer button.chip:text("Adevarat")').click(); + await page.waitForFunction( + () => (document.getElementById('msgs')?.textContent?.match(/Asta era/g) ?? []).length >= 2, + { timeout: 12000 } + ); + + // Puzzle 3: choice — asteapta chip-ul "Paris" + await page.waitForFunction( + () => { + const c = document.getElementById('composer'); + return c && [...c.querySelectorAll('button.chip')] + .some(b => b.textContent.trim() === 'Paris'); + }, + { timeout: 15000 } + ); + await page.locator('#composer button.chip:text("Paris")').click(); + + // Asteapta overlay-ul final + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-point.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-point.html')); + + // Asteapta randarea scenei SVG cu obiectele "hot" + await page.waitForFunction( + () => typeof openPuzzle !== 'undefined' && document.querySelectorAll('g.hot').length > 0, + { timeout: 5000 } + ); + + const puzzles = await page.evaluate(() => CFG.puzzles); + + // Click fiecare obiect hot si rezolva puzzle-ul din modal + for (let i = 0; i < puzzles.length; i++) { + await page.locator(`g.hot[data-i="${i}"]`).click(); + await solveModal(page, puzzles[i]); + } + + // Toate rezolvate → usa se deschide → click pe usa → ecranul final + await expect(page.locator('#door')).toHaveClass(/open/, { timeout: 3000 }); + await page.locator('#door').click(); + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + +}); + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 2 — EDGE CASES (rulabile acum, fara campanie) +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Edge cases @regresie', () => { + + test('import JSON corupt — fara crash, builder ramane functional', async ({ page }) => { + const errors = trackErrors(page); + + // Dismiss alert-ul de eroare + page.on('dialog', d => d.accept()); + + await page.goto(fileURL('escape-builder.html')); + + // Scrie un fisier JSON corupt temporar si incarca-l + const tmpPath = join(ROOT, 'tests', '.tmp-corrupt-test.json'); + writeFileSync(tmpPath, '{"title":"test","puzzles":[{INVALID_JSON_HERE}]}'); + + try { + await page.locator('#fileLoad').setInputFiles(tmpPath); + await page.waitForTimeout(600); // asteapta alert + dismiss + } finally { + unlinkSync(tmpPath); + } + + // Builder-ul trebuie sa fie in continuare functional + await expect(page.locator('#gTitle')).toBeVisible(); + await expect(page.locator('#addPuzzle')).toBeVisible(); + // Starea existenta trebuie pastrata (nu resetata la corupt) + const title = await page.locator('#gTitle').inputValue(); + expect(title.length).toBeGreaterThan(0); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + // Assert 320x568 fara overflow orizontal per stil (§Design pct. 11) + for (const stil of ['clasic', 'terminal', 'arcade', 'chat', 'point']) { + test(`320x568 fara overflow orizontal — ${stil}`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + const errors = trackErrors(page); + + await page.goto(fileURL(`exemplu-${stil}.html`)); + await page.waitForTimeout(400); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1 + ); + expect(overflow, `Overflow orizontal la 320x568 in exemplu-${stil}`).toBe(false); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + } + + test('regenerare demo-uri via gameHTML — fara erori de consola', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + // Verifica ca gameHTML genereaza HTML valid si fara erori pentru fiecare stil + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + const singlePuzzle = { + title: 'Q1', type: 'free', question: 'Test?', answer: 'da', + tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'T' + }; + + for (const style of styles) { + const html = await page.evaluate((args) => { + return gameHTML({ + title: 'Test ' + args.style, player: 'Tester', color: '#6d28d9', + style: args.style, charName: 'Alex', + story: 'Poveste test.', finalMessage: 'Bravo!', + puzzles: [args.puzzle] + }); + }, { style, puzzle: singlePuzzle }); + + // Verificari de baza pe stringul HTML generat + expect(typeof html, `${style}: gameHTML nu a returnat string`).toBe('string'); + expect(html, `${style}: lipseste doctype`).toContain('undefined<'); + + // Incarca in pagina noua si verifica absenta erorilor + const genPage = await page.context().newPage(); + const genErrors = []; + genPage.on('console', m => { if (m.type() === 'error') genErrors.push(m.text()); }); + genPage.on('pageerror', e => genErrors.push(e.message)); + + await genPage.setContent(html, { waitUntil: 'domcontentloaded' }); + await genPage.waitForTimeout(500); + + expect(genErrors, `${style} gameHTML erori:\n${genErrors.join('\n')}`).toHaveLength(0); + await genPage.close(); + } + + expect(errors, 'Builder erori:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('builder: JSON cu tip puzzle necunoscut → normalizat, fara crash', async ({ page }) => { + const errors = trackErrors(page); + page.on('dialog', d => d.accept()); + + await page.goto(fileURL('escape-builder.html')); + + // Incarca JSON cu tip invalid si choices non-string + const tmpPath = join(ROOT, 'tests', '.tmp-invalid-type.json'); + writeFileSync(tmpPath, JSON.stringify({ + title: 'Test normalizare', + style: 'classic', + color: '#6d28d9', + charName: 'X', + story: 'S', + finalMessage: 'F', + puzzles: [ + { title: 'P1', type: 'INVALID_TYPE', question: 'Q?', answer: 'A', + tfAnswer: 'Adevarat', choices: 42, hint: null, letter: 'X' } + ] + })); + + try { + await page.locator('#fileLoad').setInputFiles(tmpPath); + await page.waitForTimeout(600); + } finally { + unlinkSync(tmpPath); + } + + // Builder-ul trebuie sa ramana functional (nu crash) + await expect(page.locator('#gTitle')).toBeVisible(); + await expect(page.locator('#addPuzzle')).toBeVisible(); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + +}); + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 3 — CAMPANIE E2E +// +// *** SKIP pana cand integrator anunta implementarea gata *** +// Dupa: schimba `test.skip(true, ...)` → `test.skip(false)` sau sterge linia. +// +// Contractul documentat in plan: +// - gameCampaign(cfg) genereaza HTML cu iframe per camera +// - Fiecare camera apeleaza parent.nextRoom({idx, stars, letter}) +// - parent.roomReady() semnaledaza montarea cu succes +// - parent.roomError(idx, msg) declanseaza skip cu 0 stele +// - Timeout 4s fara roomReady → camera moarta → skip +// - CFG._campaign = {idx, total, stars, letters, deadline} in fiecare camera +// - Replace token: TPL.replace('__CFG__', () => json) (D1: evita $&) +// - safeStore (try/catch) pentru resume (D3) +// - Hash djb2 peste CFG embedat la export (D11) +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Campanie E2E @campanie', () => { + + /** Helper: genereaza un cfg de campanie cu N puzzle-uri, stiluri rotite. */ + function campaignCfg(n = 5) { + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + return { + title: 'Test Campanie ' + n, player: 'Tester', color: '#6d28d9', + style: 'campaign', charName: 'Alex', + story: 'O campanie de test cu ' + n + ' camere.', + finalMessage: 'Ai terminat campania!', + puzzles: Array.from({ length: n }, (_, i) => ({ + title: 'Camera ' + (i + 1), + type: 'free', + question: 'Raspunde ' + (i + 1), + answer: 'r' + (i + 1), + tfAnswer: 'Adevarat', + choices: '', + hint: '', + letter: String.fromCharCode(65 + (i % 26)), + style: styles[i % 5] + })) + }; + } + + test('campanie E2E — intro → camere cu stiluri rotite → final cu stele+litere+cuvant corect @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta gameCampaign de la integrator — ster skip dupa'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(5); + const html = await page.evaluate((c) => { + if (typeof gameCampaign !== 'function') throw new Error('gameCampaign not yet'); + return gameHTML(c); + }, cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Intro campanie + await gp.locator('button:text("Incepe aventura")').click(); + + // Parcurge 5 camere, fiecare in stilul ei rotat + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + for (let i = 0; i < 5; i++) { + const answer = 'r' + (i + 1); + const style = styles[i % 5]; + + // Asteapta roomReady → camera montata + await gp.waitForFunction( + () => document.querySelector('[data-room-ready="true"]') !== null || + document.querySelector('iframe.room-ready') !== null, + { timeout: 8000 } + ); + + // Raspunde in camera curenta (prin iframe) + const iframeLocator = gp.frameLocator('iframe[data-room]').last(); + + if (style === 'classic') { + await iframeLocator.locator('#btnStart').click(); + await iframeLocator.locator('#answers input').fill(answer); + await iframeLocator.locator('#answers button:text("Verifica")').click(); + } else if (style === 'terminal') { + await gp.waitForFunction(() => true); // terminal necesita interactiune specifica + await iframeLocator.locator('#cmd').fill(answer); + await iframeLocator.locator('#cmd').press('Enter'); + } + // Alte stiluri: similar + + // Asteapta nextRoom apelat → apare coridorul + if (i < 4) { + await gp.waitForSelector('button:text("Deschide usa")', { timeout: 10000 }); + // Verifica stele si litera in coridor + await expect(gp.locator('[data-stars], .stars, .stele')).toBeVisible({ timeout: 3000 }); + // Click "Deschide usa" + await gp.locator('button:text("Deschide usa")').click(); + } + } + + // Ecranul final + await expect(gp.locator('#fOverlay, [data-final]')).toBeVisible({ timeout: 10000 }); + // Cuvantul magic = ABCDE (primele 5 litere) + const finalText = await gp.content(); + expect(finalText).toMatch(/A.*B.*C.*D.*E/); + + expect(gameErrors, 'Game errors: ' + gameErrors.join('\n')).toHaveLength(0); + expect(errors, 'Builder errors: ' + errors.join('\n')).toHaveLength(0); + }); + + test('resume — reload mid-campanie returneaza la coridor (safeStore D3+D11) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta safeStore+hash din T7 de la integrator'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(3); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Start si completa camera 1 (progreseaza dincolo de ea) + await gp.locator('button:text("Incepe aventura")').click(); + // ... rezolva camera 1 ... + await gp.waitForSelector('button:text("Deschide usa")', { timeout: 12000 }); + + // Reload mid-campanie (inainte de camera 2) + await gp.reload(); + + // Trebuie sa se reia la coridor, NU la intro + await expect(gp.locator('button:text("Deschide usa")')).toBeVisible({ timeout: 5000 }); + // Intro-ul NU trebuie sa fie vizibil + const hasIntro = await gp.locator('button:text("Incepe aventura")').isVisible(); + expect(hasIntro).toBe(false); + + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('camera moarta — template stricat → skip 0 stele + cod eroare vizibil @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta roomReady/roomError+timeout T3 de la integrator'); + // Contractul: un template de camera care arunca eroare inainte de roomReady + // → coridorul afiseaza "usa intepenita" cu: + // - 0 stele + // - cod eroare "stil·idx" monospace mic + // - buton "Sari la camera urmatoare" + // + // La export final: litera camerei sarite = dala goala cu lakat. + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(3); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + await gp.locator('button:text("Incepe aventura")').click(); + + // Injecteaza o eroare in prima camera dupa montare + await gp.waitForFunction( + () => document.querySelector('iframe[data-room]') !== null, + { timeout: 6000 } + ); + await gp.evaluate(() => { + const iframe = document.querySelector('iframe[data-room]'); + if (iframe?.contentWindow) { + iframe.contentWindow.dispatchEvent(new ErrorEvent('error', { + message: 'Template stricat', error: new Error('Template stricat') + })); + } + }); + + // Coridorul trebuie sa arate "usa intepenita" cu 0 stele si cod eroare + await expect(gp.locator(':text("intepenita"), :text("Sari")')).toBeVisible({ timeout: 8000 }); + const corridorText = await gp.content(); + expect(corridorText).toMatch(/classic[·.]0|terminal[·.]0|arcade[·.]0/i); // cod eroare + + // Stars = 0 pentru camera sarita + await expect(gp.locator(':text("0 ★"), :text("0/")').first()).toBeVisible(); + + expect(gameErrors.filter(e => !e.includes('Template stricat')), gameErrors.join('\n')) + .toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('eroare post-ready — acelasi skip ca camera moarta @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta roomError semantic ORICAND T3+D5 de la integrator'); + // Camera apeleaza roomReady() dar arunca o eroare async mai tarziu + // → acelasi overlay "usa intepenita" ca si camera moarta + // Specificat in plan: "roomError are semantica ORICAND — si post-ready" + }); + + test('dublu-click "Deschide usa" — idempotent (fara stare corupta) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta guard idempotenta T4+D5 de la integrator'); + // Doua click-uri rapide pe "Deschide usa" NU trebuie: + // - sa monteze doua camere + // - sa corupte idx-ul activ + // - sa dupleze apelurile nextRoom + // Specificat in plan: butonul dezactivat dupa primul click; nextRoom ignorat pt idx deja incheiat + }); + + test('intrebare cu $/$& in text — camera se monteaza corect (D1 replace-functie) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta replace(TOKEN, () => json) D1 de la integrator'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + // Puzzle cu $ si & in intrebare (ar corupe JSON-ul daca replace e string) + const cfg = { + ...campaignCfg(1), + puzzles: [{ + title: 'Dollar test', type: 'free', + question: 'Costa $10.00 & $& mai mult?', + answer: 'da', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X', + style: 'classic' + }] + }; + + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Asteapta montarea camerei (roomReady) + await gp.waitForFunction( + () => document.querySelector('iframe[data-room]')?.contentDocument != null, + { timeout: 5000 } + ); + + // CFG.puzzles[0].question trebuie sa fie intact + const question = await gp.evaluate(() => { + const iframe = document.querySelector('iframe[data-room]'); + return iframe?.contentWindow?.CFG?.puzzles?.[0]?.question ?? ''; + }); + expect(question).toContain('$10.00'); + expect(question).toContain('$&'); + + expect(gameErrors, gameErrors.join('\n')).toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('campanie 8+ camere — beep functional pana la final @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta gameCampaign + parent.beep D2 de la integrator'); + // Cu 8 camere (peste limita de 5 stiluri), beep() trebuie sa functioneze + // in toate camerele fara sa depaseasca limita de AudioContext a browser-ului. + // Specificat in plan: audio detinut de parinte; camerele apeleaza parent.beep(ok). + }); + + test('campanie: 320x568 fara overflow orizontal (chrome 40px + calc(100vh-chrome)) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta chrome bar + buget vertical T6+TD4 de la integrator'); + await page.setViewportSize({ width: 320, height: 568 }); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(2); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + await gp.setViewportSize({ width: 320, height: 568 }); + const gpErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + await gp.waitForTimeout(500); + + // Zero scroll orizontal (§Design pct. 11) + const overflow = await gp.evaluate( + () => document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1 + ); + expect(overflow, 'Overflow orizontal la 320x568 in campanie').toBe(false); + + // Chrome 40px sub 600px (§Design pct. 11) + const chromeHeight = await gp.evaluate(() => { + const chrome = document.querySelector('[data-chrome], .campaign-chrome, #chrome'); + return chrome ? chrome.getBoundingClientRect().height : null; + }); + if (chromeHeight !== null) { + expect(chromeHeight, 'Chrome > 40px la 320px').toBeLessThanOrEqual(40); + } + + expect(gpErrors, gpErrors.join('\n')).toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + +});