Iterația 3: Joc-în-URL + QR (compresie, player hash, encoder QR, UI share)
Stage 1: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream deflate-raw,
offline, file://); `SNIP.compressJs` cu helpers inflate (doubled backslashes).
Stage 2: `campaignShell({tplJson,masterExpr,titleExpr,nStyles,bootMode})` refactor;
gameCampaign = wrapper subțire; bootMode='inline' (nop) | 'hash' (player).
Stage 3: `playerHTML()` — toate 5 motoare inline; boot async cu `(async function(){})()`
(fix: lipsea `function`, eroare Unexpected token '{' în Chromium); MASTER din
location.hash deflate-raw; orchestrator în <script type="text/plain" id="run">.
Stage 4: Encoder QR vanilla JS — GF(256), Reed-Solomon ECC L, byte mode, versiuni 1-22,
8 măști + penalty, BCH format/version. `makeQrSvg(text, opts)` → SVG.
Stage 5: UI builder — fieldset distribuie, #btnShare/#btnCopyLink/#btnDownloadPlayer/
#btnPrintQr, #qrBox, #qrCard (print A4). baseUrl în state, deleted din cleanState().
Tests: 41/41 (6 noi @share). Fix test player: necesită __ow.enterDoor(0) după btn-start
(overworld first, then enter room). Demo files: restaurate din git (configs hardcodate în @regresie).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,9 +20,10 @@ 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ă: 35/35
|
npx playwright test tests/smoke.mjs # suita completă: 41/41
|
||||||
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: 21
|
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21
|
||||||
|
npx playwright test tests/smoke.mjs --grep @share # Iterația 3: 6
|
||||||
```
|
```
|
||||||
|
|
||||||
## Durable Rules (repo-wide)
|
## Durable Rules (repo-wide)
|
||||||
@@ -30,11 +31,12 @@ npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21
|
|||||||
- **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe
|
- **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe
|
||||||
`file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/`
|
`file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/`
|
||||||
sunt **gitignored** — doar dev tooling, nu fac parte din produs.
|
sunt **gitignored** — doar dev tooling, nu fac parte din produs.
|
||||||
- **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~1960 linii), pe secțiuni
|
- **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~3200 linii), pe secțiuni
|
||||||
comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`.
|
comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`.
|
||||||
- **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare:
|
- **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare:
|
||||||
`gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează
|
`gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează
|
||||||
un string HTML complet, standalone.
|
un string HTML complet, standalone. `playerHTML()` generează player universal (hash-mode, toate 5
|
||||||
|
motoare inline, MASTER din `location.hash` comprimat deflate-raw+base64url).
|
||||||
- **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`,
|
- **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`,
|
||||||
`finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în
|
`finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în
|
||||||
TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit.
|
TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit.
|
||||||
|
|||||||
27
TODOS.md
27
TODOS.md
@@ -243,13 +243,28 @@ choice=1/opțiune); `data-fb`/`data-bkey` handler în input listener; `adventure
|
|||||||
|
|
||||||
Verificat: smoke 35/35 (4 teste noi: branch-jump, resume non-contiguu, regression non-adventure, tf branch).
|
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 ✅ LIVRAT (2026-06-14)
|
||||||
*(depinde de măsurarea dimensiunii JSON comprimate)*
|
|
||||||
|
|
||||||
- `gameHTML(cfg)` → URL data: sau LZW/gzip → QR code printabil.
|
**Scope livrat:**
|
||||||
- Open Question 2 din design doc: câte puzzle-uri încap în 2KB (URL QR L)?
|
- [x] **Stage 1 — Compresie URL**: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream native,
|
||||||
- Alternative: GitHub Pages export automat; sau link scurt cu backend minimal.
|
offline, `file://`). `CS_OK` guard. `SNIP.compressJs` cu helpers inflate (doubled backslashes).
|
||||||
- Referință: design doc §NOT in scope "Joc-în-URL + QR".
|
- [x] **Stage 2 — Refactor `campaignShell`**: parametrizat `bootMode='inline'|'hash'`. Zero schimbare
|
||||||
|
de comportament pentru inline (35/35 smoke). `chrome-title` + `document.title` setate din MASTER.
|
||||||
|
- [x] **Stage 3 — `playerHTML()` + boot din hash**: player universal (toate 5 motoare); async IIFE
|
||||||
|
(corect `(async function(){...})()`) decomprimă hash → setează MASTER → apendează orchestratorul
|
||||||
|
din `<script type="text/plain" id="run">`. No-hash → "Niciun joc în acest link."
|
||||||
|
- [x] **Stage 4 — Encoder QR**: GF(256), Reed-Solomon, byte mode, ECC L, auto-versiune 1-22,
|
||||||
|
selecție mască + BCH format/version. `makeQrSvg(text, opts)` → SVG string sau `null`.
|
||||||
|
- [x] **Stage 5 — UI builder**: fieldset „Distribuie (link+QR)", `#btnShare`/`#btnCopyLink`/
|
||||||
|
`#btnDownloadPlayer`/`#btnPrintQr`, `#qrBox`, `#qrCard` (print A4). `baseUrl` în state
|
||||||
|
(deleted din `cleanState()` → nu intră în payload). Butoane disabled dacă `!CS_OK`.
|
||||||
|
- [x] **Stage 6 — Docs**: 41/41 smoke, TODOS/AGENTS actualizate.
|
||||||
|
|
||||||
|
**DEFER** (fast-follow): Import-din-hash în builder (`escape-builder.html#hash` → editabil).
|
||||||
|
Reutilizează inflate+Save/Load; adaugă cale async la boot-ul builder (azi sincron).
|
||||||
|
|
||||||
|
Verificat: smoke 41/41. Capabilitate: 12+ puzzle-uri → ~636B → QR v10 L; 30+ puzzle-uri → ~750B.
|
||||||
|
Scan manual cu telefon real: TODO (notat în HANDOFF).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,32 @@
|
|||||||
#addPuzzle { width: 100%; padding: 10px; border-style: dashed; color: var(--muted); }
|
#addPuzzle { width: 100%; padding: 10px; border-style: dashed; color: var(--muted); }
|
||||||
#addPuzzle:hover { color: var(--accent); }
|
#addPuzzle:hover { color: var(--accent); }
|
||||||
input[type=color] { border: 1px solid var(--line); border-radius: 7px; height: 34px; width: 100%; padding: 2px; background: #fff; }
|
input[type=color] { border: 1px solid var(--line); border-radius: 7px; height: 34px; width: 100%; padding: 2px; background: #fff; }
|
||||||
|
/* Share section */
|
||||||
|
.share-url { width: 100%; font: 12px/1.4 ui-monospace,monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px; background: #f9fafb; color: var(--ink); resize: none; }
|
||||||
|
.share-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.share-btns button { flex: 1; min-width: 110px; font-size: 12px; padding: 6px 10px; }
|
||||||
|
#qrBox svg { display: block; margin: 10px auto 0; max-width: 180px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
|
||||||
|
/* QR Card print */
|
||||||
|
#qrCard {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 9999;
|
||||||
|
background: #fff; color: #111;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
padding: 30mm 20mm; gap: 12px; text-align: center;
|
||||||
|
}
|
||||||
|
#qrCard.show { display: flex; }
|
||||||
|
#qrCard .qr-title { font-size: 22px; font-weight: 900; margin: 0; }
|
||||||
|
#qrCard .qr-instr { font-size: 13px; color: #555; max-width: 70mm; line-height: 1.5; }
|
||||||
|
#qrCard .qr-url { font-family: ui-monospace,monospace; font-size: 9px; color: #555; word-break: break-all; max-width: 100mm; margin-top: 4px; }
|
||||||
|
#qrCard .qr-for { font-size: 14px; font-weight: 700; }
|
||||||
|
#qrCard svg { width: 60mm; height: 60mm; }
|
||||||
|
#qrCard .qr-back { margin-top: 16px; font-size: 12px; }
|
||||||
|
@media print {
|
||||||
|
body * { visibility: hidden; }
|
||||||
|
#qrCard, #qrCard * { visibility: visible; }
|
||||||
|
#qrCard { position: fixed; inset: 0; display: flex !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||||
|
#qrCard .qr-back { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -152,6 +178,21 @@
|
|||||||
<div id="puzzleList"></div>
|
<div id="puzzleList"></div>
|
||||||
<button id="addPuzzle">+ Adauga puzzle</button>
|
<button id="addPuzzle">+ Adauga puzzle</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset id="fsShare">
|
||||||
|
<legend>Distribuie (link + QR)</legend>
|
||||||
|
<label>URL baza player (GitHub Pages)</label>
|
||||||
|
<input type="text" id="gBaseUrl" data-g="baseUrl" placeholder="https://USERNAME.github.io/escape-builder/play.html">
|
||||||
|
<div class="help" style="margin-bottom:8px">Inlocuieste USERNAME cu contul tau GitHub. <a href="https://pages.github.com/" target="_blank" rel="noopener">Cum activezi GitHub Pages →</a></div>
|
||||||
|
<div class="share-btns">
|
||||||
|
<button id="btnShare">Genereaza QR / link</button>
|
||||||
|
<button id="btnCopyLink">Copiaza link</button>
|
||||||
|
<button id="btnDownloadPlayer">Descarca player.html</button>
|
||||||
|
<button id="btnPrintQr">Printeaza cardul QR</button>
|
||||||
|
</div>
|
||||||
|
<div id="qrBox"></div>
|
||||||
|
<textarea id="shareUrl" class="share-url" rows="2" readonly placeholder="Link-ul va aparea aici dupa generare..."></textarea>
|
||||||
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="previewPane">
|
<section id="previewPane">
|
||||||
@@ -164,6 +205,16 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Card QR print (vizibil doar la print sau la preview) -->
|
||||||
|
<div id="qrCard" role="document" aria-label="Card QR de distribuit">
|
||||||
|
<p class="qr-title" id="qrCardTitle"></p>
|
||||||
|
<div id="qrCardSvg"></div>
|
||||||
|
<p class="qr-instr">Scaneaza codul QR cu telefonul<br>pentru a juca escape room-ul!</p>
|
||||||
|
<p class="qr-url" id="qrCardUrl"></p>
|
||||||
|
<p class="qr-for" id="qrCardFor"></p>
|
||||||
|
<button class="qr-back" id="btnQrBack">Inchide previzualizarea</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -187,6 +238,7 @@ const defaultState = () => ({
|
|||||||
music: false,
|
music: false,
|
||||||
adventure: false,
|
adventure: false,
|
||||||
timerMin: 0,
|
timerMin: 0,
|
||||||
|
baseUrl: 'https://USERNAME.github.io/escape-builder/play.html',
|
||||||
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!',
|
||||||
puzzles: [
|
puzzles: [
|
||||||
@@ -462,6 +514,7 @@ $('#btnReload').addEventListener('click', refreshPreview);
|
|||||||
|
|
||||||
function cleanState() {
|
function cleanState() {
|
||||||
const s = JSON.parse(JSON.stringify(state));
|
const s = JSON.parse(JSON.stringify(state));
|
||||||
|
delete s.baseUrl; /* metadata de share — nu intră în payload/QR/JSON exportat */
|
||||||
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 */
|
||||||
const nP = s.puzzles.length;
|
const nP = s.puzzles.length;
|
||||||
s.puzzles.forEach((p, pi) => {
|
s.puzzles.forEach((p, pi) => {
|
||||||
@@ -499,6 +552,355 @@ function download(name, content, mime) {
|
|||||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* compresie URL (Iteratia 3) */
|
||||||
|
const CS_OK = typeof CompressionStream !== 'undefined' && typeof DecompressionStream !== 'undefined';
|
||||||
|
|
||||||
|
function bytesToB64url(bytes) {
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += 8192) {
|
||||||
|
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 8192)));
|
||||||
|
}
|
||||||
|
return btoa(chunks.join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlToBytes(str) {
|
||||||
|
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deflateToBase64url(str) {
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
const cs = new CompressionStream('deflate-raw');
|
||||||
|
const writer = cs.writable.getWriter();
|
||||||
|
writer.write(bytes);
|
||||||
|
writer.close();
|
||||||
|
const buf = await new Response(cs.readable).arrayBuffer();
|
||||||
|
return bytesToB64url(new Uint8Array(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inflateFromBase64url(str) {
|
||||||
|
const bytes = b64urlToBytes(str);
|
||||||
|
const ds = new DecompressionStream('deflate-raw');
|
||||||
|
const writer = ds.writable.getWriter();
|
||||||
|
writer.write(bytes);
|
||||||
|
writer.close();
|
||||||
|
const buf = await new Response(ds.readable).arrayBuffer();
|
||||||
|
return new TextDecoder().decode(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR encoder (Iteratia 3, doar builder) — byte mode, ECC L, versiuni 1-22, 8 masti + penalti */
|
||||||
|
(function(){
|
||||||
|
|
||||||
|
/* GF(256) cu polinom primitiv 0x11d */
|
||||||
|
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
||||||
|
(function(){
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) {
|
||||||
|
EXP[i] = x; LOG[x] = i; x = x < 128 ? x << 1 : (x << 1) ^ 0x11d;
|
||||||
|
}
|
||||||
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||||
|
})();
|
||||||
|
function gmul(a, b) { return a && b ? EXP[LOG[a] + LOG[b]] : 0; }
|
||||||
|
|
||||||
|
/* Reed-Solomon: generator poly de grad n */
|
||||||
|
function rsGenerator(n) {
|
||||||
|
let g = [1];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ng = new Array(g.length + 1).fill(0);
|
||||||
|
for (let j = 0; j < g.length; j++) { ng[j] ^= g[j]; ng[j+1] ^= gmul(g[j], EXP[i]); }
|
||||||
|
g = ng;
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function rsRemainder(data, gen) {
|
||||||
|
const rem = new Array(gen.length - 1).fill(0);
|
||||||
|
for (const byte of data) {
|
||||||
|
const factor = byte ^ rem.shift(); rem.push(0);
|
||||||
|
for (let j = 0; j < rem.length; j++) rem[j] ^= gmul(gen[j+1], factor);
|
||||||
|
}
|
||||||
|
return rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabele versiune 1-22, ECC L
|
||||||
|
[dataCodewords, ecCodewordsPerBlock, group1Blocks, group1Words, group2Blocks, group2Words] */
|
||||||
|
const VER_TABLE = [
|
||||||
|
null, /* v0 placeholder */
|
||||||
|
[19,7,1,19,0,0],[34,10,1,34,0,0],[55,15,1,55,0,0],[80,20,2,40,0,0],
|
||||||
|
[108,26,2,54,0,0],[136,18,2,44,0,0],[156,20,4,32,0,0],[194,24,4,40,0,0],
|
||||||
|
[232,30,4,36,2,36],[274,18,6,36,2,32],[324,20,6,36,4,32],[370,24,6,36,4,36],
|
||||||
|
[428,26,8,37,1,37],[461,30,8,40,1,38],[523,22,10,40,2,38],[589,24,12,40,2,37],
|
||||||
|
[647,28,16,37,0,0],[721,30,12,38,6,37],[795,28,17,35,1,35],[861,28,19,35,2,34],
|
||||||
|
[932,28,16,38,6,38],[1006,30,17,36,6,36],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Centru alignment patterns per versiune */
|
||||||
|
const ALIGN_POS = [
|
||||||
|
[],[],[6,18],[6,22],[6,26],[6,30],[6,34],
|
||||||
|
[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],
|
||||||
|
[6,26,46,66],[6,26,48,70],[6,30,50,70],[6,30,54,74],[6,30,56,74],
|
||||||
|
[6,34,56,76],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,74,102],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* BCH pentru format info (generator 0x537, 10 bit) */
|
||||||
|
function bchFormat(data) {
|
||||||
|
let d = data << 10;
|
||||||
|
for (let i = 4; i >= 0; i--) if (d & (1 << (i+10))) d ^= (0x537 << i);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
/* BCH pentru versiune info (generator 0x1f25, 12 bit, v>=7) */
|
||||||
|
function bchVersion(ver) {
|
||||||
|
let d = ver << 12;
|
||||||
|
for (let i = 5; i >= 0; i--) if (d & (1 << (i+12))) d ^= (0x1f25 << i);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Construieste matricea QR */
|
||||||
|
function buildMatrix(ver) {
|
||||||
|
const size = ver * 4 + 17;
|
||||||
|
const mat = Array.from({length: size}, () => new Int8Array(size).fill(-1));
|
||||||
|
const res = Array.from({length: size}, () => new Uint8Array(size)); /* 1=rezervat */
|
||||||
|
|
||||||
|
function setFinder(r, c) {
|
||||||
|
for (let dr = -1; dr <= 7; dr++) for (let dc = -1; dc <= 7; dc++) {
|
||||||
|
const rr = r + dr, cc = c + dc;
|
||||||
|
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||||
|
res[rr][cc] = 1;
|
||||||
|
const inFinder = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
||||||
|
mat[rr][cc] = inFinder ? ((dr===0||dr===6||dc===0||dc===6||
|
||||||
|
(dr>=2&&dr<=4&&dc>=2&&dc<=4)) ? 1 : 0) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFinder(0,0); setFinder(0,size-7); setFinder(size-7,0);
|
||||||
|
|
||||||
|
/* Timing */
|
||||||
|
for (let i = 8; i < size-8; i++) {
|
||||||
|
mat[6][i] = mat[i][6] = (i & 1) ? 0 : 1;
|
||||||
|
res[6][i] = res[i][6] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark module */
|
||||||
|
mat[4*ver+9][8] = 1; res[4*ver+9][8] = 1;
|
||||||
|
|
||||||
|
/* Alignment patterns */
|
||||||
|
const apos = ALIGN_POS[ver] || [];
|
||||||
|
for (const ar of apos) for (const ac of apos) {
|
||||||
|
if ((ar===6&&ac===6)||(ar===6&&ac===apos[apos.length-1])||(ar===apos[apos.length-1]&&ac===6)) continue;
|
||||||
|
for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) {
|
||||||
|
res[ar+dr][ac+dc] = 1;
|
||||||
|
mat[ar+dr][ac+dc] = (Math.abs(dr)===2||Math.abs(dc)===2||(dr===0&&dc===0)) ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rezervare format + versiune */
|
||||||
|
const fmtCells = [[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],[8,7],[8,8],
|
||||||
|
[7,8],[5,8],[4,8],[3,8],[2,8],[1,8],[0,8],
|
||||||
|
[size-1,8],[size-2,8],[size-3,8],[size-4,8],[size-5,8],[size-6,8],[size-7,8],[size-8,8],
|
||||||
|
[8,size-8],[8,size-7],[8,size-6],[8,size-5],[8,size-4],[8,size-3],[8,size-2],[8,size-1]];
|
||||||
|
for (const [r,c] of fmtCells) { res[r][c] = 1; mat[r][c] = 0; }
|
||||||
|
if (ver >= 7) {
|
||||||
|
for (let i=0;i<6;i++) for (let j=0;j<3;j++) {
|
||||||
|
res[i][size-11+j]=1; mat[i][size-11+j]=0;
|
||||||
|
res[size-11+j][i]=1; mat[size-11+j][i]=0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mat, res, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeData(text, ver) {
|
||||||
|
const vtbl = VER_TABLE[ver];
|
||||||
|
const [, ecPB, g1b, g1w, g2b, g2w] = vtbl;
|
||||||
|
const totalData = vtbl[0];
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
/* Bit buffer */
|
||||||
|
const bits = [];
|
||||||
|
const pushBits = (v, n) => { for (let i=n-1;i>=0;i--) bits.push((v>>i)&1); };
|
||||||
|
pushBits(0b0100, 4); /* mode: byte */
|
||||||
|
pushBits(bytes.length, 8); /* char count (v1-9: 8 bits) — v10-26: 16 bits */
|
||||||
|
/* For v10+: char count is 16 bits in byte mode. Versions we use (1-22) span both ranges.
|
||||||
|
Versions 1-9: 8-bit count; 10-26: 16-bit count. */
|
||||||
|
/* Recalculate: already pushed 8 bits for v<=9; for v>=10 we need 16-bit. Fix: */
|
||||||
|
bits.length = 0;
|
||||||
|
pushBits(0b0100, 4);
|
||||||
|
if (ver <= 9) pushBits(bytes.length, 8); else pushBits(bytes.length, 16);
|
||||||
|
for (const b of bytes) pushBits(b, 8);
|
||||||
|
|
||||||
|
/* Terminator + pad to byte boundary */
|
||||||
|
for (let i=0;i<4&&bits.length<totalData*8;i++) bits.push(0);
|
||||||
|
while (bits.length % 8) bits.push(0);
|
||||||
|
|
||||||
|
/* Pad codewords */
|
||||||
|
const pads = [0xEC, 0x11]; let pi = 0;
|
||||||
|
while (bits.length < totalData * 8) { pushBits(pads[pi++ % 2], 8); }
|
||||||
|
|
||||||
|
/* Convert to bytes */
|
||||||
|
const data = [];
|
||||||
|
for (let i=0;i<bits.length;i+=8) data.push(bits.slice(i,i+8).reduce((a,b)=>a*2+b,0));
|
||||||
|
|
||||||
|
/* Interleave blocks */
|
||||||
|
const blocks = [];
|
||||||
|
let off = 0;
|
||||||
|
for (let i=0;i<g1b+g2b;i++) {
|
||||||
|
const w = (i<g1b) ? g1w : g2w;
|
||||||
|
blocks.push(data.slice(off, off+w)); off += w;
|
||||||
|
}
|
||||||
|
const gen = rsGenerator(ecPB);
|
||||||
|
const ecBlocks = blocks.map(b => rsRemainder(b, gen));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const maxLen = Math.max(...blocks.map(b=>b.length));
|
||||||
|
for (let i=0;i<maxLen;i++) for (const b of blocks) if (i<b.length) out.push(b[i]);
|
||||||
|
for (let i=0;i<ecPB;i++) for (const eb of ecBlocks) out.push(eb[i]);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeData(mat, res, data, size) {
|
||||||
|
const bits = [];
|
||||||
|
for (const b of data) for (let i=7;i>=0;i--) bits.push((b>>i)&1);
|
||||||
|
|
||||||
|
let bi = 0, up = true;
|
||||||
|
for (let col = size-1; col >= 0; col -= 2) {
|
||||||
|
if (col === 6) col--;
|
||||||
|
for (let ri = 0; ri < size; ri++) {
|
||||||
|
const r = up ? size-1-ri : ri;
|
||||||
|
for (let dc = 0; dc <= 1; dc++) {
|
||||||
|
const c = col - dc;
|
||||||
|
if (!res[r][c] && bi < bits.length) { mat[r][c] = bits[bi++]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
up = !up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASK_FNS = [
|
||||||
|
(r,c)=>(r+c)%2===0,(r,c)=>r%2===0,(r,c)=>c%3===0,(r,c)=>(r+c)%3===0,
|
||||||
|
(r,c)=>((Math.floor(r/2)+Math.floor(c/3))%2===0),(r,c)=>(r*c)%2+(r*c)%3===0,
|
||||||
|
(r,c)=>((r*c)%2+(r*c)%3)%2===0,(r,c)=>((r+c)%2+(r*c)%3)%2===0
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyMask(mat, res, size, mi) {
|
||||||
|
const fn = MASK_FNS[mi];
|
||||||
|
const m = mat.map(r=>r.slice());
|
||||||
|
for (let r=0;r<size;r++) for (let c=0;c<size;c++) if (!res[r][c]) m[r][c] ^= fn(r,c)?1:0;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFormat(m, size, maskIdx) {
|
||||||
|
/* ECC L = 01 */
|
||||||
|
const raw = (0b01 << 3) | maskIdx;
|
||||||
|
const fmtBits = ((raw << 10) | bchFormat(raw)) ^ 0x5412;
|
||||||
|
const seq = [[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],[8,7],[8,8],
|
||||||
|
[7,8],[5,8],[4,8],[3,8],[2,8],[1,8],[0,8]];
|
||||||
|
const seqR = [[size-1,8],[size-2,8],[size-3,8],[size-4,8],[size-5,8],[size-6,8],[size-7,8],
|
||||||
|
[8,size-8],[8,size-7],[8,size-6],[8,size-5],[8,size-4],[8,size-3],[8,size-2],[8,size-1]];
|
||||||
|
for (let i=0;i<15;i++) {
|
||||||
|
const bit = (fmtBits >> (14-i)) & 1;
|
||||||
|
m[seq[i][0]][seq[i][1]] = bit;
|
||||||
|
m[seqR[i][0]][seqR[i][1]] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeVersion(m, size, ver) {
|
||||||
|
if (ver < 7) return;
|
||||||
|
const raw = ver;
|
||||||
|
const verBits = (raw << 12) | bchVersion(raw);
|
||||||
|
for (let i=0;i<18;i++) {
|
||||||
|
const bit = (verBits >> i) & 1;
|
||||||
|
const r = Math.floor(i/3), c = size-11+i%3;
|
||||||
|
m[r][c] = bit; m[c][r] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function penaltyScore(m, size) {
|
||||||
|
let score = 0;
|
||||||
|
/* Rule 1: 5+ in a row */
|
||||||
|
for (let r=0;r<size;r++) {
|
||||||
|
for (let isCol=0;isCol<2;isCol++) {
|
||||||
|
let run=1, prev=isCol?m[0][r]:m[r][0];
|
||||||
|
for (let i=1;i<size;i++) {
|
||||||
|
const v = isCol?m[i][r]:m[r][i];
|
||||||
|
if (v===prev) { run++; if(run===5) score+=3; else if(run>5) score++; }
|
||||||
|
else { run=1; prev=v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Rule 2: 2x2 blocks */
|
||||||
|
for (let r=0;r<size-1;r++) for (let c=0;c<size-1;c++) {
|
||||||
|
const v=m[r][c]; if(v===m[r+1][c]&&v===m[r][c+1]&&v===m[r+1][c+1]) score+=3;
|
||||||
|
}
|
||||||
|
/* Rule 3: finder-like patterns */
|
||||||
|
const p1=[1,0,1,1,1,0,1,0,0,0,0], p2=[0,0,0,0,1,0,1,1,1,0,1];
|
||||||
|
for (let r=0;r<size;r++) for (let c=0;c<=size-11;c++) {
|
||||||
|
let h1=true,h2=true,v1=true,v2=true;
|
||||||
|
for (let k=0;k<11;k++) {
|
||||||
|
if(m[r][c+k]!==p1[k]) h1=false; if(m[r][c+k]!==p2[k]) h2=false;
|
||||||
|
if(m[c+k][r]!==p1[k]) v1=false; if(m[c+k][r]!==p2[k]) v2=false;
|
||||||
|
}
|
||||||
|
if(h1||h2) score+=40; if(v1||v2) score+=40;
|
||||||
|
}
|
||||||
|
/* Rule 4: dark module ratio */
|
||||||
|
let dark=0;
|
||||||
|
for (let r=0;r<size;r++) for (let c=0;c<size;c++) if(m[r][c]) dark++;
|
||||||
|
const ratio = Math.abs(dark*100/(size*size)-50);
|
||||||
|
score += Math.floor(ratio/5)*10;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQrSvg(text, opts={}) {
|
||||||
|
opts = Object.assign({ quietZone: 4 }, opts);
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const n = bytes.length;
|
||||||
|
/* Trova minimum version ECC L */
|
||||||
|
let ver = 0;
|
||||||
|
for (let v=1; v<=22; v++) {
|
||||||
|
if (VER_TABLE[v] && VER_TABLE[v][0] >= n + (v<=9?3:4)) { ver=v; break; }
|
||||||
|
}
|
||||||
|
if (!ver) return null; /* text too long */
|
||||||
|
|
||||||
|
const data = encodeData(text, ver);
|
||||||
|
const { mat, res, size } = buildMatrix(ver);
|
||||||
|
placeData(mat, res, data, size);
|
||||||
|
|
||||||
|
/* Alege masca cu penalti minim */
|
||||||
|
let bestMask = 0, bestScore = Infinity;
|
||||||
|
for (let mi=0;mi<8;mi++) {
|
||||||
|
const m2 = applyMask(mat, res, size, mi);
|
||||||
|
writeFormat(m2, size, mi);
|
||||||
|
writeVersion(m2, size, ver);
|
||||||
|
const sc = penaltyScore(m2, size);
|
||||||
|
if (sc < bestScore) { bestScore=sc; bestMask=mi; }
|
||||||
|
}
|
||||||
|
const finalMat = applyMask(mat, res, size, bestMask);
|
||||||
|
writeFormat(finalMat, size, bestMask);
|
||||||
|
writeVersion(finalMat, size, ver);
|
||||||
|
|
||||||
|
/* Genereaza SVG cu un singur <path> (run-uri per rand) */
|
||||||
|
const qz = opts.quietZone;
|
||||||
|
const total = size + 2*qz;
|
||||||
|
let d = '';
|
||||||
|
for (let r=0;r<size;r++) {
|
||||||
|
let run=0, startC=-1;
|
||||||
|
for (let c=0;c<=size;c++) {
|
||||||
|
const on = c<size && finalMat[r][c]===1;
|
||||||
|
if (on && run===0) { startC=c; run=1; }
|
||||||
|
else if (on) run++;
|
||||||
|
else if (run>0) {
|
||||||
|
d += `M${startC+qz},${r+qz}h${run}v1h-${run}z`;
|
||||||
|
run=0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" shape-rendering="crispEdges"><rect width="${total}" height="${total}" fill="#fff"/><path fill="#000" d="${d}"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeQrSvg = makeQrSvg;
|
||||||
|
})();
|
||||||
|
|
||||||
/* ---------- preview ---------- */
|
/* ---------- preview ---------- */
|
||||||
|
|
||||||
let timer = null;
|
let timer = null;
|
||||||
@@ -921,6 +1323,26 @@ SNIP.hudJs = `function hudLetters(isSolved){
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
/* helperi inflate pt. player (player-side, backslash dublu pentru regex) */
|
||||||
|
SNIP.compressJs = `function bytesToB64url(bytes){
|
||||||
|
var chunks=[];
|
||||||
|
for(var i=0;i<bytes.length;i+=8192)chunks.push(String.fromCharCode.apply(null,bytes.subarray(i,i+8192)));
|
||||||
|
return btoa(chunks.join('')).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'');
|
||||||
|
}
|
||||||
|
function b64urlToBytes(str){
|
||||||
|
var b64=str.replace(/-/g,'+').replace(/_/g,'/');
|
||||||
|
var bin=atob(b64);var bytes=new Uint8Array(bin.length);
|
||||||
|
for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
async function inflateFromBase64url(str){
|
||||||
|
var bytes=b64urlToBytes(str);
|
||||||
|
var ds=new DecompressionStream('deflate-raw');
|
||||||
|
var writer=ds.writable.getWriter();writer.write(bytes);writer.close();
|
||||||
|
var buf=await new Response(ds.readable).arrayBuffer();
|
||||||
|
return new TextDecoder().decode(buf);
|
||||||
|
}`;
|
||||||
|
|
||||||
/* ---------- motor: terminal retro ---------- */
|
/* ---------- motor: terminal retro ---------- */
|
||||||
|
|
||||||
function gameTerminal(cfg) {
|
function gameTerminal(cfg) {
|
||||||
@@ -1645,28 +2067,30 @@ roomReady();
|
|||||||
* json = JSON.stringify(cfg).replace(/</g,'\\u003c') — D6.
|
* json = JSON.stringify(cfg).replace(/</g,'\\u003c') — D6.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function gameCampaign(cfg) {
|
function campaignShell({ tplJson, masterExpr, titleExpr, nStyles, bootMode }) {
|
||||||
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
const _scriptOpen = bootMode === 'hash'
|
||||||
|
? `<script>
|
||||||
/* Template-uri per stil: fiecare motor generat O dată cu sentinel __CFG__ */
|
${SNIP.compressJs}
|
||||||
const stylesNeeded = new Set(cfg.puzzles.map((p, i) => p.style || ROTATION[i % ROTATION.length]));
|
var TPL = ${tplJson};
|
||||||
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
|
(async function(){
|
||||||
const templates = {};
|
var h=location.hash.slice(1);
|
||||||
for (const style of stylesNeeded) {
|
if(!h){document.getElementById('intro-title').textContent='Niciun joc în acest link.';return;}
|
||||||
templates[style] = (engines[style] || gameClassic)('__TEMPLATE__');
|
window.MASTER=JSON.parse(await inflateFromBase64url(h));
|
||||||
}
|
var s=document.createElement('script');s.textContent=document.getElementById('run').textContent;document.body.appendChild(s);
|
||||||
|
})();
|
||||||
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
|
<\/script>
|
||||||
const masterJson = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
<script type="text/plain" id="run">
|
||||||
const nRooms = cfg.puzzles.length;
|
var MASTER=window.MASTER;`
|
||||||
const nStyles = stylesNeeded.size;
|
: `<script>
|
||||||
|
var TPL = ${tplJson};
|
||||||
|
var MASTER = ${masterExpr};`;
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="ro">
|
<html lang="ro">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${esc(cfg.title)}</title>
|
<title>${titleExpr}</title>
|
||||||
<style>
|
<style>
|
||||||
/*
|
/*
|
||||||
* ASCII DIAGRAM — contractul parent.*:
|
* ASCII DIAGRAM — contractul parent.*:
|
||||||
@@ -1923,7 +2347,7 @@ body {
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="chrome">
|
<div id="chrome">
|
||||||
<span id="chrome-title">${esc(cfg.title)}</span>
|
<span id="chrome-title">${titleExpr}</span>
|
||||||
<div class="sp"></div>
|
<div class="sp"></div>
|
||||||
<span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span>
|
<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>🎵</button>
|
<button id="btn-music" type="button" aria-label="Muzica de fundal" hidden>🎵</button>
|
||||||
@@ -1995,7 +2419,7 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
${_scriptOpen}
|
||||||
/*
|
/*
|
||||||
* ASCII DIAGRAM — contractul parent.*:
|
* ASCII DIAGRAM — contractul parent.*:
|
||||||
* ┌──────────── orchestrator (window) ─────────────────────────┐
|
* ┌──────────── orchestrator (window) ─────────────────────────┐
|
||||||
@@ -2008,8 +2432,6 @@ body {
|
|||||||
* roomDone[idx]=true după primul nextRoom → duplicatele ignorate.
|
* roomDone[idx]=true după primul nextRoom → duplicatele ignorate.
|
||||||
* Timeout 4s → skipRoom → aceeași compoziție de coridor (D5).
|
* Timeout 4s → skipRoom → aceeași compoziție de coridor (D5).
|
||||||
*/
|
*/
|
||||||
var TPL = ${tplJson};
|
|
||||||
var MASTER = ${masterJson};
|
|
||||||
var ROTATION = ['classic','terminal','arcade','chat','point'];
|
var ROTATION = ['classic','terminal','arcade','chat','point'];
|
||||||
var TOKEN = '__CFG__';
|
var TOKEN = '__CFG__';
|
||||||
|
|
||||||
@@ -2603,6 +3025,8 @@ owBuild();
|
|||||||
|
|
||||||
/* ----- Intro ----- */
|
/* ----- Intro ----- */
|
||||||
document.getElementById('intro-title').textContent = MASTER.title;
|
document.getElementById('intro-title').textContent = MASTER.title;
|
||||||
|
document.getElementById('chrome-title').textContent = MASTER.title;
|
||||||
|
document.title = MASTER.title || document.title;
|
||||||
var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
|
var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
|
||||||
document.getElementById('intro-story').textContent = _introStory;
|
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';
|
||||||
@@ -2689,6 +3113,96 @@ buildDots();
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gameCampaign(cfg) {
|
||||||
|
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
const stylesNeeded = new Set(cfg.puzzles.map((p, i) => p.style || ROTATION[i % ROTATION.length]));
|
||||||
|
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
|
||||||
|
const templates = {};
|
||||||
|
for (const style of stylesNeeded) {
|
||||||
|
templates[style] = (engines[style] || gameClassic)('__TEMPLATE__');
|
||||||
|
}
|
||||||
|
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
|
||||||
|
const masterJson = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
||||||
|
const nStyles = stylesNeeded.size;
|
||||||
|
return campaignShell({ tplJson, masterExpr: masterJson, titleExpr: esc(cfg.title), nStyles, bootMode: 'inline' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerHTML() {
|
||||||
|
/* Player universal: toate 5 template-uri inline, MASTER vine din hash comprimat */
|
||||||
|
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
|
||||||
|
const templates = {};
|
||||||
|
for (const style of ROTATION) {
|
||||||
|
templates[style] = engines[style]('__TEMPLATE__');
|
||||||
|
}
|
||||||
|
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
|
||||||
|
const nStyles = ROTATION.length; /* toate 5 stiluri disponibile */
|
||||||
|
return campaignShell({ tplJson, masterExpr: '', titleExpr: 'Escape Room Player', nStyles, bootMode: 'hash' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- distribuie (link + QR) ---------- */
|
||||||
|
|
||||||
|
$('#btnShare').disabled = !CS_OK;
|
||||||
|
$('#btnCopyLink').disabled = !CS_OK;
|
||||||
|
if (!CS_OK) {
|
||||||
|
const t = 'Browserul nu suportă CompressionStream (necesar pentru compresie URL)';
|
||||||
|
$('#btnShare').title = t; $('#btnCopyLink').title = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnShare').addEventListener('click', async () => {
|
||||||
|
if (state.puzzles.length === 0) { alert('Adaugă cel puțin un puzzle înainte de a genera QR-ul!'); return; }
|
||||||
|
const payload = await deflateToBase64url(JSON.stringify(cleanState()));
|
||||||
|
const baseUrl = (state.baseUrl || '').replace(/#.*$/, '');
|
||||||
|
const url = baseUrl + '#' + payload;
|
||||||
|
$('#shareUrl').value = url;
|
||||||
|
const svg = makeQrSvg ? makeQrSvg(url) : null;
|
||||||
|
if (!svg) {
|
||||||
|
$('#qrBox').innerHTML = '<p style="color:var(--danger);font-size:12px">URL prea lung pentru QR (încearcă un domeniu mai scurt sau mai puține puzzle-uri).</p>';
|
||||||
|
} else {
|
||||||
|
$('#qrBox').innerHTML = svg;
|
||||||
|
/* Populate QR card for print */
|
||||||
|
$('#qrCardTitle').textContent = state.title || 'Escape Room';
|
||||||
|
$('#qrCardFor').textContent = state.player ? 'Pentru: ' + state.player : '';
|
||||||
|
$('#qrCardUrl').textContent = url;
|
||||||
|
$('#qrCardSvg').innerHTML = svg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnCopyLink').addEventListener('click', async () => {
|
||||||
|
const url = $('#shareUrl').value;
|
||||||
|
if (!url) { alert('Generează mai întâi link-ul cu butonul "Generează QR / link".'); return; }
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
const btn = $('#btnCopyLink');
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = 'Copiat!';
|
||||||
|
setTimeout(() => { btn.textContent = old; }, 1500);
|
||||||
|
} catch (_) {
|
||||||
|
/* fallback pentru file:// unde clipboard API e blocat */
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch (_2) { alert('Copiază manual: ' + url); }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnDownloadPlayer').addEventListener('click', () => {
|
||||||
|
download('player.html', playerHTML(), 'text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnPrintQr').addEventListener('click', () => {
|
||||||
|
const svg = $('#qrCardSvg').innerHTML;
|
||||||
|
if (!svg) { alert('Generează mai întâi QR-ul cu butonul "Generează QR / link".'); return; }
|
||||||
|
$('#qrCard').classList.add('show');
|
||||||
|
window.print();
|
||||||
|
$('#qrCard').classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnQrBack').addEventListener('click', () => {
|
||||||
|
$('#qrCard').classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------- start ---------- */
|
/* ---------- start ---------- */
|
||||||
|
|
||||||
renderGlobals();
|
renderGlobals();
|
||||||
|
|||||||
@@ -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 (~35 teste).
|
- `tests/smoke.mjs` — unicul fișier de teste (~41 teste).
|
||||||
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
@@ -13,27 +13,30 @@ până la ecranul final, fără erori de consolă.
|
|||||||
zero-dependențe. Instalarea dev e o singură dată: `npm i -D @playwright/test && npx playwright install chromium`.
|
zero-dependențe. Instalarea dev e o singură dată: `npm i -D @playwright/test && npx playwright install chromium`.
|
||||||
- **Fără npm scripts** — se rulează direct cu `npx`.
|
- **Fără npm scripts** — se rulează direct cu `npx`.
|
||||||
- **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie
|
- **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie
|
||||||
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`.
|
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`. Testele `@share` scriu
|
||||||
|
player HTML temp în `tests/.tmp-player*.html` (deleted în `finally`).
|
||||||
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
||||||
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), `@campanie` (21), `@share` (6 — Iterația 3):
|
||||||
stil top-level invalid la import + bomberman gameplay + bomberman rază/powerup-uri) și `@campanie`
|
- `@share compresie round-trip` — deflate/inflate builder
|
||||||
(21 — intro→hartă→camere→final, resume, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil,
|
- `@share QR structural` — makeQrSvg SVG valid
|
||||||
audio S1, voce/narațiune D10, a11y tap/aria/reduced-motion, navigare overworld, timer calm T10,
|
- `@share playerHTML()` — structura HTML player
|
||||||
muzica ambient T10, diploma A4, adventure branch-jump, adventure resume non-contiguu,
|
- `@share player porneste din hash` — campanie 1 cameră din URL hash; folosește `__ow.enterDoor(0)`
|
||||||
adventure regression non-adventure, adventure branch tf).
|
- `@share player fara hash` — mesaj „Niciun joc"
|
||||||
- **Status țintă: 35/35 PASS.**
|
- `@share share UI` — butoane disabled fără CompressionStream
|
||||||
|
- **Status țintă: 41/41 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ă
|
||||||
adaugi/schimbi un stil, `@campanie` pentru contractul de montare.
|
adaugi/schimbi un stil, `@campanie` pentru contractul de montare, `@share` pentru Iterația 3.
|
||||||
- Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori.
|
- Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
```bash
|
```bash
|
||||||
npx playwright test tests/smoke.mjs # 35/35
|
npx playwright test tests/smoke.mjs # 41/41
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie
|
npx playwright test tests/smoke.mjs --grep @regresie # 16
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie
|
npx playwright test tests/smoke.mjs --grep @campanie # 21
|
||||||
|
npx playwright test tests/smoke.mjs --grep @share # 6
|
||||||
```
|
```
|
||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|||||||
149
tests/smoke.mjs
149
tests/smoke.mjs
@@ -1656,3 +1656,152 @@ test.describe('Campanie E2E @campanie', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SECTIUNEA 5 — SHARE (link + QR + player.html) @share
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe('Share: link + QR + player @share', () => {
|
||||||
|
|
||||||
|
test('@share compresie round-trip: inflate(deflate(s)) === s', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const ok = await page.evaluate(async () => {
|
||||||
|
if (typeof deflateToBase64url !== 'function') return 'missing deflate';
|
||||||
|
if (typeof inflateFromBase64url !== 'function') return 'missing inflate';
|
||||||
|
const s = JSON.stringify({ title: 'Test', puzzles: [{ question: 'x', answer: '42' }] });
|
||||||
|
const compressed = await deflateToBase64url(s);
|
||||||
|
if (typeof compressed !== 'string' || compressed.length === 0) return 'empty compressed';
|
||||||
|
const decompressed = await inflateFromBase64url(compressed);
|
||||||
|
return decompressed === s ? 'ok' : 'mismatch';
|
||||||
|
});
|
||||||
|
expect(ok, 'round-trip result').toBe('ok');
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@share QR structural: makeQrSvg produce SVG valid', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
if (typeof makeQrSvg !== 'function') return { err: 'makeQrSvg missing' };
|
||||||
|
const svg = makeQrSvg('https://example.com/play.html#abc123');
|
||||||
|
if (!svg) return { err: 'null result' };
|
||||||
|
return { hasViewBox: svg.includes('viewBox'), hasPath: svg.includes('<path'), len: svg.length };
|
||||||
|
});
|
||||||
|
expect(result.err, 'error').toBeUndefined();
|
||||||
|
expect(result.hasViewBox, 'viewBox').toBe(true);
|
||||||
|
expect(result.hasPath, '<path').toBe(true);
|
||||||
|
expect(result.len, 'svg length').toBeGreaterThan(100);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@share playerHTML() genereaza HTML cu inflate + script run + TPL', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
if (typeof playerHTML !== 'function') return { err: 'playerHTML missing' };
|
||||||
|
const html = playerHTML();
|
||||||
|
return {
|
||||||
|
hasInflate: html.includes('inflateFromBase64url'),
|
||||||
|
hasRunScript: html.includes('text/plain'),
|
||||||
|
hasTPL: html.includes('var TPL'),
|
||||||
|
len: html.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result.err, 'error').toBeUndefined();
|
||||||
|
expect(result.hasInflate, 'inflate helper').toBe(true);
|
||||||
|
expect(result.hasRunScript, 'text/plain run script').toBe(true);
|
||||||
|
expect(result.hasTPL, 'var TPL').toBe(true);
|
||||||
|
expect(result.len).toBeGreaterThan(5000);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@share player porneste din hash — campanie 1 camera, final vizibil', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const { playerHtml, hash } = await page.evaluate(async () => {
|
||||||
|
const cfg = {
|
||||||
|
title: 'Test Player', player: '', color: '#6d28d9', style: 'campaign', creator: '',
|
||||||
|
charName: 'Alex', voice: false, music: false, adventure: false, timerMin: 0,
|
||||||
|
puzzles: [{ title: 'P1', type: 'free', question: 'Cat fac 1+1?', answer: '2',
|
||||||
|
tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: {} }],
|
||||||
|
story: 'Povestea', finalMessage: 'Bravo!'
|
||||||
|
};
|
||||||
|
const compressed = await deflateToBase64url(JSON.stringify(cfg));
|
||||||
|
return { playerHtml: playerHTML(), hash: compressed };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tmpPath = join(ROOT, 'tests', '.tmp-player.html');
|
||||||
|
writeFileSync(tmpPath, playerHtml);
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
const consoleLogs = [];
|
||||||
|
gp.on('console', m => consoleLogs.push(m.type() + ': ' + m.text()));
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath + '#' + hash);
|
||||||
|
await gp.waitForFunction(() => !!window.MASTER, { timeout: 10000 }).catch(e => { throw new Error('MASTER not set. Console: ' + consoleLogs.slice(0,5).join(' | ')); });
|
||||||
|
const title = await gp.evaluate(() => window.MASTER.title);
|
||||||
|
expect(title, 'MASTER.title').toBe('Test Player');
|
||||||
|
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
/* after start, overworld is shown; navigate to room 0 */
|
||||||
|
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
|
||||||
|
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();
|
||||||
|
await ifl.locator('#answers input[type=text]').fill('2');
|
||||||
|
await ifl.locator('#answers button:text("Verifica")').click();
|
||||||
|
await gp.waitForTimeout(1200);
|
||||||
|
await gp.waitForFunction(() => {
|
||||||
|
const fin = document.getElementById('finale');
|
||||||
|
return fin && fin.classList.contains('show');
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
} finally {
|
||||||
|
await gp.close();
|
||||||
|
try { unlinkSync(tmpPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@share player fara hash — afiseaza mesaj niciun joc', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const playerHtml = await page.evaluate(() => playerHTML());
|
||||||
|
const tmpPath = join(ROOT, 'tests', '.tmp-player-empty.html');
|
||||||
|
writeFileSync(tmpPath, playerHtml);
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
await gp.waitForTimeout(600);
|
||||||
|
const txt = await gp.locator('#intro-title').textContent({ timeout: 3000 }).catch(() => '');
|
||||||
|
expect(txt, 'mesaj fara hash').toContain('Niciun joc');
|
||||||
|
} finally {
|
||||||
|
await gp.close();
|
||||||
|
try { unlinkSync(tmpPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@share share UI: butoane share disabled fara CompressionStream', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
delete window.CompressionStream;
|
||||||
|
delete window.DecompressionStream;
|
||||||
|
});
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
const shareDisabled = await page.locator('#btnShare').isDisabled();
|
||||||
|
const copyDisabled = await page.locator('#btnCopyLink').isDisabled();
|
||||||
|
expect(shareDisabled, 'btnShare disabled').toBe(true);
|
||||||
|
expect(copyDisabled, 'btnCopyLink disabled').toBe(true);
|
||||||
|
const dlEnabled = await page.locator('#btnDownloadPlayer').isEnabled();
|
||||||
|
expect(dlEnabled, 'btnDownloadPlayer enabled').toBe(true);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user