// Tahap 2.2, 2.3, 2.6: layar VS, layar hasil, kutipan. // node tests/harness.mjs tests/t2-vsresult.mjs // VSR_SHOTS=folder simpan screenshot (default: folder sementara sistem); VSR_ONLY=regex hanya bagian yang namanya cocok (quotes|vs|skip|abort|result|run|ui|phone) // Jendela 1280x520 dipakai sengaja: 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, errors, characters }) => { const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, ...(pass ? {} : { info }) }); const only = process.env.VSR_ONLY ? new RegExp(process.env.VSR_ONLY) : null, want = n => !only || only.test(n); const dir = process.env.VSR_SHOTS || path.join(os.tmpdir(), 't2-vsresult-shots'); fs.mkdirSync(dir, { recursive: true }); const shot = async name => { await page.screenshot({ path: path.join(dir, name + '.png') }); }; const wait = ms => page.waitForTimeout(ms); // Menyelesaikan pertandingan: sisi 'player' atau 'enemy' menang dua ronde (tanpa uji gerak). const finish = (side, rounds = 2) => game(({ side, rounds }) => { const g = window.__game; g.manual = true; let won = 0; for (let i = 0; i < 900 && !g.snapshot().menuOpen; i++) { const s = g.snapshot(); if (s.match?.phase === 'fight' && s.match.round >= 1 && won < 99) { if (side === 'player') g.dummy.damage = 1e4; else g.hero.hp = 0; } g.advance(.1); } return { menuOpen: g.snapshot().menuOpen }; }, { side, rounds }); const startVS = (p, e, extra = {}) => game(({ p, e, extra }) => { const g = window.__game; g.manual = false; return g.startMatch({ player: p, enemy: e, stage: extra.stage || 'bellora', level: extra.level || 'medium', mode: extra.mode || 'versus', vs: extra.vs ?? true }); }, { p, e, extra }); const vsState = () => game(() => { const g = window.__game, el = document.querySelector('#vs-screen'); const s = g.snapshot(); return { el: !!el, out: el?.classList.contains('out') || false, active: g.vsActive(), phase: s.match?.phase, phaseTime: +(s.match?.phaseTime || 0).toFixed(3), cues: s.roundCues.map(c => c.cue + c.round), paused: s.paused, menuOpen: s.menuOpen, names: [...document.querySelectorAll('#vs-screen .vs-name')].map(n => n.textContent), quotes: [...document.querySelectorAll('#vs-screen .vs-quote')].map(n => n.textContent), roundCard: !document.querySelector('#round-call').hidden }; }); // ---------- 2.6 data kutipan ---------- if (want('quotes')) { const q = await game(() => JSON.parse(JSON.stringify(window.QUOTES || {}))); 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/i; for (const id of characters) { const d = q[id]; ok(`kutipan ${id}: ada intro/win/lose`, d && ['intro', 'win', 'lose'].every(k => Array.isArray(d[k])), d); for (const k of ['intro', 'win', 'lose']) { const list = d?.[k] || []; ok(`kutipan ${id}.${k}: 3-4 kalimat pendek, unik, tanpa kata terlarang`, list.length >= 3 && list.length <= 4 && new Set(list).size === list.length && list.every(t => typeof t === 'string' && t.length >= 8 && t.length <= 70 && !bad.test(t)), list); } } // Gufron hanya meme ringan: semut / syududu / koloni const gufron = Object.values(q.gufron || {}).flat(); ok('kutipan gufron: hanya meme ringan semut/syududu', gufron.length > 0 && gufron.every(t => /semut|syududu|koloni|remah|gula/i.test(t)), gufron); // Pemilih: tidak memakai random() mesin (seed mesin tidak berubah) dan tidak mengulang kutipan yang sama berturut-turut const pick = await game(() => { const g = window.__game, before = g.snapshot().stepCount; const seen = []; for (let i = 0; i < 40; i++) seen.push(VS.quote('browo', 'win')); return { seen, repeats: seen.filter((t, i) => i && t === seen[i - 1]).length, distinct: new Set(seen).size, stepCount: g.snapshot().stepCount - before }; }); ok('pemilih kutipan: variasi dan tanpa pengulangan berturut-turut', pick.repeats === 0 && pick.distinct >= 3, pick); const unk = await game(() => VS.quote('tidakada', 'intro')); ok('pemilih kutipan: karakter tak dikenal memakai kutipan umum', typeof unk === 'string' && unk.length > 3, unk); } // ---------- 2.2 layar VS ---------- if (want('vs')) { // tanpa opsi vs: perilaku lama (cue langsung, tanpa overlay) await startVS('browo', 'megachan', { vs: false }); let s = await vsState(); ok('tanpa vs: tidak ada overlay dan cue Round 1 langsung', !s.el && !s.active && s.cues.join() === 'round1', s); // latihan: tanpa layar VS walau diminta await startVS('browo', 'megachan', { mode: 'training', vs: true }); s = await vsState(); ok('latihan: tidak ada layar VS', !s.el && !s.active, s); const pairs = [['browo', 'megachan'], ['gufron', 'soloman'], ['sonofsolo', 'anice'], ['ethanolman', 'browo']]; for (const [p, e] of pairs) { await game(() => { window.VS.seed(7); }); const r = await startVS(p, e, { stage: 'harbor', level: 'hard' }); s = await vsState(); ok(`vs ${p} lawan ${e}: layar tampil, ronde ditahan`, r && s.el && s.active && s.phase === 'intro' && s.phaseTime === 0 && s.cues.length === 0 && !s.roundCard, s); const names = await game(({ p, e }) => ({ p: window.FIGHTERS?.[p]?.name || 'BRO WO', e: window.FIGHTERS?.[e]?.name || 'BRO WO', q: window.QUOTES }), { p, e }); ok(`vs ${p} lawan ${e}: nama dan kutipan pembuka dari QUOTES`, s.names[0] === names.p && s.names[1] === names.e && names.q[p].intro.some(t => s.quotes[0].includes(t)) && names.q[e].intro.some(t => s.quotes[1].includes(t)), { s, names: [names.p, names.e] }); const adv = await game(() => { const g = window.__game; g.manual = true; g.advance(1.5); return { phaseTime: g.snapshot().match.phaseTime, cues: g.snapshot().roundCues.length }; }); ok(`vs ${p} lawan ${e}: simulasi tidak berjalan selama layar VS`, adv.phaseTime === 0 && adv.cues === 0, adv); if (p === 'browo') { await game(() => { window.__game.manual = false; }); await wait(300); await shot('vs-1280x520-a-masuk'); await wait(700); await shot('vs-1280x520-b-slam'); await wait(600); await shot('vs-1280x520-c-akhir'); } await game(() => { window.__game.manual = false; }); } // waktu: kira-kira 2,2 detik lalu cue Round 1 muncul tepat sekali dan urutan intro normal const t0 = Date.now(); await startVS('gufron', 'anice'); let first = null; while (Date.now() - t0 < 4000) { s = await vsState(); if (!s.active) { first = Date.now() - t0; break; } await wait(40); } ok('vs: selesai sendiri dalam 1,8 - 2,6 detik', first !== null && first > 1800 && first < 2600, { first }); s = await vsState(); ok('vs: cue Round 1 keluar tepat sekali setelah layar selesai', s.cues.join() === 'round1', s); await wait(500); s = await vsState(); ok('vs: overlay hilang dari DOM sesudah fade', !s.el, s); // urutan intro sesudah VS sama seperti tanpa VS: round -> fight -> fase fight const seq = await game(() => { const g = window.__game; g.manual = true; const out = []; for (let i = 0; i < 60; i++) { g.advance(.1); const s = g.snapshot(); const k = s.match.phase + ':' + s.roundCues.map(c => c.cue + c.round).join('+'); if (out[out.length - 1] !== k) out.push(k); } return out; }); ok('vs: urutan cue ronde dan fase intro utuh (round1 -> fight -> fight phase)', seq[0].startsWith('intro:round1') && seq.some(x => x === 'intro:round1+fight1') && seq[seq.length - 1] === 'fight:round1+fight1', seq); await game(() => { window.__game.manual = false; }); } // gerak dikurangi: tidak ada animasi, tetapi layar tetap selesai dan ronde jalan if (want('vs')) { await page.emulateMedia({ reducedMotion: 'reduce' }); await startVS('anice', 'soloman'); await wait(150); const calm = await game(() => { const art = document.querySelector('#vs-screen .vs-art'), q = document.querySelector('#vs-screen .vs-quote'); return { anim: getComputedStyle(art).animationName, qop: getComputedStyle(q).opacity, active: window.__game.vsActive() }; }); ok('gerak dikurangi: tanpa animasi masuk, kutipan langsung terlihat', calm.anim === 'none' && calm.qop === '1' && calm.active, calm); await wait(2200); const after = await vsState(); ok('gerak dikurangi: layar tetap selesai dan cue Round 1 keluar', !after.active && after.cues.join() === 'round1', after); await page.emulateMedia({ reducedMotion: 'no-preference' }); } // ---------- melewati / membatalkan ---------- if (want('skip')) { for (const how of ['Space', 'Enter', 'click', 'tidak-awal']) { await startVS('browo', 'anice'); await wait(how === 'tidak-awal' ? 60 : 450); if (how === 'Space') await page.keyboard.press(' '); else if (how === 'Enter') await page.keyboard.press('Enter'); else if (how === 'click') await page.mouse.click(640, 260); else { await page.keyboard.press(' '); await page.mouse.click(640, 260); } await wait(30); let s = await vsState(); if (how === 'tidak-awal') { ok('skip: tidak bisa dilewati dalam 0,38 detik pertama (cegah klik ganda dari menu)', s.active && s.cues.length === 0, s); await wait(450); await page.keyboard.press(' '); await wait(30); s = await vsState(); } ok(`skip ${how}: layar selesai dan cue Round 1 sekali`, !s.active && s.cues.join() === 'round1' && s.phase === 'intro', s); await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(60); s = await vsState(); ok(`skip ${how}: menekan lagi tidak menggandakan cue`, s.cues.join() === 'round1', s); await wait(350); } // Esc selama VS tidak membuka jeda; hanya melewati await startVS('soloman', 'megachan'); await wait(450); await page.keyboard.press('Escape'); await wait(40); let s = await vsState(); ok('Esc selama VS: melewati, tidak membuka jeda', !s.active && !s.paused && s.cues.join() === 'round1', s); // tombol game tidak bocor ke bawah layar VS await startVS('soloman', 'megachan'); await wait(100); await page.keyboard.down('d'); await page.keyboard.press('i'); await page.keyboard.up('d'); const keys = await game(() => ({ x: Math.round(window.__game.hero.x), cd: window.__game.hero.cooldowns.skill1 })); ok('tombol gerak/skill tidak bocor saat VS', keys.x === 350 && keys.cd === 0, keys); await wait(2300); } if (want('abort')) { // kembali ke menu saat VS: overlay hilang, tidak ada cue susulan await startVS('browo', 'anice'); await wait(200); await game(() => window.__game.setMenuOpen(true)); await wait(60); let s = await vsState(); ok('abort: setMenuOpen(true) menutup layar VS tanpa cue', !s.el && !s.active && s.cues.length === 0, s); await wait(2400); s = await vsState(); ok('abort: tidak ada cue susulan setelah layar dibatalkan', s.cues.length === 0, s); // pertandingan baru saat VS masih jalan: tidak ada cue ganda dan hanya satu overlay await startVS('browo', 'anice'); await wait(300); await startVS('gufron', 'megachan'); await wait(100); s = await vsState(); const n = await game(() => document.querySelectorAll('#vs-screen').length); ok('abort: pertandingan baru saat VS menggantikan layar lama', n === 1 && s.names[0] === 'GUFRON' && s.active, { s, n }); await wait(2300); s = await vsState(); ok('abort: hanya satu cue Round 1 untuk pertandingan terbaru', s.cues.join() === 'round1' && !s.el, s); // announcer tidak ditahan: clip round_1 diminta setelah VS (bukan sebelum) const ann = await game(async () => { const g = window.__game; g.manual = false; const log = []; g.onRoundCue(e => log.push(e.cue + e.round)); g.startMatch({ player: 'browo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus', vs: true }); const t = performance.now(); await new Promise(r => setTimeout(r, 900)); const early = log.length; await new Promise(r => setTimeout(r, 1700)); const late = log.slice(); g.onRoundCue(null); return { early, late, t: Math.round(performance.now() - t) }; }); ok('onRoundCue: tidak terpanggil selama VS, sekali sesudahnya', ann.early === 0 && ann.late.join() === 'round1', ann); } // ---------- 2.3 layar hasil ---------- if (want('result')) { // jumlah koin tidak berubah: bandingkan dengan rumus lama const legacy = (r) => { const won = r.winner === 'player'; let g = won ? 120 : 30; if (won) { if (r.level && r.level !== 'easy') g += { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; if (r.perfect) g += 60; } g += Math.min(60, (r.bestCombo || 0) * 4); return g; }; const cases = []; for (const winner of ['player', 'enemy']) for (const level of ['easy', 'medium', 'hard', 'excellent']) for (const perfect of [false, true]) for (const bestCombo of [0, 3, 9, 20]) cases.push({ winner, level, perfect: perfect && winner === 'player', bestCombo, playerWins: 2, enemyWins: 0, player: 'browo', enemy: 'anice' }); const coin = await game(cases => { const M = window.Meta; M.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; const rows = []; for (const c of cases) { const before = M.coins; M.award(c); const a = M.lastAward; rows.push({ c, delta: M.coins - before, gain: a.gain, sum: a.lines.reduce((x, l) => x + l.amount, 0), keys: a.lines.map(l => l.key).join() }); } return rows; }, cases); const wrong = coin.filter(r => !(r.delta === legacy(r.c) && r.gain === r.delta && r.sum === r.delta)); ok(`koin: ${coin.length} kombinasi sama dengan rumus lama dan rincian berjumlah total`, wrong.length === 0, wrong.slice(0, 3)); ok('koin: rincian memuat baris menang/hadiah main, tingkat, bersih, kombo sesuai syarat', coin.every(r => r.keys.split(',')[0] === (r.c.winner === 'player' ? 'win' : 'play') && (r.c.winner === 'player' && r.c.level !== 'easy') === r.keys.includes('level') && r.c.perfect === r.keys.includes('perfect') && (r.c.bestCombo > 0) === r.keys.includes('combo')), coin.filter(r => false)); // rincian terpenuh (menang bersih, tingkat, kombo) lewat data buatan: harus tetap muat di 520 px await game(() => { const g = window.__game; g.setMenuOpen(true); FrontEnd.showResult({ winner: 'player', playerWins: 2, enemyWins: 0, player: 'sonofsolo', enemy: 'ethanolman', stage: 'elderwood', level: 'excellent', mode: 'versus', bestCombo: 9, matchBest: 14, perfect: true, award: { won: true, gain: 120 + 120 + 60 + 36, lines: [{ key: 'win', label: 'Menang', amount: 120 }, { key: 'level', label: 'Tingkat kesulitan', amount: 120, note: 'Sangat sulit' }, { key: 'perfect', label: 'Bersih', amount: 60, note: 'Tanpa kehilangan ronde' }, { key: 'combo', label: 'Bonus kombo', amount: 36, note: '9 pukulan, ronde terakhir' }] } }); }); await wait(2300); await shot('hasil-penuh-sonofsolo-ethanolman'); const full = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()), q = document.querySelector('.rs-stats dd'), m = document.querySelector('.rs-main').getBoundingClientRect(); return { bottom: Math.max(...b.map(x => x.bottom)), h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight, mainTop: m.top, mainBottom: m.bottom, rows: document.querySelectorAll('.rs-coins li').length, combo: q.textContent }; }); ok('hasil dengan empat baris koin dan kombo 14 muat di 520 px', full.bottom <= full.h - 8 && full.scroll <= 0 && full.rows === 4 && /14/.test(full.combo), full); // kemenangan pemain (tanpa kalah ronde = bersih) await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.__game.manual = false; }); for (const [p, e, side] of [['browo', 'megachan', 'player'], ['sonofsolo', 'ethanolman', 'enemy'], ['anice', 'gufron', 'player'], ['megachan', 'soloman', 'enemy']]) { await startVS(p, e, { vs: false, level: 'hard', stage: 'moonrise' }); const before = await game(() => window.Meta.coins); await finish(side); await wait(120); const r = await game(() => { const el = document.querySelector('#front-menu .rs'); const rows = [...document.querySelectorAll('.rs-coins li')].map(li => [li.querySelector('span').textContent, li.querySelector('b').textContent]); return { screen: document.querySelector('#front-menu').dataset.screen, tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, winName: el?.querySelector('.rs-fighter.win strong')?.textContent, loseName: el?.querySelector('.rs-fighter.lose .rs-plate small')?.textContent, winArt: el?.querySelector('.rs-fighter.win img')?.getAttribute('src'), quote: el?.querySelector('.rs-quote p')?.textContent, loseQuote: el?.querySelector('.rs-fighter.lose p')?.textContent, rows, total: el?.querySelector('.rs-total b')?.dataset.count, btn: [...document.querySelectorAll('.rs-actions [data-cmd]')].map(b => b.dataset.cmd + ':' + b.textContent.trim()), stats: [...document.querySelectorAll('.rs-stats dl div')].map(d => d.textContent), coins: window.Meta.coins, last: window.Meta.lastAward, bar: document.querySelector('#front-menu').scrollHeight, h: innerHeight }; }); const wn = side === 'player' ? p : e, ln = side === 'player' ? e : p; const info = await game(({ wn, ln }) => ({ wn: window.FIGHTERS?.[wn]?.name || 'BRO WO', ln: window.FIGHTERS?.[ln]?.name || 'BRO WO', q: window.QUOTES }), { wn, ln }); ok(`hasil ${p} vs ${e} (${side} menang): layar baru tampil`, r.screen === 'result' && !!r.h1 && r.tone === (side === 'player' ? 'win' : 'lose') && r.h1 === (side === 'player' ? 'MENANG' : 'KALAH'), r); ok(`hasil ${p} vs ${e}: pemenang besar dengan kutipan menang, yang kalah dengan kutipan kalah`, r.winName === info.wn && r.loseName === info.ln && r.winArt.includes(wn) && info.q[wn].win.some(t => r.quote.includes(t)) && info.q[ln].lose.some(t => r.loseQuote.includes(t)), { r, info: [info.wn, info.ln] }); ok(`hasil ${p} vs ${e}: toast koin tidak menumpuk di layar hasil`, await game(() => document.querySelectorAll('.meta-toast.coin').length === 0), 'toast koin masih tampil'); ok(`hasil ${p} vs ${e}: rincian koin berjumlah total dan sama dengan koin bertambah`, r.rows.length >= 1 && r.rows.reduce((x, [, a]) => x + Number(a.replace('+', '')), 0) === r.last.gain && Number(r.total) === r.last.gain && r.coins - before === r.last.gain, { rows: r.rows, total: r.total, delta: r.coins - before, gain: r.last.gain }); ok(`hasil ${p} vs ${e}: empat tombol dan empat statistik`, r.btn.join('|') === 'rematch:MAIN LAGI ↗|reselect:PILIH ULANG|shop:KE TOKO|home:MENU UTAMA' && r.stats.length === 4, r); await wait(1900); await shot(`hasil-${side === 'player' ? 'menang' : 'kalah'}-${p}-${e}`); const fit = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()), rs = document.querySelector('.rs').getBoundingClientRect(); return { bottom: Math.max(...b.map(x => x.bottom)), top: Math.min(...b.map(x => x.top)), rsBottom: rs.bottom, h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight, clipped: [...document.querySelectorAll('.rs-main > *')].some(n => { const r = n.getBoundingClientRect(); return r.bottom > innerHeight + 1 || r.top < 0; }) }; }); ok(`hasil ${p} vs ${e}: semua tombol muat di jendela 520 px tanpa gulir`, fit.bottom <= fit.h && fit.scroll <= 0 && !fit.clipped, fit); } } // ---------- Arcade/Survival ---------- if (want('run')) { await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1, arcade_clear: 1, survival5: 1 }; window.__game.manual = false; }); // Arcade: lawan pertama memakai layar VS berlabel, menang, lalu lawan kedua, lalu kalah -> layar akhir bergaya hasil await game(() => { window.Meta.startRun('arcade', 'browo'); }); await wait(150); let s = await vsState(); const label = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || ''); ok('arcade: lawan 1 memakai layar VS berlabel', s.el && s.active && /ARCADE · LAWAN 1 \/ 6/.test(label), { s, label }); await wait(450); await shot('vs-arcade'); await game(() => window.__game.vsSkip()); await finish('player', 2).then(() => null).catch(() => null); await wait(100); // setelah menang jeda 2,6 detik lalu lawan berikut (VS lagi) await wait(2800); s = await vsState(); ok('arcade: menang -> lawan 2 muncul dengan layar VS', s.el && /LAWAN 2 \/ 6/.test(await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || '')), s); await game(() => window.__game.vsSkip()); await finish('enemy'); await wait(200); const end = await game(() => { const el = document.querySelector('#front-menu .rs'); return { screen: document.querySelector('#front-menu').dataset.screen, tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, rows: [...document.querySelectorAll('.rs-coins li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()), total: el?.querySelector('.rs-total b')?.dataset.count, stats: [...document.querySelectorAll('.rs-stats dl div')].map(d => d.textContent), oldPanel: !!document.querySelector('.run-end'), metaHidden: document.querySelector('#meta-overlay')?.hidden ?? true }; }); ok('arcade: kalah di lawan 2 -> layar akhir memakai tampilan hasil baru', end.screen === 'result' && end.tone === 'lose' && end.h1 === 'BERAKHIR' && /KALAH DI LAWAN KE-2 DARI 6/.test(end.kicker) && !end.oldPanel, end); ok('arcade: rincian koin memuat lawan sebelumnya dan pertandingan terakhir', end.rows.length >= 2 && /sebelumnya/i.test(end.rows[0]) && /terakhir/i.test(end.rows[end.rows.length - 1]), end); await wait(1900); await shot('hasil-arcade-berakhir'); // Main lagi memulai rangkaian baru; Pilih ulang kembali ke pilih karakter dengan mode run await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState(); const lab2 = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || ''); ok('arcade: Main lagi memulai rangkaian baru (lawan 1)', s.el && /LAWAN 1 \/ 6/.test(lab2), { s, lab2 }); await game(() => window.__game.vsSkip()); await finish('enemy'); await wait(200); await page.click('[data-cmd=reselect]'); await wait(100); const sel = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, step: document.querySelector('#front-menu').dataset.step, eyebrow: document.querySelector('.select-header .eyebrow')?.textContent })); ok('arcade: Pilih ulang kembali ke pilih karakter (mode ARCADE)', sel.screen === 'select' && sel.step === '0' && /ARCADE/.test(sel.eyebrow), sel); // Survival berakhir pada kekalahan pertama await game(() => { window.Meta.startRun('survival', 'megachan'); }); await wait(100); await game(() => window.__game.vsSkip()); await finish('enemy'); await wait(200); const sv = await game(() => { const el = document.querySelector('#front-menu .rs'); return { tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, score: el?.querySelector('.rs-score')?.textContent.replace(/\s+/g, ' ') }; }); ok('survival: layar akhir tampil dengan rekor', sv.h1 === 'BERAKHIR' && /SURVIVAL/.test(sv.kicker), sv); await wait(1900); await shot('hasil-survival'); // Arcade tamat: menang semua lawan (6) await game(() => { window.Meta.startRun('arcade', 'anice'); }); for (let i = 0; i < 6; i++) { await wait(80); await game(() => window.__game.vsSkip()); await finish('player'); if (i < 5) await wait(2800); } await wait(300); const clr = await game(() => { const el = document.querySelector('#front-menu .rs'); return { tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, rows: [...document.querySelectorAll('.rs-coins li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()), total: el?.querySelector('.rs-total b')?.dataset.count, confetti: document.querySelectorAll('.rs-confetti i').length }; }); ok('arcade tamat: layar TAMAT! dengan bonus 500 dan konfeti', clr.h1 === 'TAMAT!' && clr.tone === 'win' && clr.rows.some(r => /Bonus Arcade tamat.*\+500/.test(r)) && clr.confetti > 0, clr); await wait(1900); await shot('hasil-arcade-tamat'); } // ---------- jalur menu: dua pemain, tombol hasil ---------- if (want('ui')) { await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.Meta.cancelRun(); window.__game.setMenuOpen(true); window.FrontEnd.home(); }); await page.click('[data-cmd="2p"]'); await wait(80); await page.click('[data-fighter=browo]'); await wait(1100); await page.click('[data-fighter=megachan]'); await wait(1100); await page.click('[data-cmd=start]'); await wait(200); let s = await vsState(); const meta = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent })); ok('dua pemain lewat menu: layar VS berlabel PEMAIN 1 / PEMAIN 2', s.active && meta.who.join() === 'PEMAIN 1,PEMAIN 2' && /DUA PEMAIN/.test(meta.top), { s, meta }); await wait(2200); s = await vsState(); ok('dua pemain: ronde mulai normal setelah VS', !s.active && s.cues.join() === 'round1', s); await finish('enemy'); await wait(150); const two = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, kicker: document.querySelector('.rs-kicker')?.textContent, tone: document.querySelector('.rs')?.dataset.tone })); ok('dua pemain: hasil menyebut PEMAIN 2 MENANG', two.screen === 'result' && /PEMAIN 2 MENANG/.test(two.kicker), two); // tombol: Ke toko membuka toko; tutup kembali ke layar hasil; Main lagi memulai pertandingan lagi (2P) await page.click('[data-cmd=shop]'); await wait(150); let m = await game(() => ({ hidden: document.querySelector('#meta-overlay')?.hidden, screen: document.querySelector('#front-menu').dataset.screen })); ok('tombol KE TOKO membuka toko di atas layar hasil', m.hidden === false && m.screen === 'result', m); await page.click('#meta-overlay [data-close]'); await wait(100); m = await game(() => ({ hidden: document.querySelector('#meta-overlay')?.hidden, screen: document.querySelector('#front-menu').dataset.screen, rs: !!document.querySelector('.rs') })); ok('menutup toko kembali ke layar hasil', m.hidden === true && m.screen === 'result' && m.rs, m); await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState(); const again = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent) })); ok('tombol MAIN LAGI memulai ulang (VS tampil, tetap dua pemain)', s.active && again.who.join() === 'PEMAIN 1,PEMAIN 2', { s, again }); await game(() => window.__game.vsSkip()); await finish('player'); await wait(150); await page.click('[data-cmd=home]'); await wait(100); m = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen })); ok('tombol MENU UTAMA kembali ke menu utama', m.screen === 'home', m); // lawan CPU lewat menu: VS menampilkan KOMPUTER dan tingkat kesulitan await page.click('[data-cmd=versus]'); await wait(80); await page.click('[data-fighter=sonofsolo]'); await wait(1100); await page.click('[data-fighter=ethanolman]'); await wait(1100); await page.click('[data-level=hard]'); await page.click('[data-cmd=start]'); await wait(450); const cpu = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent })); ok('versus CPU lewat menu: label KOMPUTER, arena, tingkat SULIT', cpu.who.join() === 'PEMAIN 1,KOMPUTER' && /VERSUS KOMPUTER/.test(cpu.top) && /SULIT/.test(cpu.top), cpu); await shot('vs-versus-menu'); await game(() => window.__game.vsSkip()); } // ---------- ponsel lanskap ---------- if (want('phone')) { await page.setViewportSize({ width: 667, height: 375 }); await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.__game.setMenuOpen(true); }); await startVS('sonofsolo', 'ethanolman'); await wait(1300); await shot('vs-667x375'); await game(() => window.__game.vsSkip()); await finish('player'); await wait(2300); await shot('hasil-667x375'); const fit = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()); return { bottom: Math.max(...b.map(x => x.bottom)), h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight }; }); ok('hasil di ponsel lanskap 667x375: tombol muat tanpa gulir', fit.bottom <= fit.h && fit.scroll <= 0, fit); await page.setViewportSize({ width: 1280, height: 520 }); } const failed = checks.filter(c => !c.pass); return { total: checks.length, failed: failed.length, shots: dir, ...(failed.length ? { failures: failed } : {}), passed: checks.filter(c => c.pass).map(c => c.name) }; };