Muzica ambient T10: arpegiu calm care accelereaza sub 1 min

Opt-in din builder (checkbox music, default off). Orchestrator-only: parintele
detine AudioContext (reutilizeaza beep._ctx); camerele nu stiu de muzica.

- arpegiu pentatonica minora (oscilatoare sine scurte), tempo ~1.8x pe ultimul
  minut (legat de _deadline-ul Timer Calm); fara timer -> loop calm fara accelerare
- buton 🎵/🔇 in bara chrome (#btn-music)
- duck pe voce: voiceSay onstart/onend regleaza gain (vocea are prioritate)
- fallback fara AudioContext -> no-op, buton ascuns (zero penalizare)
- porneste la start + resume; stop la showFinale + toggle
- hook test window.__music; exemplu-campanie.html regenerat (ramane opt-in off)

Smoke 30/30 (test nou "muzica ambient": opt-in, start, tempo sub 1 min, duck, toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 20:12:32 +00:00
parent b359bbe50a
commit d8cb515545
6 changed files with 238 additions and 28 deletions

View File

@@ -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ă: 29/29
npx playwright test tests/smoke.mjs # suita completă: 30/30
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 15
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 16
```
## 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.
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 + Timer Calm LIVRATE — vezi §§ mai jos.)
Rămas din Etapa 2: Adventure Mode v0 (+ Diplomă §Design pct.9). (D7 + Timer Calm + Muzică T10 LIVRATE.)
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
@@ -106,12 +106,20 @@ safe by default. `exemplu-campanie.html` regenerat (rămâne fără timer — op
smoke 29/29 (test nou „timer calm": format M:SS, prag auriu, freeze la expirare, jocul continuă, resume
păstrează ceasul). Commit: (acest commit). Următorul: muzică T10 (accelerare sub 1 min — depinde de timer).
### Muzică accelerată la timer (PR2 / T10) — depinde de Timer Calm (LIVRAT)
- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut.
- Ownership: părintele deține AudioContext; camerele nu știu de muzică.
- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve).
- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan.
- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10).
### [x] Muzică ambient accelerată la timer (PR2 / T10) — LIVRAT (2026-06-13)
Opt-in din builder (checkbox `music`, default off). Orchestrator-only: părintele deține AudioContext
(reutilizează `beep._ctx`, deblocat de gestul global); camerele NU știu de muzică. Arpegiu calm pe
pentatonică minoră (`_mTick`, oscilatoare sine scurte la ~520ms); tempo **accelerează** spre ~1.8×
pe ultimul minut (`musicTempoFactor`, legat de `_deadline`-ul Timer Calm). Buton 🎵/🔇 în bara chrome
(`#btn-music`). Edge-uri tratate:
- **Duck pe voce:** `voiceSay` setează `u.onstart→duckMusic(true)` / `onend|onerror→duckMusic(false)`;
`voiceCancel` și el unduck. Vocea are prioritate (gain muzică × 0.22 cât timp vorbește).
- **Fallback fără AudioContext:** tot în `try/catch` → no-op, buton ascuns (zero penalizare).
- pornește la „Începe aventura" + la resume; se oprește la `showFinale` (+ toggle).
- fără timer → tempo rămâne 1.0 (loop calm, fără accelerare).
Hook test `window.__music` (`tempo()`, `state()`). `exemplu-campanie.html` regenerat (rămâne fără
muzică — opt-in, ca vocea). Verificat: smoke 30/30 (test nou „muzica ambient": opt-in, start, tempo
crește sub 1 min, duck, toggle). Următorul roadmap: Diplomă (§Design pct.9) + Adventure Mode v0.
### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2)
Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox

View File

@@ -135,6 +135,7 @@
<label>Mesajul final (la castig)</label>
<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="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>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">
<div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div>
@@ -179,6 +180,7 @@ const defaultState = () => ({
style: 'classic',
charName: 'Alex',
voice: false,
music: 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!',
@@ -1634,15 +1636,15 @@ body {
#chrome-timer[hidden] { display: none; }
#chrome-timer.low { color: var(--c-gold); }
#chrome-timer.expired { color: var(--c-gold); opacity: .55; }
#btn-voice {
#btn-voice, #btn-music {
width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer;
border-radius: 8px; background: rgba(255,255,255,.12); color: #fff;
font-size: 17px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
#btn-voice[hidden] { display: none; } /* id batea specificitatea UA [hidden] */
#btn-voice:hover { background: rgba(255,255,255,.22); }
#btn-voice[aria-pressed="false"] { opacity: .5; }
#btn-voice:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
#btn-voice[hidden], #btn-music[hidden] { display: none; } /* id batea specificitatea UA [hidden] */
#btn-voice:hover, #btn-music:hover { background: rgba(255,255,255,.22); }
#btn-voice[aria-pressed="false"], #btn-music[aria-pressed="false"] { opacity: .5; }
#btn-voice:focus-visible, #btn-music:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
#dots { display: flex; gap: 8px; }
#dots span {
width: 10px; height: 10px; border-radius: 50%;
@@ -1812,6 +1814,7 @@ body {
<span id="chrome-title">${esc(cfg.title)}</span>
<div class="sp"></div>
<span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span>
<button id="btn-music" type="button" aria-label="Muzica de fundal" hidden>&#127925;</button>
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>&#128266;</button>
<div id="dots" role="group" aria-label="Progres camere"></div>
</div>
@@ -1931,6 +1934,56 @@ function startTimer(){
if(!_timerInt && !_timerExpired) _timerInt = setInterval(tickTimer, 1000);
}
/* ----- Muzică ambient (T10) — opt-in MASTER.music. Orchestrator-only: părintele
deține AudioContext (reutilizează beep._ctx, deblocat de gestul global); camerele
nu știu de muzică. Arpegiu calm pe pentatonică minoră; tempo ACCELEREAZĂ sub 1 min
(legat de Timer Calm). Se atenuează (duck) cât timp vocea vorbește. Fallback:
fără AudioContext → no-op, butonul rămâne ascuns (zero penalizare). ----- */
var MUSIC = !!MASTER.music;
var musicOn = MUSIC;
var _mGain = null, _mTimer = null, _mStep = 0, _mDuck = 1;
var _MSCALE = [0, 3, 5, 7, 10]; /* pentatonică minoră (semitonuri) */
function _mFreq(semi){ return 220 * Math.pow(2, semi / 12); }
function musicTempoFactor(){
/* 1.0 normal → ~1.8 pe ultimul minut (accelerare progresivă) */
if(TIMER_SEC <= 0 || !_deadline) return 1;
var rem = (_deadline - Date.now()) / 1000;
if(rem < 0) return 1.8;
if(rem > 60) return 1;
return 1 + (1 - rem / 60) * 0.8;
}
function _mTick(){
if(!musicOn || !_mGain){ _mTimer = null; return; }
var ctx = beep._ctx;
if(ctx){
try{
var oct = (Math.floor(_mStep / _MSCALE.length) % 2) ? 12 : 0;
var semi = _MSCALE[_mStep % _MSCALE.length] + oct;
var o = ctx.createOscillator(), g = ctx.createGain(), t = ctx.currentTime;
o.type = 'sine'; o.frequency.value = _mFreq(semi);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.05 * _mDuck, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.55);
o.connect(g); g.connect(_mGain);
o.start(t); o.stop(t + 0.6);
}catch(e){}
}
_mStep++;
_mTimer = setTimeout(_mTick, 520 / musicTempoFactor()); /* mai rapid când tempo crește */
}
function startMusic(){
if(!musicOn) return;
try{
var ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)());
if(ctx.state === 'suspended') ctx.resume();
if(!_mGain){ _mGain = ctx.createGain(); _mGain.gain.value = 1; _mGain.connect(ctx.destination); }
if(!_mTimer){ _mStep = 0; _mTick(); }
}catch(e){}
}
function stopMusic(){ if(_mTimer){ clearTimeout(_mTimer); _mTimer = null; } }
function duckMusic(on){ _mDuck = on ? 0.22 : 1; } /* vocea are prioritate (edge T10) */
window.__music = { tempo: musicTempoFactor, state: function(){ return { on: musicOn, playing: !!_mTimer, duck: _mDuck }; } };
var frameEl = document.getElementById('room-frame');
var introEl = document.getElementById('intro');
var skipEl = document.getElementById('skip-banner');
@@ -2025,7 +2078,7 @@ if(SPEECH){
_pickVoice();
try{ window.speechSynthesis.onvoiceschanged = _pickVoice; }catch(e){}
}
function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } }
function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } duckMusic(false); }
function voiceSay(text){
if(!SPEECH || !voiceOn || !text) return;
try{
@@ -2034,6 +2087,10 @@ function voiceSay(text){
var u = new SpeechSynthesisUtterance(String(text));
if(_roVoice){ u.voice = _roVoice; u.lang = _roVoice.lang; } else { u.lang = 'ro-RO'; }
u.rate = 1; u.pitch = 1;
/* vocea are prioritate → atenuează muzica cât timp vorbește (edge T10) */
u.onstart = function(){ duckMusic(true); };
u.onend = function(){ duckMusic(false); };
u.onerror = function(){ duckMusic(false); };
window.speechSynthesis.speak(u);
}catch(e){}
}
@@ -2158,7 +2215,7 @@ function showSkipBanner(idx, code, reason){
/* ----- Final ----- */
function showFinale(){
stopTimer(); /* jocul s-a încheiat — oprește ceasul */
stopTimer(); stopMusic(); /* jocul s-a încheiat — oprește ceasul + muzica */
hideAll(); finaleEl.classList.add('show');
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
collected.forEach(function(l,j){
@@ -2328,6 +2385,7 @@ document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); 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) */
};
@@ -2348,6 +2406,23 @@ if(SPEECH && btnVoice){
};
}
/* ----- Buton muzică în bara chrome (T10) ----- */
var btnMusic = document.getElementById('btn-music');
if(MUSIC && btnMusic){
btnMusic.hidden = false;
var _syncMusicBtn = function(){
btnMusic.innerHTML = musicOn ? '&#127925;' : '&#128263;'; /* 🎵 / 🔇 */
btnMusic.setAttribute('aria-pressed', musicOn ? 'true' : 'false');
btnMusic.title = musicOn ? 'Muzica pornita — apasa ca sa opresti' : 'Muzica oprita — apasa ca sa pornesti';
};
_syncMusicBtn();
btnMusic.onclick = function(){
musicOn = !musicOn;
if(musicOn) startMusic(); else stopMusic();
_syncMusicBtn();
};
}
buildDots();
/* ----- Resume la reload (D11) ----- */
@@ -2370,6 +2445,7 @@ buildDots();
}
owResetPlayer(); showOverworld(resumeIdx);
startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
startMusic(); /* resume → reia muzica (T10) */
})();
<\/script>
</body>

View File

@@ -38,15 +38,15 @@ body {
#chrome-timer[hidden] { display: none; }
#chrome-timer.low { color: var(--c-gold); }
#chrome-timer.expired { color: var(--c-gold); opacity: .55; }
#btn-voice {
#btn-voice, #btn-music {
width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer;
border-radius: 8px; background: rgba(255,255,255,.12); color: #fff;
font-size: 17px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
#btn-voice[hidden] { display: none; } /* id batea specificitatea UA [hidden] */
#btn-voice:hover { background: rgba(255,255,255,.22); }
#btn-voice[aria-pressed="false"] { opacity: .5; }
#btn-voice:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
#btn-voice[hidden], #btn-music[hidden] { display: none; } /* id batea specificitatea UA [hidden] */
#btn-voice:hover, #btn-music:hover { background: rgba(255,255,255,.22); }
#btn-voice[aria-pressed="false"], #btn-music[aria-pressed="false"] { opacity: .5; }
#btn-voice:focus-visible, #btn-music:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
#dots { display: flex; gap: 8px; }
#dots span {
width: 10px; height: 10px; border-radius: 50%;
@@ -216,6 +216,7 @@ body {
<span id="chrome-title">Comoara ascunsa</span>
<div class="sp"></div>
<span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span>
<button id="btn-music" type="button" aria-label="Muzica de fundal" hidden>&#127925;</button>
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>&#128266;</button>
<div id="dots" role="group" aria-label="Progres camere"></div>
</div>
@@ -335,6 +336,56 @@ function startTimer(){
if(!_timerInt && !_timerExpired) _timerInt = setInterval(tickTimer, 1000);
}
/* ----- Muzică ambient (T10) — opt-in MASTER.music. Orchestrator-only: părintele
deține AudioContext (reutilizează beep._ctx, deblocat de gestul global); camerele
nu știu de muzică. Arpegiu calm pe pentatonică minoră; tempo ACCELEREAZĂ sub 1 min
(legat de Timer Calm). Se atenuează (duck) cât timp vocea vorbește. Fallback:
fără AudioContext → no-op, butonul rămâne ascuns (zero penalizare). ----- */
var MUSIC = !!MASTER.music;
var musicOn = MUSIC;
var _mGain = null, _mTimer = null, _mStep = 0, _mDuck = 1;
var _MSCALE = [0, 3, 5, 7, 10]; /* pentatonică minoră (semitonuri) */
function _mFreq(semi){ return 220 * Math.pow(2, semi / 12); }
function musicTempoFactor(){
/* 1.0 normal → ~1.8 pe ultimul minut (accelerare progresivă) */
if(TIMER_SEC <= 0 || !_deadline) return 1;
var rem = (_deadline - Date.now()) / 1000;
if(rem < 0) return 1.8;
if(rem > 60) return 1;
return 1 + (1 - rem / 60) * 0.8;
}
function _mTick(){
if(!musicOn || !_mGain){ _mTimer = null; return; }
var ctx = beep._ctx;
if(ctx){
try{
var oct = (Math.floor(_mStep / _MSCALE.length) % 2) ? 12 : 0;
var semi = _MSCALE[_mStep % _MSCALE.length] + oct;
var o = ctx.createOscillator(), g = ctx.createGain(), t = ctx.currentTime;
o.type = 'sine'; o.frequency.value = _mFreq(semi);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.05 * _mDuck, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.55);
o.connect(g); g.connect(_mGain);
o.start(t); o.stop(t + 0.6);
}catch(e){}
}
_mStep++;
_mTimer = setTimeout(_mTick, 520 / musicTempoFactor()); /* mai rapid când tempo crește */
}
function startMusic(){
if(!musicOn) return;
try{
var ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)());
if(ctx.state === 'suspended') ctx.resume();
if(!_mGain){ _mGain = ctx.createGain(); _mGain.gain.value = 1; _mGain.connect(ctx.destination); }
if(!_mTimer){ _mStep = 0; _mTick(); }
}catch(e){}
}
function stopMusic(){ if(_mTimer){ clearTimeout(_mTimer); _mTimer = null; } }
function duckMusic(on){ _mDuck = on ? 0.22 : 1; } /* vocea are prioritate (edge T10) */
window.__music = { tempo: musicTempoFactor, state: function(){ return { on: musicOn, playing: !!_mTimer, duck: _mDuck }; } };
var frameEl = document.getElementById('room-frame');
var introEl = document.getElementById('intro');
var skipEl = document.getElementById('skip-banner');
@@ -429,7 +480,7 @@ if(SPEECH){
_pickVoice();
try{ window.speechSynthesis.onvoiceschanged = _pickVoice; }catch(e){}
}
function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } }
function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } duckMusic(false); }
function voiceSay(text){
if(!SPEECH || !voiceOn || !text) return;
try{
@@ -438,6 +489,10 @@ function voiceSay(text){
var u = new SpeechSynthesisUtterance(String(text));
if(_roVoice){ u.voice = _roVoice; u.lang = _roVoice.lang; } else { u.lang = 'ro-RO'; }
u.rate = 1; u.pitch = 1;
/* vocea are prioritate → atenuează muzica cât timp vorbește (edge T10) */
u.onstart = function(){ duckMusic(true); };
u.onend = function(){ duckMusic(false); };
u.onerror = function(){ duckMusic(false); };
window.speechSynthesis.speak(u);
}catch(e){}
}
@@ -562,7 +617,7 @@ function showSkipBanner(idx, code, reason){
/* ----- Final ----- */
function showFinale(){
stopTimer(); /* jocul s-a încheiat — oprește ceasul */
stopTimer(); stopMusic(); /* jocul s-a încheiat — oprește ceasul + muzica */
hideAll(); finaleEl.classList.add('show');
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
collected.forEach(function(l,j){
@@ -732,6 +787,7 @@ document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); 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) */
};
@@ -752,6 +808,23 @@ if(SPEECH && btnVoice){
};
}
/* ----- Buton muzică în bara chrome (T10) ----- */
var btnMusic = document.getElementById('btn-music');
if(MUSIC && btnMusic){
btnMusic.hidden = false;
var _syncMusicBtn = function(){
btnMusic.innerHTML = musicOn ? '&#127925;' : '&#128263;'; /* 🎵 / 🔇 */
btnMusic.setAttribute('aria-pressed', musicOn ? 'true' : 'false');
btnMusic.title = musicOn ? 'Muzica pornita — apasa ca sa opresti' : 'Muzica oprita — apasa ca sa pornesti';
};
_syncMusicBtn();
btnMusic.onclick = function(){
musicOn = !musicOn;
if(musicOn) startMusic(); else stopMusic();
_syncMusicBtn();
};
}
buildDots();
/* ----- Resume la reload (D11) ----- */
@@ -774,6 +847,7 @@ buildDots();
}
owResetPlayer(); showOverworld(resumeIdx);
startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
startMusic(); /* resume → reia muzica (T10) */
})();
</script>
</body>

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ă.
## Ownership
- `tests/smoke.mjs` — unicul fișier de teste (~29 teste).
- `tests/smoke.mjs` — unicul fișier de teste (~30 teste).
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
## Local Contracts
@@ -18,9 +18,9 @@ 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`
(15 — 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).
- **Status țintă: 29/29 PASS.**
(16 — 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).
- **Status țintă: 30/30 PASS.**
## Work Guidance
- 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
```bash
npx playwright test tests/smoke.mjs # 29/29
npx playwright test tests/smoke.mjs # 30/30
npx playwright test tests/smoke.mjs --grep @regresie
npx playwright test tests/smoke.mjs --grep @campanie
```

View File

@@ -739,6 +739,58 @@ test.describe('Campanie E2E @campanie', () => {
expect(errors, errors.join('\n')).toHaveLength(0);
});
test('muzica ambient — opt-in, porneste la start, tempo accelereaza sub 1 min, duck pe voce, toggle (T10)',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
cfg.music = true;
cfg.timerMin = 1; /* timer pornit → tempo poate accelera */
const tmpPath = await writeCampaignHtml(page, cfg, 'music');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Butonul de muzica vizibil (opt-in activ); muzica inca neporita
await expect(gp.locator('#btn-music')).toBeVisible();
expect(await gp.evaluate(() => window.__music.state().playing), 'inca neporita pe intro').toBe(false);
await gp.locator('#btn-start').click();
// Dupa start: muzica ruleaza, buton apasat
await gp.waitForFunction(() => window.__music.state().playing === true, null, { timeout: 4000 });
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'true');
// Tempo: 1.0 cand >60s ramase; creste progresiv sub 1 min (citit determinist)
const tempos = await gp.evaluate(() => {
const f = window.__music.tempo;
_deadline = Date.now() + 90000; const t90 = f();
_deadline = Date.now() + 30000; const t30 = f();
_deadline = Date.now() + 1000; const t1 = f();
return { t90, t30, t1 };
});
expect(tempos.t90).toBeCloseTo(1, 1);
expect(tempos.t30, 'accelereaza sub 1 min').toBeGreaterThan(tempos.t90);
expect(tempos.t1, 'mai rapid spre expirare').toBeGreaterThan(tempos.t30);
// Duck: vocea are prioritate → atenueaza muzica
const ducked = await gp.evaluate(() => { duckMusic(true); return window.__music.state().duck; });
const unducked = await gp.evaluate(() => { duckMusic(false); return window.__music.state().duck; });
expect(ducked, 'duck activ < 1').toBeLessThan(1);
expect(unducked, 'duck dezactivat = 1').toBe(1);
// Toggle off din buton → se opreste
await gp.locator('#btn-music').click();
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'false');
expect(await gp.evaluate(() => window.__music.state().playing), 'oprit dupa toggle').toBe(false);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 3: Camera moartă — timeout 4s → skip-banner + cod eroare
// ─────────────────────────────────────────────────────────────────────