// Tahap 3.1 dan 3.4: dialog pasangan di layar VS dan easter egg berpasangan (dialog rahasia, +75 koin, pencapaian REUNI). // node tests/harness.mjs tests/t3-dialog.mjs // DLG_SHOTS=folder simpan screenshot (default: folder sementara sistem); DLG_ONLY=regex hanya bagian yang namanya cocok (data|map|fit|secret|flow|time|shots) // Jendela awal 1280x520: tampilan harus muat di tinggi 520 px. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export const options = { viewport: { width: 1280, height: 520 } }; export default async ({ page, game, characters }) => { const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, ...(pass ? {} : { info }) }); const only = process.env.DLG_ONLY ? new RegExp(process.env.DLG_ONLY) : null, want = n => !only || only.test(n); const dir = process.env.DLG_SHOTS || path.join(os.tmpdir(), 't3-dialog-shots'); fs.mkdirSync(dir, { recursive: true }); const wait = ms => page.waitForTimeout(ms); const sorted = [...characters].sort(); const keys = []; for (let i = 0; i < sorted.length; i++) for (let j = i + 1; j < sorted.length; j++) keys.push(sorted[i] + '|' + sorted[j]); const startVS = (p, e, vs = true, mode = 'versus') => game(({ p, e, vs, mode }) => { const g = window.__game; g.manual = false; return g.startMatch({ player: p, enemy: e, stage: 'bellora', level: 'medium', mode, vs }); }, { p, e, vs, mode }); const vsEnd = () => page.waitForFunction(() => !window.__game.vsActive(), null, { timeout: 9000 }).catch(() => null); const state = () => game(() => { const g = window.__game, s = g.snapshot(), q = sel => [...document.querySelectorAll(sel)]; return { active: g.vsActive(), cues: s.roundCues.map(c => c.cue + c.round), bubbles: q('#vs-screen .vs-bubble').map(n => n.textContent), quotes: q('#vs-screen .vs-quote').map(n => n.textContent), secretBubbles: q('#vs-screen .vs-bubble.secret').length, label: q('#vs-screen .vs-secret').map(n => n.textContent.trim()), toasts: q('.meta-toast').map(n => n.textContent), coins: window.Meta.coins, eggs: Object.keys(window.Meta.save.eggs).filter(k => k.startsWith('dialog:')).sort(), ach: !!window.Meta.save.ach.reuni }; }); const toastsNow = () => game(() => [...document.querySelectorAll('.meta-toast')].map(n => n.textContent)); // Toast yang ditahan selama VS dilepas Meta begitu tidak ada layar VS aktif: batalkan VS dulu, tunggu toast tertahan muncul, lalu bersihkan sebelum bagian uji berikutnya. const resetMeta = async () => { await game(() => { window.VS.cancel(); }); await wait(380); await game(() => { const M = window.Meta; M.save.eggs = {}; M.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; document.querySelectorAll('.meta-toast').forEach(n => n.remove()); }); }; // ---------- 3.1 / 3.4 data ---------- if (want('data')) { const D = await game(() => JSON.parse(JSON.stringify(window.DIALOGS || {}))); const bad = /allah|tuhan|bismillah|\bamin\b|islam|kristen|hindu|budha|gereja|masjid|ustaz|ustadz|kiai|alhamdulillah|insyaallah|korupsi|tersangka|penjara|ijazah|dinasti|nepotisme|\bsuap\b|skandal|\bayah\b|\bbapak\b|\bibu\b|keluarga|\banak\b|istri|suami|\bpak\b|\bbu\b/i; const lineOk = t => typeof t === 'string' && t.length >= 10 && t.length <= 60 && !bad.test(t); ok('data: tepat 21 kunci pasangan ditambah rahasia, semua berurutan alfabet dan tidak ada kunci lain', Object.keys(D).filter(k => k !== 'rahasia').sort().join() === keys.join() && keys.length === 21, Object.keys(D)); ok('data: tiap pasangan punya dua baris a/b, 10-60 karakter, tanpa kata terlarang', keys.every(k => D[k] && lineOk(D[k].a) && lineOk(D[k].b)), keys.filter(k => !(D[k] && lineOk(D[k].a) && lineOk(D[k].b)))); const all = keys.flatMap(k => [D[k]?.a, D[k]?.b]); ok('data: 42 baris unik (tidak ada ucapan yang dipakai dua kali)', new Set(all).size === 42, all.length); const meme = /semut|syududu|koloni|remah|gula/i, gufronLines = keys.filter(k => k.includes('gufron')).map(k => k.split('|')[0] === 'gufron' ? D[k].a : D[k].b); ok('data: baris Gufron hanya meme ringan (semut/syududu/koloni/remah/gula)', gufronLines.length === 6 && gufronLines.every(t => meme.test(t)), gufronLines); const R = D.rahasia || {}, rk = Object.keys(R); ok('rahasia: tepat empat pasangan, kuncinya salah satu dari 21 pasangan', rk.length === 4 && rk.every(k => keys.includes(k)), rk); ok('rahasia: dua baris per pasangan, lolos pedoman, berbeda dari dialog biasa pasangan yang sama', rk.every(k => lineOk(R[k].a) && lineOk(R[k].b) && R[k].a !== D[k].a && R[k].b !== D[k].b), rk); ok('rahasia: tidak ada baris yang sama dengan dialog biasa mana pun', rk.every(k => !all.includes(R[k].a) && !all.includes(R[k].b)), rk); const gr = rk.filter(k => k.includes('gufron')).map(k => k.split('|')[0] === 'gufron' ? R[k].a : R[k].b); ok('rahasia: baris Gufron tetap meme ringan', gr.length >= 1 && gr.every(t => meme.test(t)), gr); const ach = await game(() => { const M = window.Meta; M.open('book'); const li = [...document.querySelectorAll('#meta-overlay .ach li')].map(l => l.textContent); M.close(); return li; }); const spoiler = /bro wo|mega chan|solo|ethanol|a nice|gufron|browo|megachan|anice|soloman|sonofsolo|ethanolman|\|/i, hiddenRows = ach.filter(t => /\?\?\?/.test(t)); ok('pencapaian: 21 pencapaian di daftar (REUNI satu-satunya tambahan)', ach.length === 21, ach.length); ok('pencapaian: tujuh pencapaian rahasia (termasuk REUNI) tampil ??? dan tanpa nama karakter/pasangan', hiddenRows.length === 7 && hiddenRows.every(t => /^☆ \?\?\?Pencapaian rahasia\.$/.test(t) && !spoiler.test(t)), hiddenRows); ok('pencapaian: nama REUNI belum bocor sebelum terbuka', ach.every(t => !/REUNI/.test(t)), ach.filter(t => /REUNI/.test(t))); } // ---------- pemetaan sisi ---------- if (want('map')) { const out = await game(() => { const D = window.DIALOGS, ids = Object.keys(window.FIGHTERS).concat(['browo']).sort(), bad = []; for (const p of ids) for (const e of ids) { const d = VS.dialog(p, e); if (p === e) { if (d) bad.push(['mirror', p]); continue; } const key = p < e ? p + '|' + e : e + '|' + p, src = D.rahasia[key] || D[key]; const wantL = p < e ? src.a : src.b, wantR = p < e ? src.b : src.a; if (!d || d.l !== wantL || d.r !== wantR || d.key !== key || d.secret !== !!D.rahasia[key]) bad.push([p, e, d]); } return { n: ids.length, bad, none: VS.dialog('browo', 'tidakada'), nul: VS.dialog(null, 'browo') }; }); ok('peta: 42 kombinasi (pemain, lawan) memetakan ucapan ke sisi yang benar, kunci urut alfabet', out.n === 7 && out.bad.length === 0, out.bad.slice(0, 3)); ok('peta: karakter yang sama atau tidak dikenal tidak punya dialog (kutipan umum jadi cadangan)', out.none === null && out.nul === null, out); // pemain di kiri, lawan di kanan: balon kiri = ucapan pemain for (const [p, e] of [['soloman', 'browo'], ['browo', 'soloman'], ['gufron', 'anice']]) { await resetMeta(); await startVS(p, e); await wait(120); const s = await state(), exp = await game(({ p, e }) => VS.dialog(p, e), { p, e }); ok(`peta layar: ${p} (kiri) lawan ${e} (kanan): balon kiri ${p} dan kanan ${e}`, s.bubbles.length === 2 && s.bubbles[0] === exp.l && s.bubbles[1] === exp.r && s.quotes.length === 2, { s, exp }); } } // ---------- muat di layar (reduced motion: tata letak langsung final) ---------- if (want('fit')) { await page.emulateMedia({ reducedMotion: 'reduce' }); const sizes = [[1280, 720], [1280, 520], [1024, 520], [844, 390], [667, 375], [568, 320]], ids = [...characters]; for (const [w, h] of sizes) { await page.setViewportSize({ width: w, height: h }); await resetMeta(); const bad = []; for (const p of ids) for (const e of ids) { if (p === e) continue; await startVS(p, e); const r = await game(() => { const vw = innerWidth, vh = innerHeight, rect = n => n && n.getBoundingClientRect(), top = rect(document.querySelector('#vs-screen .vs-top')), lab = rect(document.querySelector('#vs-screen .vs-secret')); const limit = Math.max(top.bottom, lab ? lab.bottom : 0), mark = rect(document.querySelector('#vs-screen .vs-mark b')), res = []; for (const [i, b] of [...document.querySelectorAll('#vs-screen .vs-bubble')].entries()) { const br = rect(b), info = b.closest('.vs-info'), ir = rect(info), nm = rect(info.querySelector('.vs-name')), skip = rect(document.querySelector('#vs-screen .vs-skip')); res.push({ i, inView: br.left >= 0 && br.right <= vw && br.top >= 0 && br.bottom <= vh, side: i === 0 ? br.right <= vw / 2 + 1 : br.left >= vw / 2 - 1, belowName: br.top >= nm.bottom - 1, infoBelowBar: ir.top >= limit + 2, noClip: b.scrollHeight <= b.clientHeight + 1 && b.scrollWidth <= b.clientWidth + 1, clearOfMark: br.right <= mark.left + 6 || br.left >= mark.right - 6 || br.bottom <= mark.top || br.top >= mark.bottom, lines: Math.round(br.height), top: Math.round(br.top), infoTop: Math.round(ir.top), limit: Math.round(limit) }); } return res; }); if (r.length !== 2 || r.some(x => !x.inView || !x.side || !x.belowName || !x.infoBelowBar || !x.noClip || !x.clearOfMark)) bad.push({ p, e, r }); } ok(`muat ${w}x${h}: balon 42 kombinasi di dalam layar, di bawah nama, tidak menimpa bilah atas/VS dan tidak terpotong`, bad.length === 0, bad.slice(0, 2)); } await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.setViewportSize({ width: 1280, height: 520 }); } // ---------- alur dialog rahasia ---------- if (want('secret')) { await resetMeta(); const c0 = await game(() => window.Meta.coins); // pasangan biasa: tanpa label, tanpa koin await startVS('browo', 'megachan'); await wait(150); let s = await state(); ok('biasa: tanpa label DIALOG RAHASIA, tanpa balon emas, tanpa koin/egg', s.label.length === 0 && s.secretBubbles === 0 && s.bubbles.length === 2 && s.eggs.length === 0 && s.coins === c0, s); await vsEnd(); await wait(250); // pasangan rahasia pertama (urutan terbalik dari kunci alfabet) await startVS('sonofsolo', 'soloman'); await wait(150); s = await state(); const D1 = await game(() => VS.dialog('sonofsolo', 'soloman')); ok('rahasia: label DIALOG RAHASIA tampil dan kedua balon emas memakai dialog rahasia', s.label.join() === 'DIALOG RAHASIA' && s.secretBubbles === 2 && D1.secret && s.bubbles[0] === D1.l && s.bubbles[1] === D1.r, { s, D1 }); ok('rahasia: koin +75 langsung dan egg tersimpan di Meta.save.eggs', s.coins === c0 + 75 && s.eggs.join() === 'dialog:soloman|sonofsolo', { c0, s }); ok('rahasia: toast ditahan selama layar VS (tidak menutupi bilah atas)', s.active && s.toasts.length === 0, s.toasts); await vsEnd(); await wait(400); let t = await toastsNow(); ok('rahasia: tepat satu toast DIALOG RAHASIA TERBUKA +75 setelah VS selesai', t.filter(x => /DIALOG RAHASIA TERBUKA\s+\+75 koin/.test(x)).length === 1 && t.length === 1, t); // pertemuan kedua pasangan sama (urutan lain): label dan dialog tetap tampil, tetapi tanpa koin dan toast baru await game(() => document.querySelectorAll('.meta-toast').forEach(n => n.remove())); const c1 = await game(() => window.Meta.coins); await startVS('soloman', 'sonofsolo'); await wait(150); s = await state(); ok('rahasia ulang: label dan dialog rahasia tetap tampil, koin tidak bertambah lagi', s.label.length === 1 && s.secretBubbles === 2 && s.coins === c1 && s.eggs.length === 1, s); await vsEnd(); await wait(400); t = await toastsNow(); ok('rahasia ulang: tidak ada toast baru', t.length === 0, t); // tiga pasangan lain: koin +75 sekali per pasangan; pencapaian REUNI baru pada pasangan keempat const rest = [['megachan', 'anice'], ['gufron', 'ethanolman'], ['soloman', 'browo']]; for (const [i, [p, e]] of rest.entries()) { const before = await game(() => ({ coins: window.Meta.coins, ach: !!window.Meta.save.ach.reuni })); await startVS(p, e); await wait(150); s = await state(); const last = i === rest.length - 1; ok(`rahasia ${p}-${e}: label, balon emas, +75 koin${last ? ' (+50 pencapaian REUNI)' : ' tanpa pencapaian'}`, s.label.length === 1 && s.secretBubbles === 2 && s.coins === before.coins + 75 + (last ? 50 : 0) && s.ach === last && !before.ach, { before, s }); await vsEnd(); await wait(400); t = await toastsNow(); ok(`rahasia ${p}-${e}: toast sesudah VS ${last ? 'berisi DIALOG RAHASIA TERBUKA dan PENCAPAIAN: REUNI' : 'hanya DIALOG RAHASIA TERBUKA'}`, t.some(x => /DIALOG RAHASIA TERBUKA/.test(x)) && t.some(x => /REUNI/.test(x)) === last && t.length === (last ? 2 : 1), t); await game(() => document.querySelectorAll('.meta-toast').forEach(n => n.remove())); } const fin = await state(); ok('rahasia: keempat egg tersimpan, REUNI terbuka, koin total 4x75 + 50', fin.eggs.length === 4 && fin.ach && fin.coins === c0 + 4 * 75 + 50, fin); // Pencapaian muncul di buku koleksi dengan nama dan deskripsi umum (tanpa menyebut pasangan) const book = await game(() => { const M = window.Meta; M.open('book'); const row = [...document.querySelectorAll('#meta-overlay .ach li')].find(l => /REUNI/.test(l.textContent)); const txt = row?.textContent || null, ok = row?.classList.contains('ok') || false; M.close(); return { txt, ok }; }); ok('pencapaian: REUNI terbuka tampil dengan deskripsi umum', book.ok && /^★ REUNITemukan semua dialog rahasia\.$/.test(book.txt || ''), book); // simpanan: egg bertahan di localStorage const stored = await game(() => { try { return Object.keys(JSON.parse(localStorage.getItem('konoha.save.v1')).eggs).filter(k => k.startsWith('dialog:')).length; } catch (_) { return -1; } }); ok('simpanan: empat egg pasangan tersimpan di localStorage', stored === 4, stored); // Toast ditahan sepanjang layar VS (dipantau tiap 40 ms, bukan hanya di awal) dan baru muncul setelah layar tidak aktif lagi await resetMeta(); const trace = await game(() => new Promise(res => { const g = window.__game, out = [], t0 = performance.now(); g.manual = false; g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'medium', mode: 'versus', vs: true }); const id = setInterval(() => { const t = performance.now() - t0; out.push({ t: Math.round(t), active: window.VS.active, toasts: document.querySelectorAll('.meta-toast').length }); if (t > 4200) { clearInterval(id); res(out); } }, 40); })); const lastActive = Math.max(...trace.filter(r => r.active).map(r => r.t)), firstToast = Math.min(...trace.filter(r => r.toasts).map(r => r.t)); ok('rahasia: toast tidak pernah tampil selagi layar VS aktif; muncul sesudah layar selesai', trace.every(r => !(r.active && r.toasts)) && firstToast > lastActive && lastActive > 2800, { lastActive, firstToast }); // karakter yang sama: tidak ada dialog dan tidak memicu apa pun await resetMeta(); await startVS('browo', 'browo'); await wait(150); s = await state(); ok('karakter sama: kutipan umum bertanda kutip, tanpa balon, tanpa label, tanpa egg', s.bubbles.length === 0 && s.quotes.length === 2 && s.quotes.every(q => /^“.*”$/.test(q)) && s.label.length === 0 && s.eggs.length === 0, s); await vsEnd(); } // ---------- alur: P2, Arcade, Survival, latihan, lewati, ronde ---------- if (want('flow')) { await resetMeta(); // dua pemain (opsi vs.p2) memakai dialog pasangan juga await startVS('anice', 'gufron', { p2: true }); await wait(150); let s = await state(); const d2 = await game(() => VS.dialog('anice', 'gufron')); const who = await game(() => [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent).join()); ok('dua pemain: label PEMAIN 1/2 dan balon dialog pasangan', who === 'PEMAIN 1,PEMAIN 2' && s.bubbles[0] === d2.l && s.bubbles[1] === d2.r, { who, s }); await vsEnd(); await wait(300); // pasangan rahasia di dua pemain juga memicu (egg per pasangan, bukan per sisi) await startVS('gufron', 'ethanolman', { p2: true }); await wait(150); s = await state(); ok('dua pemain: pasangan rahasia memicu label dan egg', s.label.length === 1 && s.eggs.join() === 'dialog:ethanolman|gufron', s); await vsEnd(); await wait(300); // latihan: tanpa layar VS, tanpa dialog, tanpa egg await resetMeta(); await startVS('soloman', 'sonofsolo', true, 'training'); await wait(150); s = await state(); ok('latihan: tanpa layar VS, tanpa dialog rahasia, tanpa egg', !s.active && s.bubbles.length === 0 && s.eggs.length === 0, s); // tanpa opsi vs: perilaku lama await startVS('soloman', 'sonofsolo', false); await wait(150); s = await state(); ok('tanpa vs: cue Round 1 langsung, tanpa dialog, tanpa egg', !s.active && s.bubbles.length === 0 && s.cues.join() === 'round1' && s.eggs.length === 0, s); // Arcade: lawan pertama memakai dialog pasangan (pemain kiri, lawan kanan) dan label bar tetap await game(() => { window.Meta.startRun('arcade', 'browo'); }); await wait(200); s = await state(); const arc = await game(() => { const n = [...document.querySelectorAll('#vs-screen .vs-name')].map(x => x.textContent); const ids = Object.keys(window.FIGHTERS).concat(['browo']); const en = ids.find(id => (window.FIGHTERS[id]?.name || 'BRO WO') === n[1]); return { names: n, en, d: en && VS.dialog('browo', en), top: document.querySelector('#vs-screen .vs-top')?.textContent }; }); ok('arcade: lawan 1 memakai dialog pasangan dengan pemain di kiri dan label ARCADE', s.active && arc.d && s.bubbles[0] === arc.d.l && s.bubbles[1] === arc.d.r && /ARCADE · LAWAN 1 \/ 6/.test(arc.top), { s, arc }); await game(() => { window.Meta.cancelRun(); window.__game.setMenuOpen(true); }); // Survival await game(() => { window.Meta.startRun('survival', 'megachan'); }); await wait(200); s = await state(); const sv = await game(() => ({ top: document.querySelector('#vs-screen .vs-top')?.textContent, n: document.querySelectorAll('#vs-screen .vs-bubble').length })); ok('survival: layar VS memakai balon dialog', s.active && sv.n === 2 && /SURVIVAL/.test(sv.top), sv); await game(() => { window.Meta.cancelRun(); window.__game.setMenuOpen(true); }); // Dilewati: ronde jalan seperti biasa, tidak ada cue ganda await resetMeta(); await startVS('browo', 'anice'); await wait(500); await page.keyboard.press(' '); await wait(60); s = await state(); ok('lewati: layar selesai, cue Round 1 sekali, simulasi berjalan', !s.active && s.cues.join() === 'round1', s); await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(60); s = await state(); ok('lewati: menekan lagi tidak menggandakan cue', s.cues.join() === 'round1', s); // pertandingan baru saat VS rahasia masih jalan: satu overlay, egg lama tidak dobel await resetMeta(); await startVS('soloman', 'sonofsolo'); await wait(200); await startVS('browo', 'anice'); await wait(100); s = await state(); const cnt = await game(() => document.querySelectorAll('#vs-screen').length); ok('batal: pertandingan baru menggantikan layar VS rahasia, label hilang, egg tetap satu', cnt === 1 && s.label.length === 0 && s.eggs.length === 1 && s.bubbles.length === 2, { s, cnt }); await vsEnd(); await wait(300); await game(() => { window.__game.setMenuOpen(true); }); } // ---------- waktu tampil ---------- if (want('time')) { await resetMeta(); const dur = await game(() => ({ base: VS.duration, dlg: VS.durationDialog })); ok('waktu: layar dengan dialog lebih lama dari layar tanpa dialog', dur.dlg > dur.base && dur.dlg <= 3600, dur); for (const [p, e, lo, hi, label] of [['gufron', 'anice', 2800, 3800, 'dengan dialog'], ['megachan', 'megachan', 1800, 2600, 'tanpa dialog (karakter sama)']]) { const t0 = Date.now(); await startVS(p, e); let first = null; while (Date.now() - t0 < 5000) { const a = await game(() => window.__game.vsActive()); if (!a) { first = Date.now() - t0; break; } await wait(40); } ok(`waktu: selesai sendiri dalam ${lo / 1000}-${hi / 1000} detik ${label}`, first !== null && first > lo && first < hi, { first }); await wait(350); } const s = await state(); ok('waktu: setelah selesai sendiri cue Round 1 keluar tepat sekali', s.cues.join() === 'round1', s); } // ---------- screenshot untuk dinilai mata ---------- if (want('shots')) { await resetMeta(); const shot = async (name, p, e) => { await startVS(p, e); await wait(2300); await page.screenshot({ path: path.join(dir, name + '.png') }); await game(() => window.__game.vsSkip()); await wait(450); }; for (const [w, h] of [[1280, 720], [1024, 520], [844, 390]]) { await page.setViewportSize({ width: w, height: h }); await shot(`biasa-${w}x${h}`, 'anice', 'gufron'); await shot(`rahasia-${w}x${h}`, 'megachan', 'anice'); } await page.setViewportSize({ width: 1280, height: 520 }); } const failed = checks.filter(c => !c.pass); return { total: checks.length, failed: failed.length, shots: dir, failures: failed, passed: failed.length ? undefined : checks.map(c => c.name) }; };