Adventure Mode v0 (E0-E6): ramificare per-raspuns in campanie

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 21:08:21 +00:00
parent 023df382f0
commit 8fc8f8040f
5 changed files with 383 additions and 34 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ă: 31/31 npx playwright test tests/smoke.mjs # suita completă: 35/35
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16 npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 17 npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21
``` ```
## Durable Rules (repo-wide) ## Durable Rules (repo-wide)

View File

@@ -215,14 +215,33 @@ Referință: §Design pct. 13 (TD5, PR2); D19 din plan.
--- ---
## Iterația 2 — Adventure Mode v0 ### [x] Adventure Mode v0 — LIVRAT (2026-06-13)
*(decizie office-hours: fundația contractului de azi e infrastructura directă)* Opt-in flag `adventure` (default off) → campanie cu ramificare per-răspuns. Zero regresie non-adventure.
- Contract de montare (`nextRoom`, `roomReady`, `roomError`) se refolosesc as-is. **E0**`adventure: false` în `defaultState()`; checkbox `data-gb="adventure"` în builder (lângă voice/music);
- Motoarele noi (orice stil) implementează aceleași 3 puncte + `parent.beep`. `var ADVENTURE = !!MASTER.adventure` în orchestrator.
- `gameCampaign` se extinde cu ramificare: `if (answer === 'left') nextRoom({dir: 'left'})`.
- Builder UI: adaugă câmpul "ramificare" per puzzle; drag & drop între camere. **E1**`_lastGiven` în libJS; `checkAnswer` setează `_lastGiven` pe succes; `campaignDone()` calculează
- Referință: design doc §NOT in scope "Adventure Mode v0". cheia branch (`'*'` free, text pentru tf, index string pentru choice) și o trimite în payload `nextRoom`.
**E2**`resolveBranch(idx, key)`: non-adventure→liniar; adventure→`p.branch[key]` (fallback `branch['*']`,
apoi liniar idx+1); 'end'/out-of-range→'end'. `nextRoom` pe ramura ADVENTURE: 'end'→`owExitUnlocked=true`+
`showOverworld` cu exit deblocat; număr→`owUnlocked[dest]=true`+`owTargetIdx=dest`+`showOverworld(dest)`.
**E3**`owCheckEnter`: blocat dacă `ADVENTURE && !owUnlocked[d.idx]`; exit folosește `owExitUnlocked` în
loc de `owAllDone()`. `owRefreshDoors`: stilul `.locked` (dim+🔒) pentru ușile nedeblcate; hint/exit
folosesc `owExitUnlocked`. `window.__ow.state`: adaugă `owUnlocked`/`owExitUnlocked`.
**E4**`saveProgress`: adaugă `doneList`, `owUnlocked`, `owExitUnlocked`, `target`. `tryResume`: pe
ADVENTURE reconstruiește din `doneList` (non-contiguu), nu bucla liniară `0..saved.idx`.
**E5**`buildDiploma`: camerele `ADVENTURE && !roomDone[i]` → „neexplorată" (nu ☆☆☆ înșelător).
**E6** — Builder UI: `normalizePuzzle` garantează `p.branch={}`; `cleanState` clampa țintele + strip
`branch` când `!adventure`; `puzzleCard` afișează dropdown-uri ramificare per-puzzle (free=1, tf=2,
choice=1/opțiune); `data-fb`/`data-bkey` handler în input listener; `adventure` change → `renderPuzzles()`.
Verificat: smoke 35/35 (4 teste noi: branch-jump, resume non-contiguu, regression non-adventure, tf branch).
## Iterația 3 — Joc-în-URL + QR ## Iterația 3 — Joc-în-URL + QR
*(depinde de măsurarea dimensiunii JSON comprimate)* *(depinde de măsurarea dimensiunii JSON comprimate)*

View File

@@ -138,6 +138,7 @@
<textarea id="gFinal" data-g="finalMessage" rows="2"></textarea> <textarea id="gFinal" data-g="finalMessage" rows="2"></textarea>
<label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala &mdash; citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label> <label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala &mdash; citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label>
<label class="ck"><input type="checkbox" id="gMusic" data-gb="music"> Muzica de fundal &mdash; arpegiu calm care accelereaza sub 1 minut (doar in Campanie; buton de oprire in bara)</label> <label class="ck"><input type="checkbox" id="gMusic" data-gb="music"> Muzica de fundal &mdash; arpegiu calm care accelereaza sub 1 minut (doar in Campanie; buton de oprire in bara)</label>
<label class="ck"><input type="checkbox" id="gAdventure" data-gb="adventure"> Mod aventura &mdash; raspunsul decide urmatoarea camera (ramificare); configureaza per-puzzle mai jos (doar in Campanie)</label>
<label>Timp limita (minute, 0 = fara) &mdash; ceas calm in bara, doar in Campanie</label> <label>Timp limita (minute, 0 = fara) &mdash; ceas calm in bara, doar in Campanie</label>
<input type="number" id="gTimer" data-g="timerMin" min="0" max="120" step="1" placeholder="0"> <input type="number" id="gTimer" data-g="timerMin" min="0" max="120" step="1" placeholder="0">
<div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div> <div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div>
@@ -184,6 +185,7 @@ const defaultState = () => ({
charName: 'Alex', charName: 'Alex',
voice: false, voice: false,
music: false, music: false,
adventure: false,
timerMin: 0, timerMin: 0,
story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.', story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.',
finalMessage: 'Felicitari! Ai gasit comoara!', finalMessage: 'Felicitari! Ai gasit comoara!',
@@ -207,6 +209,7 @@ function normalizePuzzle(p) {
if (typeof p.letter !== 'string') p.letter = ''; if (typeof p.letter !== 'string') p.letter = '';
if (!validStyles.includes(p.style || '')) p.style = ''; if (!validStyles.includes(p.style || '')) p.style = '';
if (typeof p.style === 'undefined') p.style = ''; if (typeof p.style === 'undefined') p.style = '';
if (typeof p.branch !== 'object' || Array.isArray(p.branch) || p.branch === null) p.branch = {};
return p; return p;
} }
@@ -239,6 +242,23 @@ function renderWord() {
$('#finalWord').textContent = word || ' '; $('#finalWord').textContent = word || ' ';
} }
function choiceOptsBuilder(p) {
return (p.choices || '').split('\n').map(l => l.trim()).filter(Boolean)
.map(o => o.charAt(0) === '*' ? o.slice(1).trim() : o);
}
function branchTargetSelect(p, pi, key, n) {
const branch = p.branch || {};
const cur = branch[key] !== undefined ? String(branch[key]) : '';
let opts = `<option value="" ${cur === '' ? 'selected' : ''}>Liniar (urmatoarea)</option>`;
for (let k = 0; k < n; k++) {
if (k === pi) continue;
opts += `<option value="${k}" ${cur === String(k) ? 'selected' : ''}>Camera ${k + 1}</option>`;
}
opts += `<option value="end" ${cur === 'end' ? 'selected' : ''}>Sfarsit</option>`;
return `<select data-fb data-bkey="${esc(key)}">${opts}</select>`;
}
function puzzleCard(p, i) { function puzzleCard(p, i) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'puzzle' + (p._closed ? ' closed' : ''); div.className = 'puzzle' + (p._closed ? ' closed' : '');
@@ -304,6 +324,18 @@ function puzzleCard(p, i) {
<input type="text" data-f="letter" maxlength="1" value="${esc(p.letter)}"> <input type="text" data-f="letter" maxlength="1" value="${esc(p.letter)}">
</div> </div>
</div> </div>
${state.style === 'campaign' && state.adventure ? `
<div style="margin-top:10px;padding:10px 0 0;border-top:1px solid rgba(255,255,255,.1)">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#a78bfa;font-weight:700;margin-bottom:6px">Ramificare (Adventure Mode)</div>
${p.type === 'free' ? `
<div class="row"><div><label>Daca raspunde corect &rarr; mergi la</label>${branchTargetSelect(p, i, '*', state.puzzles.length)}</div></div>` : ''}
${p.type === 'tf' ? `
<div class="row"><div><label>Daca <strong>Adevarat</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, 'Adevarat', state.puzzles.length)}</div></div>
<div class="row"><div><label>Daca <strong>Fals</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, 'Fals', state.puzzles.length)}</div></div>` : ''}
${p.type === 'choice' ? choiceOptsBuilder(p).map((opt, ci) => `
<div class="row"><div><label>Daca <strong>${esc(opt)}</strong> &rarr; mergi la</label>${branchTargetSelect(p, i, String(ci), state.puzzles.length)}</div></div>`).join('') : ''}
<div class="help" style="color:#fbbf24">&#9888; Reordonarea sau stergerea puzzle-urilor poate invalida ramificarile &mdash; verifica-le dupa!</div>
</div>` : ''}
</div>`; </div>`;
return div; return div;
} }
@@ -328,10 +360,23 @@ document.querySelectorAll('[data-g]').forEach(el => {
}); });
document.querySelectorAll('[data-gb]').forEach(el => { document.querySelectorAll('[data-gb]').forEach(el => {
el.addEventListener('change', () => { state[el.dataset.gb] = el.checked; onChange(); }); el.addEventListener('change', () => {
state[el.dataset.gb] = el.checked;
if (el.dataset.gb === 'adventure') renderPuzzles(); /* re-render: branch dropdowns apar/dispar */
onChange();
});
}); });
puzzleList.addEventListener('input', e => { puzzleList.addEventListener('input', e => {
/* branch key selects (adventure mode) */
if (e.target.hasAttribute('data-fb')) {
const bkey = e.target.dataset.bkey;
const i = +e.target.closest('.puzzle').dataset.i;
if (!state.puzzles[i].branch) state.puzzles[i].branch = {};
state.puzzles[i].branch[bkey] = e.target.value;
onChange();
return;
}
const f = e.target.dataset.f; const f = e.target.dataset.f;
if (!f) return; if (!f) return;
const i = +e.target.closest('.puzzle').dataset.i; const i = +e.target.closest('.puzzle').dataset.i;
@@ -341,6 +386,7 @@ puzzleList.addEventListener('input', e => {
card.querySelector('.head .t').textContent = state.puzzles[i].title || state.puzzles[i].question || 'Puzzle fara titlu'; card.querySelector('.head .t').textContent = state.puzzles[i].title || state.puzzles[i].question || 'Puzzle fara titlu';
} }
if (f === 'type') { state.puzzles[i]._closed = false; renderPuzzles(); } if (f === 'type') { state.puzzles[i]._closed = false; renderPuzzles(); }
if (f === 'choices') renderPuzzles(); /* re-render: branch dropdowns per opțiune */
onChange(); onChange();
}); });
@@ -417,10 +463,25 @@ $('#btnReload').addEventListener('click', refreshPreview);
function cleanState() { function cleanState() {
const s = JSON.parse(JSON.stringify(state)); const s = JSON.parse(JSON.stringify(state));
s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */ s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */
s.puzzles.forEach(p => { const nP = s.puzzles.length;
s.puzzles.forEach((p, pi) => {
delete p._closed; delete p._closed;
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */ /* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
p.letter = (p.letter || '').replace(/[^A-Za-z0-9]/g, '').charAt(0); p.letter = (p.letter || '').replace(/[^A-Za-z0-9]/g, '').charAt(0);
if (!s.adventure) {
delete p.branch; /* strip branch când adventure e off */
} else {
/* clamp ținte out-of-range → '' (liniar) */
const br = p.branch || {};
Object.keys(br).forEach(k => {
const v = br[k];
if (v !== '' && v !== 'end') {
const n = +v;
if (isNaN(n) || n < 0 || n >= nP) br[k] = '';
}
});
p.branch = br;
}
}); });
return s; return s;
} }
@@ -722,13 +783,14 @@ function starsFor(att, hint){ return (hint || att >= 2) ? 1 : (att === 1 ? 2 : 3
function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += L.toUpperCase(); } return w; } function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += L.toUpperCase(); } return w; }
function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); } function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; } function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); } var _lastGiven = '';
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); var ok = norm(given) !== '' && norm(given) === norm(exp); if(ok){ _lastGiven = given; } return ok; }
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} } function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } } function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } } function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
/* Contract de finalizare a camerei — un singur loc pentru payload-ul parent.nextRoom /* Contract de finalizare a camerei — un singur loc pentru payload-ul parent.nextRoom
(înlocuiește duplicatele din showFinal/finale; D7). Citește totalStars + finalWord() la apel. */ (înlocuiește duplicatele din showFinal/finale; D7). Citește totalStars + finalWord() la apel. */
function campaignDone(){ if(CFG._campaign){ try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:finalWord().charAt(0)}); }catch(e){} } } function campaignDone(){ if(CFG._campaign){ try{ var p = CFG.puzzles[0]; var bkey = '*'; if(p.type === 'tf'){ bkey = _lastGiven || 'Adevarat'; } else if(p.type === 'choice'){ var opts = choiceOpts(p); var bi = opts.findIndex(function(o){ return norm(o) === norm(_lastGiven); }); bkey = String(bi >= 0 ? bi : 0); } parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:finalWord().charAt(0), branch:bkey}); }catch(e){} } }
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } }; window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
if(CFG._campaign){ if(CFG._campaign){
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */ /* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
@@ -1680,6 +1742,7 @@ body {
.ow-door { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; color: #fff; border-radius: 7px; background: #e11d48; box-shadow: 0 2px 8px rgba(0,0,0,.5); } .ow-door { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; color: #fff; border-radius: 7px; background: #e11d48; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
.ow-door.solved { background: var(--c-gold); color: #3a2606; } .ow-door.solved { background: var(--c-gold); color: #3a2606; }
.ow-door.target { box-shadow: 0 0 0 3px #a78bfa, 0 2px 10px rgba(167,139,250,.6); } .ow-door.target { box-shadow: 0 0 0 3px #a78bfa, 0 2px 10px rgba(167,139,250,.6); }
.ow-door.locked { background: #374151; filter: grayscale(1) brightness(.65); opacity: .7; cursor: not-allowed; }
.ow-exit { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; border-radius: 7px; background: #3b2a63; filter: grayscale(1) brightness(.7); } .ow-exit { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; border-radius: 7px; background: #3b2a63; filter: grayscale(1) brightness(.7); }
.ow-exit.open { background: #166534; filter: none; box-shadow: 0 0 14px #22c55e; } .ow-exit.open { background: #166534; filter: none; box-shadow: 0 0 14px #22c55e; }
.ow-player { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; transition: left .1s linear, top .1s linear; z-index: 3; } .ow-player { position: absolute; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; transition: left .1s linear, top .1s linear; z-index: 3; }
@@ -1962,6 +2025,9 @@ var activeIdx = -1;
var activeWindow = null; var activeWindow = null;
var readyTimer = null; var readyTimer = null;
var roomDone = {}; var roomDone = {};
var ADVENTURE = !!MASTER.adventure;
var owUnlocked = {0: true}; /* ușile deblocate în adventure mode */
var owExitUnlocked = false;
/* ----- Resume — safeStore (D3) + djb2 hash (D11) ----- */ /* ----- Resume — safeStore (D3) + djb2 hash (D11) ----- */
function djb2(s){ function djb2(s){
@@ -1973,7 +2039,14 @@ var _RESUME_KEY = 'esc-camp-' + djb2(JSON.stringify(MASTER));
function safeGet(){ try{ return JSON.parse(sessionStorage.getItem(_RESUME_KEY)); }catch(e){ return null; } } function safeGet(){ try{ return JSON.parse(sessionStorage.getItem(_RESUME_KEY)); }catch(e){ return null; } }
function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } } function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } }
function saveProgress(){ function saveProgress(){
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), roomStars: roomStars.slice(), skipped: skipped }); var payload = { idx: activeIdx, totalStars: totalStars, collected: collected.slice(), roomStars: roomStars.slice(), skipped: skipped };
if(ADVENTURE){
payload.doneList = Object.keys(roomDone).map(Number);
payload.owUnlocked = owUnlocked;
payload.owExitUnlocked = owExitUnlocked;
payload.target = owTargetIdx;
}
safeSet(payload);
} }
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} } function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} }
@@ -2173,6 +2246,20 @@ window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — gua
/* ----- parent.* API ----- */ /* ----- parent.* API ----- */
/* Calculează camera țintă după răspuns (adventure mode).
Returnează număr (idx cameră) sau 'end'. Non-adventure → idx+1. */
function resolveBranch(idx, key){
if(!ADVENTURE) return idx + 1;
var p = MASTER.puzzles[idx];
var br = p && p.branch;
var t = br ? (br[key] !== undefined ? br[key] : br['*']) : undefined;
if(t === undefined || t === '') t = idx + 1; /* fallback liniar */
if(t === 'end') return 'end';
t = +t;
if(isNaN(t) || t < 0 || t >= N) return 'end';
return t;
}
window.nextRoom = function(data){ window.nextRoom = function(data){
/* Guard: doar de la camera activă (D5) */ /* Guard: doar de la camera activă (D5) */
if(!activeWindow || frameEl.contentWindow !== activeWindow){ if(!activeWindow || frameEl.contentWindow !== activeWindow){
@@ -2189,10 +2276,24 @@ window.nextRoom = function(data){
var letter = String(data.letter || '').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase(); var letter = String(data.letter || '').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase();
if(letter) collected.push(letter); if(letter) collected.push(letter);
setDot(idx,'done'); setDot(idx,'done');
saveProgress();
console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter); console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter);
if(ADVENTURE){
var dest = resolveBranch(idx, data.branch || '*');
if(dest === 'end'){
owExitUnlocked = true;
saveProgress();
showOverworld(idx, data); /* overworld cu exit deblocat — player merge la steag */
} else {
owUnlocked[dest] = true;
owTargetIdx = dest;
saveProgress();
showOverworld(dest, data);
}
} else {
saveProgress();
var next = idx + 1; var next = idx + 1;
if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); } if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); }
}
}; };
window.roomReady = function(idx){ window.roomReady = function(idx){
@@ -2327,6 +2428,7 @@ function buildDiploma(){
var lab = document.createElement('span'); lab.textContent = 'Camera ' + (i+1); var lab = document.createElement('span'); lab.textContent = 'Camera ' + (i+1);
var val = document.createElement('span'); var val = document.createElement('span');
if(skipped[i]){ val.className = 'rskip'; val.textContent = '\\uD83D\\uDD12 s\\u0103rit\\u0103'; } if(skipped[i]){ val.className = 'rskip'; val.textContent = '\\uD83D\\uDD12 s\\u0103rit\\u0103'; }
else if(ADVENTURE && !roomDone[i]){ val.className = 'rskip'; val.textContent = '\\u2014 neexplorat\\u0103'; }
else { val.className = 'rstars'; val.textContent = _starStr(roomStars[i]||0); } else { val.className = 'rstars'; val.textContent = _starStr(roomStars[i]||0); }
row.appendChild(lab); row.appendChild(val); rooms.appendChild(row); row.appendChild(lab); row.appendChild(val); rooms.appendChild(row);
} }
@@ -2416,15 +2518,18 @@ function owRefreshDoors(){
owDoors.forEach(function(d){ owDoors.forEach(function(d){
var el = document.getElementById('ow-door-' + d.idx); if (!el) return; var el = document.getElementById('ow-door-' + d.idx); if (!el) return;
var done = !!roomDone[d.idx], isSkip = !!skipped[d.idx]; var done = !!roomDone[d.idx], isSkip = !!skipped[d.idx];
el.className = 'ow-door' + (done ? ' solved' : '') + (!done && d.idx === owTargetIdx ? ' target' : ''); var locked = ADVENTURE && !owUnlocked[d.idx] && !done;
el.className = 'ow-door' + (done ? ' solved' : '') + (!done && !locked && d.idx === owTargetIdx ? ' target' : '') + (locked ? ' locked' : '');
if (isSkip) el.textContent = '\\ud83d\\udd12'; if (isSkip) el.textContent = '\\ud83d\\udd12';
else if (done) el.textContent = (MASTER.puzzles[d.idx].letter || '').trim().toUpperCase() || '\\u2713'; else if (done) el.textContent = (MASTER.puzzles[d.idx].letter || '').trim().toUpperCase() || '\\u2713';
else if (locked) el.textContent = '\\ud83d\\udd12';
else el.textContent = (d.idx + 1); else el.textContent = (d.idx + 1);
}); });
var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (owAllDone() ? ' open' : ''); var exitOpen = ADVENTURE ? owExitUnlocked : owAllDone();
document.getElementById('ow-hint').textContent = owAllDone() var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (exitOpen ? ' open' : '');
? 'Toate camerele rezolvate! Mergi la steag \\ud83c\\udfc1 ca să evadezi.' document.getElementById('ow-hint').textContent = exitOpen
: 'Mergi la ușa următoare (săgeți / WASD / butoane).'; ? 'Toate camerele rezolvate! Mergi la steag \\ud83c\\udfc1 ca s\\u0103 evadezi.'
: 'Mergi la u\\u015fa urm\\u0103toare (s\\u0103ge\\u021bi / WASD / butoane).';
} }
function owCenter(){ function owCenter(){
@@ -2448,8 +2553,15 @@ function owMove(dc, dr){
} }
function owCheckEnter(){ function owCheckEnter(){
for (var i = 0; i < owDoors.length; i++){ var d = owDoors[i]; if (owPlayer.col === d.col && owPlayer.row === d.row){ if (!roomDone[d.idx]) owEnterDoor(d.idx); return; } } for (var i = 0; i < owDoors.length; i++){
if (owPlayer.col === owExit.col && owPlayer.row === owExit.row && owAllDone()){ owActive = false; showFinale(); } var d = owDoors[i];
if (owPlayer.col === d.col && owPlayer.row === d.row){
if (!roomDone[d.idx] && (!ADVENTURE || owUnlocked[d.idx])) owEnterDoor(d.idx);
return;
}
}
var canExit = ADVENTURE ? owExitUnlocked : owAllDone();
if (owPlayer.col === owExit.col && owPlayer.row === owExit.row && canExit){ owActive = false; clearProgress(); showFinale(); }
} }
function owEnterDoor(idx){ if (!owActive) return; /* idempotență — a doua intrare ignorată (T4/D4) */ owActive = false; mountRoom(idx); } function owEnterDoor(idx){ if (!owActive) return; /* idempotență — a doua intrare ignorată (T4/D4) */ owActive = false; mountRoom(idx); }
@@ -2482,7 +2594,7 @@ document.querySelectorAll('#ow-dpad button[data-d]').forEach(function(b){
/* Hooks pentru teste (conduc harta fără tastatură) */ /* Hooks pentru teste (conduc harta fără tastatură) */
window.__ow = { window.__ow = {
get state(){ return { player: { col: owPlayer.col, row: owPlayer.row }, target: owTargetIdx, active: owActive, allDone: owAllDone(), doors: owDoors.map(function(d){ return { idx: d.idx, col: d.col, row: d.row, solved: !!roomDone[d.idx] }; }) }; }, get state(){ return { player: { col: owPlayer.col, row: owPlayer.row }, target: owTargetIdx, active: owActive, allDone: owAllDone(), owUnlocked: owUnlocked, owExitUnlocked: owExitUnlocked, doors: owDoors.map(function(d){ return { idx: d.idx, col: d.col, row: d.row, solved: !!roomDone[d.idx] }; }) }; },
enterDoor: function(i){ var d = owDoors[i]; if (d){ owPlayer.col = d.col; owPlayer.row = d.row; owRenderPlayer(); owCheckEnter(); } }, enterDoor: function(i){ var d = owDoors[i]; if (d){ owPlayer.col = d.col; owPlayer.row = d.row; owRenderPlayer(); owCheckEnter(); } },
enterExit: function(){ owPlayer.col = owExit.col; owPlayer.row = owExit.row; owRenderPlayer(); owCheckEnter(); } enterExit: function(){ owPlayer.col = owExit.col; owPlayer.row = owExit.row; owRenderPlayer(); owCheckEnter(); }
}; };
@@ -2496,7 +2608,9 @@ document.getElementById('intro-story').textContent = _introStory;
document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic'; document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic';
document.getElementById('btn-start').onclick = function(){ document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0); clearProgress(); owResetPlayer();
owUnlocked = {0: true}; owExitUnlocked = false; /* reset adventure state la start nou */
showOverworld(0);
startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */ startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */
startMusic(); /* muzica ambient pornește odată cu aventura (T10) */ startMusic(); /* muzica ambient pornește odată cu aventura (T10) */
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */ voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
@@ -2549,12 +2663,21 @@ buildDots();
roomStars = saved.roomStars || []; roomStars = saved.roomStars || [];
skipped = saved.skipped || {}; skipped = saved.skipped || {};
Object.keys(skipped).forEach(function(k){ roomDone[+k] = true; setDot(+k,'done'); }); Object.keys(skipped).forEach(function(k){ roomDone[+k] = true; setDot(+k,'done'); });
/* repornim pe hartă, la ușa camerei next */ if(ADVENTURE){
/* Adventure: restore non-contiguous done list (nu bucla liniară 0..idx) */
var dl = saved.doneList || [saved.idx];
dl.forEach(function(i){ if(i >= 0 && i < N){ roomDone[i] = true; setDot(i,'done'); } });
owUnlocked = saved.owUnlocked || {0: true};
owExitUnlocked = !!saved.owExitUnlocked;
var target = (typeof saved.target === 'number' && saved.target >= 0 && saved.target < N) ? saved.target : 0;
owResetPlayer(); showOverworld(target);
startTimer(); startMusic();
return;
}
/* Non-adventure: bucla liniară (comportament existent) */
var resumeIdx = saved.idx + 1; var resumeIdx = saved.idx + 1;
/* marchează ușile deja rezolvate pe hartă (resume) */
for(var di=0; di<=saved.idx; di++){ roomDone[di] = true; setDot(di,'done'); } for(var di=0; di<=saved.idx; di++){ roomDone[di] = true; setDot(di,'done'); }
if(resumeIdx >= N){ if(resumeIdx >= N){
/* ultima cameră deja terminată — mergi direct la final */
showFinale(); return; showFinale(); return;
} }
owResetPlayer(); showOverworld(resumeIdx); owResetPlayer(); showOverworld(resumeIdx);

View File

@@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec
până la ecranul final, fără erori de consolă. până la ecranul final, fără erori de consolă.
## Ownership ## Ownership
- `tests/smoke.mjs` — unicul fișier de teste (~31 teste). - `tests/smoke.mjs` — unicul fișier de teste (~35 teste).
- `playwright.config.mjs` (la root, **gitignored**) — config dev. - `playwright.config.mjs` (la root, **gitignored**) — config dev.
## Local Contracts ## Local Contracts
@@ -18,9 +18,11 @@ până la ecranul final, fără erori de consolă.
fiecare test asertează `errors.length === 0` la final. fiecare test asertează `errors.length === 0` la final.
- **Tag-uri:** `@regresie` (16 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML + - **Tag-uri:** `@regresie` (16 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
stil top-level invalid la import + bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` stil top-level invalid la import + bomberman gameplay + bomberman rază/powerup-uri) și `@campanie`
(17 — intro→hartă→camere→final, resume, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, (21 — intro→hartă→camere→final, resume, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil,
audio S1, voce/narațiune D10, a11y tap/aria/reduced-motion, navigare overworld, timer calm T10, muzica ambient T10, diploma A4). audio S1, voce/narațiune D10, a11y tap/aria/reduced-motion, navigare overworld, timer calm T10,
- **Status țintă: 31/31 PASS.** muzica ambient T10, diploma A4, adventure branch-jump, adventure resume non-contiguu,
adventure regression non-adventure, adventure branch tf).
- **Status țintă: 35/35 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 +31,7 @@ până la ecranul final, fără erori de consolă.
## Verification ## Verification
```bash ```bash
npx playwright test tests/smoke.mjs # 31/31 npx playwright test tests/smoke.mjs # 35/35
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

@@ -1450,4 +1450,209 @@ test.describe('Campanie E2E @campanie', () => {
expect(errors, errors.join('\n')).toHaveLength(0); expect(errors, errors.join('\n')).toHaveLength(0);
}); });
// ─────────────────────────────────────────────────────────────────────
// Adventure Mode tests (E0-E6)
// ─────────────────────────────────────────────────────────────────────
/** Helper: genereaza cfg de campanie cu adventure ON. */
function adventureCfg(puzzles) {
return {
title: 'Test Adventure', player: 'Tester', color: '#6d28d9',
style: 'campaign', charName: 'Alex',
story: 'Aventura de test.',
finalMessage: 'Ai terminat aventura!',
adventure: true,
puzzles
};
}
test('adventure — branch-jump: room0→2 (sare room1), room2→exit, diploma neexplorata @campanie',
async ({ page }) => {
test.setTimeout(90000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-jump');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Enter room 0 (door 0 unlocked in adventure)
await enterRoom(gp, 0);
await solveRoom(gp, 'classic', 'r0');
// After solving room 0: overworld with door 2 unlocked, door 1 locked
await waitOverworld(gp);
const stAfter0 = await gp.evaluate(() => window.__ow.state);
expect(stAfter0.owUnlocked[2], 'door 2 trebuie deblocata dupa room0').toBeTruthy();
expect(stAfter0.owUnlocked[1], 'door 1 trebuie sa ramana incuiata').toBeFalsy();
expect(stAfter0.doors[0].solved, 'room 0 trebuie sa fie done').toBe(true);
// Door 1 should be locked — entering it should be blocked (stay in overworld)
await gp.evaluate(() => window.__ow.enterDoor(1));
await gp.waitForTimeout(300);
const stLocked = await gp.evaluate(() => window.__ow.state);
expect(stLocked.active, 'harta trebuie sa ramana activa cand usa e incuiata').toBe(true);
// Enter room 2 (unlocked)
await enterRoom(gp, 2);
await solveRoom(gp, 'classic', 'r2');
// After solving room 2: exit should be unlocked
await waitOverworld(gp);
const stAfter2 = await gp.evaluate(() => window.__ow.state);
expect(stAfter2.owExitUnlocked, 'exit trebuie deblocat dupa room2→end').toBe(true);
// Enter exit → finale
await gp.evaluate(() => window.__ow.enterExit());
await gp.waitForFunction(
() => document.getElementById('finale')?.classList.contains('show'),
null, { timeout: 5000 }
);
// Open diploma → camera 1 should be "neexplorata"
await gp.locator('#btn-diploma').click();
const diplomaText = await gp.locator('#dipl-rooms').innerText();
expect(diplomaText).toMatch(/neexplorat/i);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure — resume non-contiguu: room0 done → reload → room0 done + usa2 deblocata + usa1 incuiata @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-resume');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Solve room 0 → branches to room 2, skips room 1
await enterRoom(gp, 0);
await solveRoom(gp, 'classic', 'r0');
await waitOverworld(gp);
// Reload — tryResume trebuie sa reconstituie starea non-contigua
await gp.reload();
await gp.waitForLoadState('domcontentloaded');
// Asteapta overworld activ (resume, nu intro)
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
const stResume = await gp.evaluate(() => window.__ow.state);
expect(stResume.doors[0].solved, 'room 0 trebuie sa fie done dupa resume').toBe(true);
expect(stResume.owUnlocked[2], 'usa 2 trebuie deblocata dupa resume').toBeTruthy();
expect(stResume.owUnlocked[1], 'usa 1 trebuie sa ramana incuiata dupa resume').toBeFalsy();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure off — regresia non-adventure: toate usile intrabile in orice ordine @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
// adventure:false (default) — toate ușile deblocate, orice ordine
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-off');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// In non-adventure: can enter door 1 first (not door 0)
await waitOverworld(gp);
await gp.evaluate(() => window.__ow.enterDoor(1));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
// overworld became inactive (we entered a room) — confirms door 1 was enterable
const stAfter = await gp.evaluate(() => window.__ow.state);
expect(stAfter.active, 'harta trebuie sa fie inactiva dupa intrarea in room1').toBe(false);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
test('adventure — branch tf: raspuns Adevarat→2, Fals→1 deblocheza usa corecta @campanie',
async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
const cfg = adventureCfg([
{ title: 'Camera 0', type: 'tf', question: 'E adevarat?', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { Adevarat: 2, Fals: 1 } },
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: {} },
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: {} }
]);
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-tf');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Enter room 0 (tf puzzle, classic engine: buttons in #answers)
await waitOverworld(gp);
await gp.evaluate(() => window.__ow.enterDoor(0));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
const ifl = gp.frameLocator('#room-frame');
await ifl.locator('#btnStart').click();
// Click "Adevarat" (correct answer → branch key 'Adevarat' → should unlock door 2)
await ifl.locator('#answers button:text("Adevarat")').click();
await gp.waitForTimeout(1200); // animatie next()
await waitOverworld(gp);
const st = await gp.evaluate(() => window.__ow.state);
expect(st.owUnlocked[2], 'Adevarat→2: usa 2 trebuie deblocata').toBeTruthy();
expect(st.owUnlocked[1], 'Adevarat→2: usa 1 trebuie sa ramana incuiata').toBeFalsy();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
}); });