From 8fc8f8040f2b55a351fece3654b2315fd7c4abde Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sat, 13 Jun 2026 21:08:21 +0000 Subject: [PATCH] Adventure Mode v0 (E0-E6): ramificare per-raspuns in campanie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 4 +- TODOS.md | 33 +++++-- escape-builder.html | 163 ++++++++++++++++++++++++++++++----- tests/AGENTS.md | 12 +-- tests/smoke.mjs | 205 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23c2fc8..ae14c92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ sursa de adevăr tehnică pentru agenți. python3 -m http.server 8000 # 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 @campanie # campanie E2E: 17 +npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21 ``` ## Durable Rules (repo-wide) diff --git a/TODOS.md b/TODOS.md index 08f5d4f..85fb82e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -215,14 +215,33 @@ 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ă)* +### [x] Adventure Mode v0 — LIVRAT (2026-06-13) +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. -- 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". +**E0** — `adventure: false` în `defaultState()`; checkbox `data-gb="adventure"` în builder (lângă voice/music); +`var ADVENTURE = !!MASTER.adventure` în orchestrator. + +**E1** — `_lastGiven` în libJS; `checkAnswer` setează `_lastGiven` pe succes; `campaignDone()` calculează +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 *(depinde de măsurarea dimensiunii JSON comprimate)* diff --git a/escape-builder.html b/escape-builder.html index c4bbd22..49ac23a 100644 --- a/escape-builder.html +++ b/escape-builder.html @@ -138,6 +138,7 @@ +
Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 — jocul continua, fara penalizare.
@@ -184,6 +185,7 @@ const defaultState = () => ({ charName: 'Alex', voice: false, music: false, + adventure: false, 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.', finalMessage: 'Felicitari! Ai gasit comoara!', @@ -207,6 +209,7 @@ function normalizePuzzle(p) { if (typeof p.letter !== 'string') p.letter = ''; if (!validStyles.includes(p.style || '')) 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; } @@ -239,6 +242,23 @@ function renderWord() { $('#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 = ``; + for (let k = 0; k < n; k++) { + if (k === pi) continue; + opts += ``; + } + opts += ``; + return ``; +} + function puzzleCard(p, i) { const div = document.createElement('div'); div.className = 'puzzle' + (p._closed ? ' closed' : ''); @@ -304,6 +324,18 @@ function puzzleCard(p, i) { + ${state.style === 'campaign' && state.adventure ? ` +
+
Ramificare (Adventure Mode)
+ ${p.type === 'free' ? ` +
${branchTargetSelect(p, i, '*', state.puzzles.length)}
` : ''} + ${p.type === 'tf' ? ` +
${branchTargetSelect(p, i, 'Adevarat', state.puzzles.length)}
+
${branchTargetSelect(p, i, 'Fals', state.puzzles.length)}
` : ''} + ${p.type === 'choice' ? choiceOptsBuilder(p).map((opt, ci) => ` +
${branchTargetSelect(p, i, String(ci), state.puzzles.length)}
`).join('') : ''} +
⚠ Reordonarea sau stergerea puzzle-urilor poate invalida ramificarile — verifica-le dupa!
+
` : ''} `; return div; } @@ -328,10 +360,23 @@ document.querySelectorAll('[data-g]').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 => { + /* 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; if (!f) return; 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'; } if (f === 'type') { state.puzzles[i]._closed = false; renderPuzzles(); } + if (f === 'choices') renderPuzzles(); /* re-render: branch dropdowns per opțiune */ onChange(); }); @@ -417,10 +463,25 @@ $('#btnReload').addEventListener('click', refreshPreview); function cleanState() { 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.puzzles.forEach(p => { + const nP = s.puzzles.length; + s.puzzles.forEach((p, pi) => { 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); + 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; } @@ -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 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); } +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 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){} } } +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){} } }; if(CFG._campaign){ /* 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.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.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.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; } @@ -1962,6 +2025,9 @@ var activeIdx = -1; var activeWindow = null; var readyTimer = null; 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) ----- */ 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 safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } } 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){} } @@ -2173,6 +2246,20 @@ window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — gua /* ----- 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){ /* Guard: doar de la camera activă (D5) */ 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(); if(letter) collected.push(letter); setDot(idx,'done'); - saveProgress(); console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter); - var next = idx + 1; - if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); } + 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; + if(next >= N){ clearProgress(); showFinale(); } else { showOverworld(next, data); } + } }; window.roomReady = function(idx){ @@ -2327,6 +2428,7 @@ function buildDiploma(){ var lab = document.createElement('span'); lab.textContent = 'Camera ' + (i+1); var val = document.createElement('span'); 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); } row.appendChild(lab); row.appendChild(val); rooms.appendChild(row); } @@ -2416,15 +2518,18 @@ function owRefreshDoors(){ owDoors.forEach(function(d){ var el = document.getElementById('ow-door-' + d.idx); if (!el) return; 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'; 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); }); - var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (owAllDone() ? ' open' : ''); - document.getElementById('ow-hint').textContent = owAllDone() - ? 'Toate camerele rezolvate! Mergi la steag \\ud83c\\udfc1 ca să evadezi.' - : 'Mergi la ușa următoare (săgeți / WASD / butoane).'; + var exitOpen = ADVENTURE ? owExitUnlocked : owAllDone(); + var ex = document.getElementById('ow-exit'); if (ex) ex.className = 'ow-exit' + (exitOpen ? ' open' : ''); + document.getElementById('ow-hint').textContent = exitOpen + ? '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(){ @@ -2448,8 +2553,15 @@ function owMove(dc, dr){ } 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; } } - if (owPlayer.col === owExit.col && owPlayer.row === owExit.row && owAllDone()){ owActive = false; showFinale(); } + 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] && (!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); } @@ -2482,7 +2594,7 @@ document.querySelectorAll('#ow-dpad button[data-d]').forEach(function(b){ /* Hooks pentru teste (conduc harta fără tastatură) */ 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(); } }, 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('btn-start').onclick = function(){ 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) */ startMusic(); /* muzica ambient pornește odată cu aventura (T10) */ voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */ @@ -2549,12 +2663,21 @@ buildDots(); roomStars = saved.roomStars || []; skipped = saved.skipped || {}; 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; - /* marchează ușile deja rezolvate pe hartă (resume) */ for(var di=0; di<=saved.idx; di++){ roomDone[di] = true; setDot(di,'done'); } if(resumeIdx >= N){ - /* ultima cameră deja terminată — mergi direct la final */ showFinale(); return; } owResetPlayer(); showOverworld(resumeIdx); diff --git a/tests/AGENTS.md b/tests/AGENTS.md index d151c2e..3bf28f7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec până la ecranul final, fără erori de consolă. ## 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. ## Local Contracts @@ -18,9 +18,11 @@ până la ecranul final, fără erori de consolă. fiecare test asertează `errors.length === 0` la final. - **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` - (17 — 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). -- **Status țintă: 31/31 PASS.** + (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, adventure branch-jump, adventure resume non-contiguu, + adventure regression non-adventure, adventure branch tf). +- **Status țintă: 35/35 PASS.** ## Work Guidance - 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 ```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 @campanie ``` diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 01527db..d11eb20 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -1450,4 +1450,209 @@ test.describe('Campanie E2E @campanie', () => { 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); + }); + });