Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Agent
16cd521430 Known improvements: dedup HUD letters-bar + validare stil import
Pasa de igiena (T8/T5/D8). Majoritatea erau deja livrate (persist guard D12,
esc/letter D13, validare 0 puzzle). Reale ramase:

- updateHud arcade/point NU erau identice (arcade: vieti/dusmani/bombe/raza;
  point: obiecte). Partea duplicata reala (scor + bara litere castigate) extrasa
  in SNIP.hudJs -> hudLetters(isSolved); isSolved(j) difera per motor
  (doorsSolved vs solvedFlags). Injectat in ambele; demo-uri regenerate.
- Stil top-level invalid la import: TOP_STYLES guard -> fallback classic + alert;
  idem la load din storage corupt. Test nou (smoke 28/28).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:36:41 +00:00
Claude Agent
bfe9be28d7 D7: migreaza gameClassic pe libJS (5/5 motoare uniforme)
Classic era ultimul motor bespoke: CFG/norm/beep/confetti/star-logic/
finalWord/payload campanie inline. Acum injecteaza libJS(cfg) si foloseste
checkAnswer/starsFor/finalWord/choiceOpts/campaignDone/roomReady ca celelalte
4 motoare. UI-ul bespoke (card sStart/sGame/sFinal) ramane intentionat -
fortarea modalului/overlay-ului SNIP ar fi regresie vizuala pe demo-ul implicit
(aceeasi decizie ca terminalul cu finale CRT).

- payload parent.nextRoom traieste o singura data in libJS.campaignDone()
- net -70 linii duplicate
- exemplu-clasic.html regenerat; celelalte demo-uri byte-identice
- smoke 27/27 (regresie clasic standalone + campanie E2E cu clasic ca odaie)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:30:40 +00:00
8 changed files with 167 additions and 168 deletions

View File

@@ -20,9 +20,9 @@ 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ă: 27/27 npx playwright test tests/smoke.mjs # suita completă: 28/28
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 15 npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 12 npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14
``` ```
## Durable Rules (repo-wide) ## Durable Rules (repo-wide)

View File

@@ -18,7 +18,7 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2
- [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26. - [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26.
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit). **PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
Rămas din Etapa 2: D7 (migrare classic pe libJS+SNIP) + muzică timer (T10) + Adventure Mode v0. Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 LIVRAT — vezi §dedicată mai jos.)
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT ### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`): Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
@@ -134,11 +134,35 @@ Acum trăiește o singură dată în `libJS.campaignDone()` (lângă `roomReady`
Verificat: smoke 25/25 (terminal standalone test 2 + camere terminal în campanie E2E test 1). Verificat: smoke 25/25 (terminal standalone test 2 + camere terminal în campanie E2E test 1).
Referință: planul §Etapa 2 pct. 1; D7. Referință: planul §Etapa 2 pct. 1; D7.
### [ ] D7 rămas: migrarea `gameClassic` pe `libJS+SNIP` ### [x] D7: migrarea `gameClassic` pe `libJS` — LIVRAT (2026-06-13)
- Classic (escape-builder.html:451) e singurul motor bespoke: propriul `totalStars`, `beep`, Classic era ultimul motor bespoke (propriul `CFG`/`norm`/`beep`/`confetti`, star-logic inline,
inline `finalWord` (dublat de 2 ori în `next()`), propriul modal final `#sFinal`. `finalWord` dublat, payload `parent.nextRoom` inline). Acum injectează `libJS(cfg)` și folosește
- După migrare: classic folosește `libJS.campaignDone()` + `SNIP` ca celelalte 4 → 5/5 uniform. `checkAnswer`/`starsFor`/`finalWord`/`choiceOpts`/`campaignDone`/`roomReady`/`onerror` din libJS
- Necesită regresie manuală pe classic standalone (e demo-ul implicit, cel mai vizibil). ca celelalte 4 motoare → **5/5 uniform** pe contractul de finalizare.
- **Decizie de design (păstrată din unificarea `campaignDone`):** UI-ul bespoke al classicului
(card `sStart`/`sGame`/`sFinal`) RĂMÂNE. NU am forțat modalul/overlay-ul `SNIP.modal`/`SNIP.final`
— classic e quiz inline (nu deschide puzzle-uri dintr-o hartă), iar `#sFinal` e on-theme; forțarea
SNIP-ului ar fi regresie vizuală pe demo-ul implicit (cel mai vizibil). Aceeași logică ca terminalul
cu finale CRT. „Migrare pe libJS+SNIP" din formularea inițială = în practică migrare pe **libJS**;
SNIP-ul modal nu se aplică unui motor non-modal (vezi și terminalul, care nu folosește SNIP.modal).
- net 70 linii duplicate; `campaignDone()` rămâne singura sursă a payload-ului `nextRoom`.
- `exemplu-clasic.html` regenerat (celelalte demo-uri byte-identice → classic a fost singura atingere).
- Verificat: smoke 28/28 (regresie classic standalone test #1 + campanie E2E cu classic ca odaie test #14).
Commit: `bfe9be2`.
### [x] Known improvements — pasă de igienă (2026-06-13)
Auditate faptic. Cele mai multe erau **deja livrate** în PR-uri anterioare:
- `persist()` try/catch → DEJA (escape-builder.html:211, D12).
- `esc(L)` la point SVG → DEJA rezolvat la SURSĂ: `cleanState()` normalizează `letter` la 1 caracter
alfanumeric (linia ~407, D13) → un `<` nu mai poate ajunge în scenă.
- Validare 0 puzzle-uri → DEJA: export blocat cu alert + preview cu mesaj ghidant (🚪).
- `updateHud` „identic" arcade/point → NU era identic (arcade arată vieți/dușmani/bombe/rază; point
arată obiecte). REAL duplicat: scor + bara de litere câștigate → extras în `SNIP.hudJs`
(`hudLetters(isSolved)`, `isSolved(j)` diferă per motor: doorsSolved vs solvedFlags). Injectat în
ambele; demo-uri arcade+point regenerate.
- **Stil top-level invalid la import** (singurul gap rămas, T5/D8) → `TOP_STYLES` guard: fallback la
`classic` + alert „Stil necunoscut …" la import; idem la load din storage corupt. Test nou smoke
(`stil top-level necunoscut → fallback classic + avertisment`).
### [x] Audit a11y motoare existente — LIVRAT (sub harness Playwright) ### [x] Audit a11y motoare existente — LIVRAT (sub harness Playwright)
Auditat faptic (măsurat, nu presupus). Ce era DEJA OK (din restyle S3, nemodificat): Auditat faptic (măsurat, nu presupus). Ce era DEJA OK (din restyle S3, nemodificat):
@@ -184,8 +208,6 @@ Referință: §Design pct. 13 (TD5, PR2); D19 din plan.
## Known improvements (oricând) ## 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). Toate cele listate inițial au fost rezolvate — vezi „[x] Known improvements — pasă de igienă" mai sus
- **`persist()` fără try/catch**: builder-ul poate crăpa pe storage plin → guard (D12, T8). (updateHud dedup în `SNIP.hudJs`, persist guard D12, esc/letter D13, validare 0 puzzle, stil invalid la
- **`esc(L)` la inserția innerHTML din point** (:1274): un `<` în câmpul `letter` strică scena SVG (D13, T8). import T5/D8). Adaugă aici lucruri noi pe măsură ce apar.
- **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).

View File

@@ -166,6 +166,8 @@ const STORAGE_KEY = 'escape-builder-v1';
const CAMPAIGN_ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point']; 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 CAMPAIGN_STYLE_NAMES = { classic: 'Clasic', terminal: 'Terminal Retro', arcade: 'Arcade Pixel', chat: 'Story Chat', point: 'Point-and-Click' };
/* Stiluri top-level valide (gameHTML rutează pe ele); orice altceva → fallback classic (T5, D8) */
const TOP_STYLES = ['classic', 'terminal', 'arcade', 'chat', 'point', 'campaign'];
const defaultState = () => ({ const defaultState = () => ({
title: 'Comoara ascunsa', title: 'Comoara ascunsa',
@@ -202,6 +204,7 @@ function normalizePuzzle(p) {
const blankPuzzle = () => normalizePuzzle({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '', style: '' }); const blankPuzzle = () => normalizePuzzle({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '', style: '' });
let state = Object.assign(defaultState(), load() || {}); let state = Object.assign(defaultState(), load() || {});
if (!TOP_STYLES.includes(state.style)) state.style = 'classic'; /* storage corupt → fallback */
if (Array.isArray(state.puzzles)) state.puzzles = state.puzzles.map(normalizePuzzle); if (Array.isArray(state.puzzles)) state.puzzles = state.puzzles.map(normalizePuzzle);
function load() { function load() {
@@ -383,8 +386,11 @@ $('#fileLoad').addEventListener('change', e => {
const data = JSON.parse(txt); const data = JSON.parse(txt);
if (!Array.isArray(data.puzzles)) throw new Error('format'); if (!Array.isArray(data.puzzles)) throw new Error('format');
state = Object.assign(defaultState(), data); state = Object.assign(defaultState(), data);
let styleWarn = '';
if (!TOP_STYLES.includes(state.style)) { styleWarn = ' Stil necunoscut „' + state.style + '" — am rotit la „Clasic".'; state.style = 'classic'; }
state.puzzles = state.puzzles.map(normalizePuzzle); state.puzzles = state.puzzles.map(normalizePuzzle);
renderGlobals(); renderPuzzles(); onChange(); renderGlobals(); renderPuzzles(); onChange();
if (styleWarn) alert('Proiect incarcat.' + styleWarn);
} catch (err) { } catch (err) {
alert('Fisierul nu este un proiect valid de escape room.'); alert('Fisierul nu este un proiect valid de escape room.');
} }
@@ -449,8 +455,8 @@ function gameHTML(cfg) {
} }
function gameClassic(cfg) { function gameClassic(cfg) {
/* cfg === '__TEMPLATE__' → emit sentinel __CFG__ în loc de JSON (D1) */ /* CFG + helperii partajați (norm/beep/confetti/checkAnswer/starsFor/finalWord/
const json = (cfg === '__TEMPLATE__') ? '__CFG__' : JSON.stringify(cfg).replace(/</g, '\\u003c'); choiceOpts/campaignDone/roomReady/onerror) vin din libJS(cfg) injectat în <script> (D7) */
return `<!doctype html> return `<!doctype html>
<html lang="ro"> <html lang="ro">
<head> <head>
@@ -556,17 +562,12 @@ function gameClassic(cfg) {
</div> </div>
<script> <script>
var CFG = ${json}; ${libJS(cfg)}
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)'); document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
var idx = 0, totalStars = 0, attempts = 0, hintUsed = false, won = []; var idx = 0, attempts = 0, hintUsed = false, won = [];
/* CFG, totalStars, el, norm, beep, confetti, starsFor, finalWord, checkAnswer,
function el(id) { return document.getElementById(id); } choiceOpts, campaignDone, roomReady, window.onerror — toate din libJS (D7) */
function norm(s) {
return String(s).trim().toLowerCase().normalize('NFD')
.replace(/[\\u0300-\\u036f]/g, '').replace(/\\s+/g, ' ').replace(/,/g, '.');
}
function show(id) { function show(id) {
var scr = document.querySelectorAll('.screen'); var scr = document.querySelectorAll('.screen');
for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on'); for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on');
@@ -617,7 +618,7 @@ function renderPuzzle() {
inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...'; inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...';
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.textContent = 'Verifica'; btn.textContent = 'Verifica';
btn.onclick = function () { check(inp.value, p.answer); }; btn.onclick = function () { check(p, inp.value); };
inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); }; inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
box.appendChild(inp); box.appendChild(btn); box.appendChild(inp); box.appendChild(btn);
setTimeout(function () { inp.focus(); }, 50); setTimeout(function () { inp.focus(); }, 50);
@@ -625,20 +626,17 @@ function renderPuzzle() {
['Adevarat', 'Fals'].forEach(function (v) { ['Adevarat', 'Fals'].forEach(function (v) {
var b = document.createElement('button'); var b = document.createElement('button');
b.className = 'opt'; b.textContent = v; b.className = 'opt'; b.textContent = v;
b.onclick = function () { check(v, p.tfAnswer); }; b.onclick = function () { check(p, v); };
box.appendChild(b); box.appendChild(b);
}); });
} else { } else {
var correct = ''; var opts = choiceOpts(p);
var opts = (p.choices || '').split('\\n').map(function (l) { return l.trim(); }).filter(Boolean); opts.forEach(function (o) {
opts.forEach(function (o) { if (o.charAt(0) === '*') correct = o.slice(1).trim(); }); var b = document.createElement('button');
opts.map(function (o) { return o.charAt(0) === '*' ? o.slice(1).trim() : o; }) b.className = 'opt'; b.textContent = o;
.forEach(function (o) { b.onclick = function () { check(p, o); };
var b = document.createElement('button'); box.appendChild(b);
b.className = 'opt'; b.textContent = o; });
b.onclick = function () { check(o, correct); };
box.appendChild(b);
});
if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)'; if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)';
} }
} }
@@ -648,9 +646,9 @@ el('btnHint').onclick = function () {
el('hinttext').style.display = 'block'; el('hinttext').style.display = 'block';
}; };
function check(given, expected) { function check(p, given) {
if (norm(given) === norm(expected) && norm(given) !== '') { if (checkAnswer(p, given)) {
var stars = (hintUsed || attempts >= 2) ? 1 : (attempts === 1 ? 2 : 3); var stars = starsFor(attempts, hintUsed);
totalStars += stars; totalStars += stars;
won[idx] = true; won[idx] = true;
beep(true); beep(true);
@@ -676,19 +674,11 @@ function check(given, expected) {
function next() { function next() {
idx++; idx++;
if (idx < CFG.puzzles.length) { renderPuzzle(); return; } if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
if(CFG._campaign){ if(CFG._campaign){ campaignDone(); return; }
var L = ''; for(var ci=0;ci<CFG.puzzles.length;ci++){var lc=(CFG.puzzles[ci].letter||'').trim();if(lc)L+=lc.toUpperCase();}
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L.charAt(0)}); }catch(e){}
return;
}
show('sFinal'); show('sFinal');
var max = CFG.puzzles.length * 3; var max = CFG.puzzles.length * 3;
el('finalStars').textContent = totalStars + ' / ' + max + ' \\u2605'; el('finalStars').textContent = totalStars + ' / ' + max + ' \\u2605';
var word = ''; var word = finalWord();
for (var i = 0; i < CFG.puzzles.length; i++) {
var L = (CFG.puzzles[i].letter || '').trim();
if (L) word += L.toUpperCase();
}
var bw = el('bigword'); var bw = el('bigword');
bw.innerHTML = ''; bw.innerHTML = '';
for (var j = 0; j < word.length; j++) { for (var j = 0; j < word.length; j++) {
@@ -702,37 +692,7 @@ function next() {
confetti(); confetti();
} }
function confetti() { roomReady(); /* beep/confetti/onerror/roomReady din libJS (D7) */
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 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 freqs = ok ? [523, 784] : [196];
freqs.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) {}
}
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} }
<\/script> <\/script>
</body> </body>
</html>`; </html>`;
@@ -876,6 +836,19 @@ SNIP.finalJs = `function showFinal(){
} }
el('fAgain').onclick = function(){ location.reload(); };`; el('fAgain').onclick = function(){ location.reload(); };`;
/* HUD partajat (arcade + point): scor + bara de litere câștigate. isSolved(j)→bool
diferă per motor (doorsSolved vs solvedFlags) → injectat ca funcție (T8). */
SNIP.hudJs = `function hudLetters(isSolved){
el('hudStars').textContent = totalStars + ' \\u2605';
var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < CFG.puzzles.length; j++){
var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue;
var s = document.createElement('span');
if (isSolved(j)){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?';
hb.appendChild(s);
}
}`;
/* ---------- motor: terminal retro ---------- */ /* ---------- motor: terminal retro ---------- */
function gameTerminal(cfg) { function gameTerminal(cfg) {
@@ -1157,9 +1130,7 @@ function updateHud(){
var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0; var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0;
var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0; var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0;
el('hudStep').textContent = '\\u2764\\ufe0f ' + lives + ' \\ud83d\\udc7e ' + alive + ' \\ud83d\\udd13 ' + solved + '/' + N + ' \\ud83d\\udca3' + (maxBombs || 1) + ' \\ud83d\\udd25' + (bombRange || 1); el('hudStep').textContent = '\\u2764\\ufe0f ' + lives + ' \\ud83d\\udc7e ' + alive + ' \\ud83d\\udd13 ' + solved + '/' + N + ' \\ud83d\\udca3' + (maxBombs || 1) + ' \\ud83d\\udd25' + (bombRange || 1);
el('hudStars').textContent = totalStars + ' \\u2605'; hudLetters(function(j){ return puzzleProgress && puzzleProgress.doorsSolved[j]; });
var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < N; j++){ var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue; var s = document.createElement('span'); if (puzzleProgress && puzzleProgress.doorsSolved[j]){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?'; hb.appendChild(s); }
} }
/* ----- Bombe + explozii în lanț ----- */ /* ----- Bombe + explozii în lanț ----- */
@@ -1326,6 +1297,7 @@ window.__game = {
getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; } getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; }
}; };
${SNIP.hudJs}
${SNIP.modalJs} ${SNIP.modalJs}
${SNIP.finalJs} ${SNIP.finalJs}
init(); init();
@@ -1573,17 +1545,9 @@ function onSolved(i){
function updateHud(){ function updateHud(){
el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N; el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N;
el('hudStars').textContent = totalStars + ' \\u2605'; hudLetters(function(j){ return solvedFlags[j]; });
var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < N; j++) {
var L = (CFG.puzzles[j].letter || '').trim();
if (!L) continue;
var s = document.createElement('span');
if (solvedFlags[j]) { s.textContent = L.toUpperCase(); s.className = 'won'; }
else s.textContent = '?';
hb.appendChild(s);
}
} }
${SNIP.hudJs}
${SNIP.modalJs} ${SNIP.modalJs}
${SNIP.finalJs} ${SNIP.finalJs}
updateHud(); updateHud();

View File

@@ -206,9 +206,7 @@ function updateHud(){
var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0; var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0;
var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0; var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0;
el('hudStep').textContent = '\u2764\ufe0f ' + lives + ' \ud83d\udc7e ' + alive + ' \ud83d\udd13 ' + solved + '/' + N + ' \ud83d\udca3' + (maxBombs || 1) + ' \ud83d\udd25' + (bombRange || 1); el('hudStep').textContent = '\u2764\ufe0f ' + lives + ' \ud83d\udc7e ' + alive + ' \ud83d\udd13 ' + solved + '/' + N + ' \ud83d\udca3' + (maxBombs || 1) + ' \ud83d\udd25' + (bombRange || 1);
el('hudStars').textContent = totalStars + ' \u2605'; hudLetters(function(j){ return puzzleProgress && puzzleProgress.doorsSolved[j]; });
var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < N; j++){ var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue; var s = document.createElement('span'); if (puzzleProgress && puzzleProgress.doorsSolved[j]){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?'; hb.appendChild(s); }
} }
/* ----- Bombe + explozii în lanț ----- */ /* ----- Bombe + explozii în lanț ----- */
@@ -375,6 +373,16 @@ window.__game = {
getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; } getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; }
}; };
function hudLetters(isSolved){
el('hudStars').textContent = totalStars + ' \u2605';
var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < CFG.puzzles.length; j++){
var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue;
var s = document.createElement('span');
if (isSolved(j)){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?';
hb.appendChild(s);
}
}
var mIdx = -1, mAtt = 0, mHint = false, mCb = null; var mIdx = -1, mAtt = 0, mHint = false, mCb = null;
el('mHintBtn').onclick = function(){ mHint = true; el('mHintText').style.display = 'block'; }; el('mHintBtn').onclick = function(){ mHint = true; el('mHintText').style.display = 'block'; };
el('mClose').onclick = function(){ el('mOverlay').style.display = 'none'; }; el('mClose').onclick = function(){ el('mOverlay').style.display = 'none'; };

View File

@@ -105,15 +105,32 @@
<script> <script>
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"classic"}; var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"classic"};
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9'); document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
var totalStars = 0;
function el(id){ return document.getElementById(id); }
function norm(s){ return String(s).trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').replace(/,/g, '.'); }
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 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 checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
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 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
(î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){} } }
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
if(CFG._campaign){
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
var _cs = document.createElement('style');
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
(document.head || document.documentElement).appendChild(_cs);
}
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)'); document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
var idx = 0, totalStars = 0, attempts = 0, hintUsed = false, won = []; var idx = 0, attempts = 0, hintUsed = false, won = [];
/* CFG, totalStars, el, norm, beep, confetti, starsFor, finalWord, checkAnswer,
function el(id) { return document.getElementById(id); } choiceOpts, campaignDone, roomReady, window.onerror — toate din libJS (D7) */
function norm(s) {
return String(s).trim().toLowerCase().normalize('NFD')
.replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').replace(/,/g, '.');
}
function show(id) { function show(id) {
var scr = document.querySelectorAll('.screen'); var scr = document.querySelectorAll('.screen');
for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on'); for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on');
@@ -164,7 +181,7 @@ function renderPuzzle() {
inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...'; inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...';
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.textContent = 'Verifica'; btn.textContent = 'Verifica';
btn.onclick = function () { check(inp.value, p.answer); }; btn.onclick = function () { check(p, inp.value); };
inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); }; inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
box.appendChild(inp); box.appendChild(btn); box.appendChild(inp); box.appendChild(btn);
setTimeout(function () { inp.focus(); }, 50); setTimeout(function () { inp.focus(); }, 50);
@@ -172,20 +189,17 @@ function renderPuzzle() {
['Adevarat', 'Fals'].forEach(function (v) { ['Adevarat', 'Fals'].forEach(function (v) {
var b = document.createElement('button'); var b = document.createElement('button');
b.className = 'opt'; b.textContent = v; b.className = 'opt'; b.textContent = v;
b.onclick = function () { check(v, p.tfAnswer); }; b.onclick = function () { check(p, v); };
box.appendChild(b); box.appendChild(b);
}); });
} else { } else {
var correct = ''; var opts = choiceOpts(p);
var opts = (p.choices || '').split('\n').map(function (l) { return l.trim(); }).filter(Boolean); opts.forEach(function (o) {
opts.forEach(function (o) { if (o.charAt(0) === '*') correct = o.slice(1).trim(); }); var b = document.createElement('button');
opts.map(function (o) { return o.charAt(0) === '*' ? o.slice(1).trim() : o; }) b.className = 'opt'; b.textContent = o;
.forEach(function (o) { b.onclick = function () { check(p, o); };
var b = document.createElement('button'); box.appendChild(b);
b.className = 'opt'; b.textContent = o; });
b.onclick = function () { check(o, correct); };
box.appendChild(b);
});
if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)'; if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)';
} }
} }
@@ -195,9 +209,9 @@ el('btnHint').onclick = function () {
el('hinttext').style.display = 'block'; el('hinttext').style.display = 'block';
}; };
function check(given, expected) { function check(p, given) {
if (norm(given) === norm(expected) && norm(given) !== '') { if (checkAnswer(p, given)) {
var stars = (hintUsed || attempts >= 2) ? 1 : (attempts === 1 ? 2 : 3); var stars = starsFor(attempts, hintUsed);
totalStars += stars; totalStars += stars;
won[idx] = true; won[idx] = true;
beep(true); beep(true);
@@ -223,19 +237,11 @@ function check(given, expected) {
function next() { function next() {
idx++; idx++;
if (idx < CFG.puzzles.length) { renderPuzzle(); return; } if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
if(CFG._campaign){ if(CFG._campaign){ campaignDone(); return; }
var L = ''; for(var ci=0;ci<CFG.puzzles.length;ci++){var lc=(CFG.puzzles[ci].letter||'').trim();if(lc)L+=lc.toUpperCase();}
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L.charAt(0)}); }catch(e){}
return;
}
show('sFinal'); show('sFinal');
var max = CFG.puzzles.length * 3; var max = CFG.puzzles.length * 3;
el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605'; el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605';
var word = ''; var word = finalWord();
for (var i = 0; i < CFG.puzzles.length; i++) {
var L = (CFG.puzzles[i].letter || '').trim();
if (L) word += L.toUpperCase();
}
var bw = el('bigword'); var bw = el('bigword');
bw.innerHTML = ''; bw.innerHTML = '';
for (var j = 0; j < word.length; j++) { for (var j = 0; j < word.length; j++) {
@@ -249,37 +255,7 @@ function next() {
confetti(); confetti();
} }
function confetti() { roomReady(); /* beep/confetti/onerror/roomReady din libJS (D7) */
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 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 freqs = ok ? [523, 784] : [196];
freqs.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) {}
}
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} }
</script> </script>
</body> </body>
</html> </html>

View File

@@ -155,14 +155,15 @@ function onSolved(i){
function updateHud(){ function updateHud(){
el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N; el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N;
hudLetters(function(j){ return solvedFlags[j]; });
}
function hudLetters(isSolved){
el('hudStars').textContent = totalStars + ' \u2605'; el('hudStars').textContent = totalStars + ' \u2605';
var hb = el('hudLetters'); hb.innerHTML = ''; var hb = el('hudLetters'); hb.innerHTML = '';
for (var j = 0; j < N; j++) { for (var j = 0; j < CFG.puzzles.length; j++){
var L = (CFG.puzzles[j].letter || '').trim(); var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue;
if (!L) continue;
var s = document.createElement('span'); var s = document.createElement('span');
if (solvedFlags[j]) { s.textContent = L.toUpperCase(); s.className = 'won'; } if (isSolved(j)){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?';
else s.textContent = '?';
hb.appendChild(s); hb.appendChild(s);
} }
} }

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 (~27 teste). - `tests/smoke.mjs` — unicul fișier de teste (~28 teste).
- `playwright.config.mjs` (la root, **gitignored**) — config dev. - `playwright.config.mjs` (la root, **gitignored**) — config dev.
## Local Contracts ## Local Contracts
@@ -20,7 +20,7 @@ până la ecranul final, fără erori de consolă.
bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` (12 — intro→hartă→camere→final, resume, bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` (12 — intro→hartă→camere→final, resume,
cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10,
a11y tap/aria/reduced-motion, navigare overworld). a11y tap/aria/reduced-motion, navigare overworld).
- **Status țintă: 27/27 PASS.** - **Status țintă: 28/28 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ă
@@ -29,7 +29,7 @@ până la ecranul final, fără erori de consolă.
## Verification ## Verification
```bash ```bash
npx playwright test tests/smoke.mjs # 27/27 npx playwright test tests/smoke.mjs # 28/28
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
``` ```

View File

@@ -404,6 +404,34 @@ test.describe('Edge cases @regresie', () => {
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
}); });
test('builder: JSON cu stil top-level necunoscut → fallback classic + avertisment (T5/D8)', async ({ page }) => {
const errors = trackErrors(page);
let warned = false;
page.on('dialog', d => { if (/stil necunoscut/i.test(d.message())) warned = true; d.accept(); });
await page.goto(fileURL('escape-builder.html'));
const tmpPath = join(ROOT, 'tests', '.tmp-invalid-style.json');
writeFileSync(tmpPath, JSON.stringify({
title: 'Test stil', style: 'banana', color: '#6d28d9', charName: 'X',
story: 'S', finalMessage: 'F',
puzzles: [{ title: 'P1', type: 'free', question: 'Q?', answer: 'A', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X' }]
}));
try {
await page.locator('#fileLoad').setInputFiles(tmpPath);
await page.waitForTimeout(600);
} finally {
unlinkSync(tmpPath);
}
// Fallback la classic + avertisment + builder functional
await expect(page.locator('#gStyle')).toHaveValue('classic');
expect(warned, 'asteptam un alert despre stilul necunoscut').toBe(true);
await expect(page.locator('#addPuzzle')).toBeVisible();
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
});
}); });
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════