Timer Calm (T10 / §Design pct.10): ceas campanie opt-in
Ceas M:SS in bara chrome a campaniei. Opt-in din builder (camp "Timp limita (minute)", default 0 = fara; cleanState coerce 0..120). - porneste exact la "Incepe aventura" (intro necronometrat) - deadline ABSOLUT in sessionStorage -> resume nu reseteaza ceasul - sub 1 min -> auriu (.low); expirare -> ingheata 0:00 + marcaj discret (.expired), jocul curge nestingherit (zero penalizare, stelele raman) - fara rosu pulsant (public copii) -> reduced-motion safe by default - exemplu-campanie.html regenerat (ramane fara timer - opt-in, ca vocea) Fundatie pentru muzica T10 (accelerare sub 1 min) + footer diploma. Test nou (smoke 29/29): format M:SS, prag auriu, freeze la expirare, jocul continua dupa expirare, resume pastreaza ceasul. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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ă: 28/28
|
||||
npx playwright test tests/smoke.mjs # suita completă: 29/29
|
||||
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
|
||||
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14
|
||||
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 15
|
||||
```
|
||||
|
||||
## Durable Rules (repo-wide)
|
||||
|
||||
@@ -231,7 +231,10 @@ Mesajul creatorului
|
||||
|
||||
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
|
||||
|
||||
> Implementare în T10/PR2.
|
||||
> **LIVRAT** (2026-06-13). Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără).
|
||||
> Implementare: `#chrome-timer` în bara chrome; `startTimer/tickTimer/stopTimer`; deadline absolut
|
||||
> în `sessionStorage` (`_DEADLINE_KEY`). Sub 1 min → `.low` (auriu); expirat → `.expired` (auriu, opac).
|
||||
> Test smoke „timer calm" (format, gold, freeze, resume păstrează ceasul).
|
||||
|
||||
- Pornește **exact** la click „Începe aventura" (intro necronometrat)
|
||||
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)
|
||||
|
||||
14
TODOS.md
14
TODOS.md
@@ -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 LIVRAT — vezi §dedicată mai jos.)
|
||||
Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 + Timer Calm LIVRATE — vezi §§ mai jos.)
|
||||
|
||||
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
|
||||
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
|
||||
@@ -96,7 +96,17 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială)
|
||||
|
||||
## Post-PR1 (după ship-ul campaniei)
|
||||
|
||||
### Muzică accelerată la timer (PR2 / T10)
|
||||
### [x] Timer Calm (§Design pct.10 / T10) — LIVRAT (2026-06-13)
|
||||
Ceas M:SS în bara chrome a campaniei. Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără;
|
||||
`cleanState` coerce la întreg 0..120). Pornește la „Începe aventura" (intro necronometrat); deadline
|
||||
ABSOLUT în `sessionStorage` (`_DEADLINE_KEY`) → resume-ul (reload mid-campanie) NU resetează ceasul.
|
||||
Sub 1 minut → auriu (`.low`); la expirare îngheață pe `0:00` + marcaj discret (`.expired`, auriu opac),
|
||||
jocul curge nestingherit (zero penalizare, stelele rămân). Fără roșu pulsant (public copii) → reduced-motion
|
||||
safe by default. `exemplu-campanie.html` regenerat (rămâne fără timer — opt-in, ca vocea). Verificat:
|
||||
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).
|
||||
|
||||
@@ -135,6 +135,9 @@
|
||||
<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 — citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label>
|
||||
<label>Timp limita (minute, 0 = fara) — 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 — jocul continua, fara penalizare.</div>
|
||||
<label>Cuvantul final (din literele puzzle-urilor)</label>
|
||||
<div class="word" id="finalWord"> </div>
|
||||
<div class="help">Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.</div>
|
||||
@@ -176,6 +179,7 @@ const defaultState = () => ({
|
||||
style: 'classic',
|
||||
charName: 'Alex',
|
||||
voice: 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!',
|
||||
puzzles: [
|
||||
@@ -407,6 +411,7 @@ $('#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 => {
|
||||
delete p._closed;
|
||||
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
|
||||
@@ -1621,6 +1626,14 @@ body {
|
||||
}
|
||||
#chrome-title { font-size: 15px; font-weight: 700; }
|
||||
#chrome .sp { flex: 1; }
|
||||
/* Timer Calm (§Design pct.10) — neutru; auriu sub 1 min; înghețat la expirare (fără roșu pulsant) */
|
||||
#chrome-timer {
|
||||
font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px;
|
||||
color: var(--c-ink); letter-spacing: .02em; min-width: 3.1em; text-align: right;
|
||||
}
|
||||
#chrome-timer[hidden] { display: none; }
|
||||
#chrome-timer.low { color: var(--c-gold); }
|
||||
#chrome-timer.expired { color: var(--c-gold); opacity: .55; }
|
||||
#btn-voice {
|
||||
width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer;
|
||||
border-radius: 8px; background: rgba(255,255,255,.12); color: #fff;
|
||||
@@ -1788,6 +1801,7 @@ body {
|
||||
@media (max-width: 599px) {
|
||||
#chrome { height: 40px; min-height: 40px; }
|
||||
#chrome-title { font-size: 13px; }
|
||||
#chrome-timer { font-size: 13px; }
|
||||
#dots span { width: 8px; height: 8px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1797,6 +1811,7 @@ body {
|
||||
<div id="chrome">
|
||||
<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-voice" type="button" aria-label="Naratiune vocala" hidden>🔊</button>
|
||||
<div id="dots" role="group" aria-label="Progres camere"></div>
|
||||
</div>
|
||||
@@ -1883,7 +1898,38 @@ function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v))
|
||||
function saveProgress(){
|
||||
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), skipped: skipped });
|
||||
}
|
||||
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); }catch(e){} }
|
||||
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} }
|
||||
|
||||
/* ----- Timer Calm (§Design pct.10) — ceas M:SS în chrome.
|
||||
Pornește la „Începe aventura"; deadline ABSOLUT în sessionStorage → resume-ul
|
||||
(reload mid-campanie) NU resetează ceasul. Sub 1 min → auriu. La expirare îngheață
|
||||
pe 0:00 + marcaj discret, jocul curge nestingherit (zero penalizare). ----- */
|
||||
var TIMER_SEC = (+MASTER.timerMin || 0) * 60;
|
||||
var _DEADLINE_KEY = _RESUME_KEY + '-dl';
|
||||
var timerEl = document.getElementById('chrome-timer');
|
||||
var _deadline = 0, _timerInt = null, _timerExpired = false;
|
||||
function _fmt(s){ var m = Math.floor(s/60), ss = s % 60; return m + ':' + (ss < 10 ? '0' : '') + ss; }
|
||||
function tickTimer(){
|
||||
if(!_deadline){ return; }
|
||||
var rem = Math.round((_deadline - Date.now()) / 1000);
|
||||
if(rem <= 0){
|
||||
rem = 0;
|
||||
if(!_timerExpired){ _timerExpired = true; timerEl.classList.add('expired'); timerEl.title = 'Timpul a expirat — jocul continua'; }
|
||||
if(_timerInt){ clearInterval(_timerInt); _timerInt = null; }
|
||||
}
|
||||
timerEl.textContent = _fmt(rem);
|
||||
if(rem <= 60) timerEl.classList.add('low');
|
||||
}
|
||||
function stopTimer(){ if(_timerInt){ clearInterval(_timerInt); _timerInt = null; } }
|
||||
function startTimer(){
|
||||
if(TIMER_SEC <= 0) return;
|
||||
timerEl.hidden = false;
|
||||
var existing = 0; try{ existing = +sessionStorage.getItem(_DEADLINE_KEY) || 0; }catch(e){}
|
||||
if(existing > 0){ _deadline = existing; } /* resume → păstrează ceasul */
|
||||
else { _deadline = Date.now() + TIMER_SEC * 1000; try{ sessionStorage.setItem(_DEADLINE_KEY, String(_deadline)); }catch(e){} }
|
||||
tickTimer();
|
||||
if(!_timerInt && !_timerExpired) _timerInt = setInterval(tickTimer, 1000);
|
||||
}
|
||||
|
||||
var frameEl = document.getElementById('room-frame');
|
||||
var introEl = document.getElementById('intro');
|
||||
@@ -2112,6 +2158,7 @@ function showSkipBanner(idx, code, reason){
|
||||
|
||||
/* ----- Final ----- */
|
||||
function showFinale(){
|
||||
stopTimer(); /* jocul s-a încheiat — oprește ceasul */
|
||||
hideAll(); finaleEl.classList.add('show');
|
||||
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
|
||||
collected.forEach(function(l,j){
|
||||
@@ -2280,6 +2327,7 @@ document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nSty
|
||||
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) */
|
||||
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
|
||||
};
|
||||
|
||||
@@ -2321,6 +2369,7 @@ buildDots();
|
||||
showFinale(); return;
|
||||
}
|
||||
owResetPlayer(); showOverworld(resumeIdx);
|
||||
startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
|
||||
})();
|
||||
<\/script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 (~28 teste).
|
||||
- `tests/smoke.mjs` — unicul fișier de teste (~29 teste).
|
||||
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
||||
|
||||
## Local Contracts
|
||||
@@ -16,11 +16,11 @@ până la ecranul final, fără erori de consolă.
|
||||
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`.
|
||||
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
||||
fiecare test asertează `errors.length === 0` la final.
|
||||
- **Tag-uri:** `@regresie` (15 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
|
||||
bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` (12 — intro→hartă→camere→final, resume,
|
||||
cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10,
|
||||
a11y tap/aria/reduced-motion, navigare overworld).
|
||||
- **Status țintă: 28/28 PASS.**
|
||||
- **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.**
|
||||
|
||||
## 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 # 28/28
|
||||
npx playwright test tests/smoke.mjs # 29/29
|
||||
npx playwright test tests/smoke.mjs --grep @regresie
|
||||
npx playwright test tests/smoke.mjs --grep @campanie
|
||||
```
|
||||
|
||||
@@ -671,6 +671,74 @@ test.describe('Campanie E2E @campanie', () => {
|
||||
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('timer calm — porneste la start, auriu sub 1 min, ingheata la expirare, resume pastreaza ceasul (T10/§Design pct.10) @campanie',
|
||||
async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
const cfg = campaignCfg(3, 'classic');
|
||||
cfg.timerMin = 1;
|
||||
const tmpPath = await writeCampaignHtml(page, cfg, 'timer');
|
||||
const gp = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await gp.goto('file://' + tmpPath);
|
||||
|
||||
// Intro necronometrat — ceasul e ascuns pana la start
|
||||
expect(await gp.locator('#chrome-timer').isVisible(), 'timer ascuns pe intro').toBe(false);
|
||||
|
||||
await gp.locator('#btn-start').click();
|
||||
|
||||
// Dupa start: vizibil, format M:SS, aproape de 1:00
|
||||
const timer = gp.locator('#chrome-timer');
|
||||
await expect(timer).toBeVisible();
|
||||
await expect(timer).toHaveText(/^\d:\d\d$/);
|
||||
const t0 = await timer.textContent();
|
||||
const sec0 = (+t0.split(':')[0]) * 60 + (+t0.split(':')[1]);
|
||||
expect(sec0, 'ceasul porneste ~1:00').toBeGreaterThan(50);
|
||||
expect(sec0).toBeLessThanOrEqual(60);
|
||||
|
||||
// Deadline ABSOLUT salvat in sessionStorage (resume pastreaza ceasul)
|
||||
const dl = await gp.evaluate(() => +sessionStorage.getItem(_DEADLINE_KEY));
|
||||
expect(dl, 'deadline absolut in sessionStorage').toBeGreaterThan(Date.now());
|
||||
|
||||
// Sub 1 minut → auriu (.low). Manipulam deadline determinist.
|
||||
await gp.evaluate(() => { _deadline = Date.now() + 5000; tickTimer(); });
|
||||
await expect(timer).toHaveText('0:05');
|
||||
await expect(timer).toHaveClass(/low/);
|
||||
|
||||
// Expirare → ingheata pe 0:00 + .expired; jocul curge nestingherit
|
||||
await gp.evaluate(() => { _deadline = Date.now() - 1000; tickTimer(); });
|
||||
await expect(timer).toHaveText('0:00');
|
||||
await expect(timer).toHaveClass(/expired/);
|
||||
|
||||
// Jocul continua dupa expirare (zero penalizare): rezolva camera 0
|
||||
await enterRoom(gp, 0);
|
||||
await solveRoom(gp, 'classic', 'r1');
|
||||
await waitOverworld(gp);
|
||||
|
||||
// Resume pastreaza ceasul: suprascriem deadline-ul cu o valoare cunoscuta, reload
|
||||
await gp.evaluate(() => { sessionStorage.setItem(_DEADLINE_KEY, String(Date.now() + 8000)); });
|
||||
await gp.reload();
|
||||
await gp.waitForLoadState('domcontentloaded');
|
||||
await gp.waitForFunction(
|
||||
() => window.__ow && window.__ow.state.active &&
|
||||
document.getElementById('overworld')?.classList.contains('show'),
|
||||
null, { timeout: 5000 }
|
||||
);
|
||||
|
||||
// Ceasul reia de la deadline-ul salvat (~0:08), NU resetat la 1:00
|
||||
await expect(gp.locator('#chrome-timer')).toBeVisible();
|
||||
const tR = await gp.locator('#chrome-timer').textContent();
|
||||
const secR = (+tR.split(':')[0]) * 60 + (+tR.split(':')[1]);
|
||||
expect(secR, 'resume reia ceasul (nu reset la 60)').toBeLessThan(55);
|
||||
expect(secR).toBeGreaterThan(0);
|
||||
|
||||
} 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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user