S1 fix real: deblocare audio pe primul gest (acopera resume), nu doar btn-start

Fix-ul initial deblocà AudioContext-ul doar in handlerul btn-start. Lacuna:
calea de resume (reload mid-campanie) intra direct pe harta fara btn-start ->
ctx nedeblocat -> camere mute. Plus resume() singur nu ajunge pe iOS Safari.

- unlockAudio() + listener global one-time (pointerdown+keydown capture):
  acopera fresh SI resume; buffer silentios iOS-safe.
- beep() se auto-vindeca daca ctx redevine suspended.
- Test smoke #9 rescris: headless creeaza ctx direct 'running' (ignora autoplay)
  -> vechiul "ctx running" trecea trivial. Acum: gest tastatura fara btn-start
  -> running (cale resume) + beep self-heal din ctx suspendat.
- Demo campanie regenerat. Suita 24/24.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 11:29:21 +00:00
parent 27fc0ca901
commit 651025bd28
4 changed files with 89 additions and 21 deletions

View File

@@ -16,13 +16,18 @@ Direcția cerută de user (decizii confirmate, vezi `HANDOFF.md`). Model hibrid
părțile grele se prototipează în PARALEL în `scratch/`, verificate jucabile, apoi integrator le
portează în `escape-builder.html` (un singur fișier, integrare secvențială).
- [x] **S1 — fix sunet campanie** *(GATA, verificat în browser)*
Cauză reală: orchestratorul crea `beep._ctx` lazy la primul `parent.beep()` din iframe;
gestul din iframe NU deblochează AudioContext-ul părintelui → ctx `suspended` → tăcere.
(Ipoteza HANDOFF „beep nedefinit" era greșită; `beep` e la `escape-builder.html:1725`.)
Fix: deblocare ctx în handler-ul `btn-start` (gest direct pe părinte), `escape-builder.html:1928`.
Verificat: `scratch/verify-audio-s1.mjs` → ctx `running` după start (era `NO_CTX`). Smoke 21/21.
TODO la S4: portează asertarea `beep._ctx.state==='running'` în `tests/smoke.mjs`.
- [x] **S1 — fix sunet campanie** *(GATA — REVENIT: fix-ul inițial era incomplet, user raporta tăcere)*
Cauză reală: gestul din iframe NU deblochează AudioContext-ul părintelui → ctx `suspended` → tăcere.
Fix v1 (incomplet): deblocare DOAR în handler-ul `btn-start`. Lacună: calea de **resume**
(reload mid-campanie, `escape-builder.html:2199`) intră direct pe hartă FĂRĂ btn-start → ctx
nedeblocat → camere mute. Plus `resume()` singur nu ajunge pe iOS Safari.
Fix v2 (real): `unlockAudio()` + listener GLOBAL one-time pe primul gest (`pointerdown`+`keydown`,
capture) — acoperă fresh ȘI resume (mers pe hartă = keydown pe părinte); buffer silențios
iOS-safe; `beep()` se auto-vindecă dacă ctx redevine `suspended`. `escape-builder.html:1893`.
**Lecție testare:** headless Chromium creează ctx direct `running` (ignoră autoplay policy) →
vechiul test „ctx running" trecea trivial, NU putea prinde tăcerea. Test nou (smoke #9):
gest tastatură FĂRĂ btn-start → running (cale resume) + beep self-heal din ctx suspendat.
Verificat: smoke 24/24 + live MCP (ArrowDown singur deblochează). Demo-uri regenerate.
- [x] **S2a — prototip Bomberman complet**`scratch/bomberman-proto.html` (GATA, 8/8 verificat de mine)
Grid 15×13, bombe timer 2.4s + explozii lanț, cutii distructibile, AI dușmani BFS urmărire,
3 vieți + respawn cu progres puzzle PĂSTRAT (stare separată), PRNG seedat (`window.__seed`),

View File

@@ -1878,6 +1878,7 @@ function doorHtml(style, isLast, isStuck){
function beep(ok){
try{
var ctx=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)());
if(ctx.state==='suspended') ctx.resume(); /* safety: ctx poate fi suspendat din nou */
var t=ctx.currentTime; var fs=ok?[523,784]:[196];
fs.forEach(function(f,k){
var o=ctx.createOscillator(),g=ctx.createGain();
@@ -1890,6 +1891,28 @@ function beep(ok){
}catch(e){}
}
/* ----- Deblocare audio (D2) — primul gest pe părinte creează+deblochează ctx-ul.
Necesar pe TOATE căile, nu doar btn-start: la resume (reload mid-campanie) se intră
direct pe hartă fără btn-start, iar camerele cheamă parent.beep() din iframe (gestul
din iframe NU deblochează ctx-ul părintelui). Pe iOS Safari resume() singur nu ajunge
→ redăm și un buffer silențios în gest. Listener one-time, se auto-elimină. */
function unlockAudio(){
try{
var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)());
if(c.state==='suspended') c.resume();
var b=c.createBuffer(1,1,22050),s=c.createBufferSource();
s.buffer=b; s.connect(c.destination); s.start(0);
}catch(e){}
}
var _audioUnlocked=false;
function _onFirstGesture(){
if(_audioUnlocked) return; _audioUnlocked=true; unlockAudio();
document.removeEventListener('pointerdown',_onFirstGesture,true);
document.removeEventListener('keydown',_onFirstGesture,true);
}
document.addEventListener('pointerdown',_onFirstGesture,true);
document.addEventListener('keydown',_onFirstGesture,true);
/* ----- parent.* API ----- */
window.nextRoom = function(data){
@@ -2170,9 +2193,7 @@ document.getElementById('intro-title').textContent = MASTER.title;
document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic';
document.getElementById('btn-start').onclick = function(){
/* Deblochează AudioContext-ul AICI (gest direct pe părinte) — camerele cheamă
parent.beep() din iframe, iar gestul din iframe NU deblochează ctx-ul părintelui. */
try{ var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); if(c.state==='suspended') c.resume(); }catch(e){}
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0);
};

View File

@@ -312,6 +312,7 @@ function doorHtml(style, isLast, isStuck){
function beep(ok){
try{
var ctx=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)());
if(ctx.state==='suspended') ctx.resume(); /* safety: ctx poate fi suspendat din nou */
var t=ctx.currentTime; var fs=ok?[523,784]:[196];
fs.forEach(function(f,k){
var o=ctx.createOscillator(),g=ctx.createGain();
@@ -324,6 +325,28 @@ function beep(ok){
}catch(e){}
}
/* ----- Deblocare audio (D2) — primul gest pe părinte creează+deblochează ctx-ul.
Necesar pe TOATE căile, nu doar btn-start: la resume (reload mid-campanie) se intră
direct pe hartă fără btn-start, iar camerele cheamă parent.beep() din iframe (gestul
din iframe NU deblochează ctx-ul părintelui). Pe iOS Safari resume() singur nu ajunge
→ redăm și un buffer silențios în gest. Listener one-time, se auto-elimină. */
function unlockAudio(){
try{
var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)());
if(c.state==='suspended') c.resume();
var b=c.createBuffer(1,1,22050),s=c.createBufferSource();
s.buffer=b; s.connect(c.destination); s.start(0);
}catch(e){}
}
var _audioUnlocked=false;
function _onFirstGesture(){
if(_audioUnlocked) return; _audioUnlocked=true; unlockAudio();
document.removeEventListener('pointerdown',_onFirstGesture,true);
document.removeEventListener('keydown',_onFirstGesture,true);
}
document.addEventListener('pointerdown',_onFirstGesture,true);
document.addEventListener('keydown',_onFirstGesture,true);
/* ----- parent.* API ----- */
window.nextRoom = function(data){
@@ -604,9 +627,7 @@ document.getElementById('intro-title').textContent = MASTER.title;
document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
document.getElementById('intro-promise').textContent = N+' camere \u00b7 3 stiluri \u00b7 1 cuvânt magic';
document.getElementById('btn-start').onclick = function(){
/* Deblochează AudioContext-ul AICI (gest direct pe părinte) — camerele cheamă
parent.beep() din iframe, iar gestul din iframe NU deblochează ctx-ul părintelui. */
try{ var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); if(c.state==='suspended') c.resume(); }catch(e){}
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0);
};

View File

@@ -945,27 +945,48 @@ test.describe('Campanie E2E @campanie', () => {
});
// ─────────────────────────────────────────────────────────────────────
// Test 9 (S4): Audio — AudioContext deblocat la "Incepe aventura" (S1)
// Test 9 (S4+): Audio — deblocare ctx pe PRIMUL gest (orice), nu doar btn-start.
// NB: headless Chromium creeaza AudioContext direct 'running' (ignora autoplay
// policy), deci "ctx==running" e trivial. Testam WIRING-ul real al deblocarii:
// (A) primul gest oarecare (tastatura, ca la resume) deblocheaza fara btn-start;
// (B) beep() se auto-vindeca dintr-un ctx readus in 'suspended'.
// ─────────────────────────────────────────────────────────────────────
test('audio — AudioContext deblocat la Incepe aventura (S1) @campanie',
test('audio — deblocare pe primul gest + beep self-heal (S1) @campanie',
async ({ page }) => {
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'audio');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Inainte de gest: ctx inexistent (creat lazy)
// Inainte de orice gest: ctx inexistent (creat lazy)
const before = await gp.evaluate(
() => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX'
);
expect(before, 'ctx nu trebuie sa existe inainte de gest').toBe('NO_CTX');
// Gestul pe parinte deblocheaza ctx-ul
await gp.locator('#btn-start').click();
await gp.waitForTimeout(200);
const after = await gp.evaluate(
// (A) Cale RESUME: un gest de tastatura (mers pe harta), FARA btn-start,
// trebuie sa creeze+deblocheze ctx-ul prin listenerul global one-time.
await gp.keyboard.press('ArrowDown');
await gp.waitForTimeout(100);
const afterKey = await gp.evaluate(
() => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX'
);
expect(after, 'ctx trebuie running dupa Incepe aventura').toBe('running');
expect(afterKey, 'gestul de tastatura trebuie sa deblocheze ctx (cale resume)').toBe('running');
// (B) beep() trebuie sa produca oscilatoare si sa reia un ctx suspendat.
const heal = await gp.evaluate(async () => {
await window.beep._ctx.suspend();
const mid = window.beep._ctx.state; // 'suspended'
let osc = 0;
const orig = window.beep._ctx.createOscillator.bind(window.beep._ctx);
window.beep._ctx.createOscillator = function () { osc++; return orig(); };
window.beep(true);
await new Promise(r => setTimeout(r, 50));
return { mid, osc, end: window.beep._ctx.state };
});
expect(heal.mid, 'ctx trebuie suspendat inainte de self-heal').toBe('suspended');
expect(heal.osc, 'beep trebuie sa creeze oscilatoare').toBeGreaterThan(0);
expect(heal.end, 'beep trebuie sa reia ctx-ul suspendat').toBe('running');
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}