// Tahap 2.1 + 2.4: meter ultimate di HUD, indikator mode, daftar gerakan di menu jeda. // node tests/harness.mjs tests/t2-hud.mjs // HUD_SHOTS=folder simpan screenshot (default: tmp/t2-hud di folder sementara sistem) // HUD_TOUCH=1 jalankan di layar sentuh 844x390 (html.touch); tanpa itu 1280x520 // HUD_ONLY=regex hanya jalankan bagian yang namanya cocok (meter|enemy|cpu|pause|layout) // Jendela 520 px dipakai sengaja: tampilan harus muat di tinggi 520 px. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const touch = !!process.env.HUD_TOUCH; export const options = touch ? { query: '?touch=1&embed=1', viewport: { width: 844, height: 390 } } : { viewport: { width: 1280, height: 520 } }; export default async ({ page, game, characters, errors }) => { const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info: pass ? undefined : info }); const only = process.env.HUD_ONLY ? new RegExp(process.env.HUD_ONLY) : null, want = n => !only || only.test(n); const dir = process.env.HUD_SHOTS || path.join(os.tmpdir(), 't2-hud'); fs.mkdirSync(dir, { recursive: true }); const tag = touch ? 'touch' : 'desk'; const shot = async name => { await game(() => window.__game.draw()); await page.screenshot({ path: path.join(dir, `${tag}-${name}.png`) }); }; const MODES = { browo: 'MENGAMUK', gufron: 'KOLONI', soloman: 'BLUSUKAN', anice: 'SENATOR', ethanolman: 'LEDAKAN', sonofsolo: 'ASAM', megachan: 'MURKA' }; const COLORS = { browo: '#ff6e28', gufron: '#c9a05a', soloman: '#ffd24a', anice: '#ffd24a', ethanolman: '#ff9a2a', sonofsolo: '#9dff3c', megachan: '#ff2a2a' }; const BASE = { browo: '#d9b44a', gufron: '#c9a05a', soloman: '#e0b04a', anice: '#5aa9f0', ethanolman: '#2fbf7a', sonofsolo: '#7bd23c', megachan: '#d62828' }; // Membaca keadaan meter dari DOM (apa yang benar-benar dilihat pemain). const read = side => game(side => { const r = document.querySelector('#ult-' + side), f = r.querySelector('.ult-fill'), t = f.style.transform.match(/scaleX\(([\d.]+)\)/); const box = r.getBoundingClientRect(), tr = r.querySelector('.ult-track').getBoundingClientRect(); return { state: r.dataset.state, name: r.querySelector('.ult-name').textContent, val: r.querySelector('.ult-val').textContent, p: t ? +t[1] : null, hidden: r.hidden, low: r.classList.contains('low'), again: r.classList.contains('again'), color: r.style.getPropertyValue('--ult'), kbd: r.querySelector('.ult-kbd').hidden ? '' : r.querySelector('.ult-kbd').textContent, label: r.getAttribute('aria-label'), box: { x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.width), h: Math.round(box.height) }, trackW: Math.round(tr.width), vis: getComputedStyle(r).visibility }; }, side); const start = (p, e, extra = {}) => game(({ p, e, extra }) => { const g = window.__game; g.manual = true; g.startMatch({ player: p, enemy: e, stage: 'bellora', level: 'easy', mode: extra.mode || 'versus' }); g.setAI(!!extra.ai); if (extra.p2) g.setP2(true); g.advance(extra.wait ?? 3); return g.snapshot().hero.cooldowns; }, { p, e, extra }); const press = (key, hold = 1 / 30) => game(({ key, hold }) => { const g = window.__game; g.press(key); g.advance(hold); g.release(key); g.advance(1 / 60); }, { key, hold }); const adv = s => game(s => { window.__game.advance(s); }, s); const near = (a, b, t = .02) => Math.abs(a - b) <= t; // ---------- 1. meter pemain: SIAP -> mengisi -> mode (7 karakter) ---------- if (want('meter')) for (const [i, ch] of characters.entries()) { const rival = characters[(i + 3) % characters.length]; await start(ch, rival, { ai: false }); const a = await read('player'); ok(`${ch}: awal ronde SIAP berdenyut`, a.state === 'ready' && a.val === 'SIAP' && a.name === 'ULTIMATE' && near(a.p, 1, .001) && !a.hidden && (touch ? true : a.kbd === 'P'), a); ok(`${ch}: warna meter = warna karakter`, a.color.toLowerCase() === BASE[ch], { got: a.color, want: BASE[ch] }); if (ch === characters[0]) await shot('ready'); await press('p'); // lewati sinematik ultimate sampai mode atau cooldown berjalan let seen = null, mid = null, prevP = null, dec = true, steps = 0, charging = null; for (; steps < 200 && !seen; steps++) { await adv(.1); const r = await read('player'); if (r.state === 'mode') seen = r; else if (r.state === 'charging' && !charging) charging = r; } ok(`${ch}: setelah ultimate masuk mode ${MODES[ch]}`, seen && seen.name === MODES[ch] && seen.color.toLowerCase() === COLORS[ch], { seen, steps }); if (seen) { ok(`${ch}: bar mode penuh di awal lalu menyusut`, seen.p > .85, seen); const vals = [seen.p]; for (let k = 0; k < 4; k++) { await adv(1.5); const r = await read('player'); if (r.state !== 'mode') break; vals.push(r.p); mid = r; } for (let k = 1; k < vals.length; k++) if (!(vals[k] < vals[k - 1])) dec = false; ok(`${ch}: bar mode menyusut monoton`, dec && vals.length >= 4, vals); ok(`${ch}: teks sisa waktu mengikuti bar`, mid && /^\d+,\d dtk$/.test(mid.val) && near(parseFloat(mid.val.replace(',', '.')) / 10, mid.p, .03), mid); if (ch === 'browo' || ch === 'anice') await shot('mode-' + ch); for (let k = 0; k < 12; k++) { await adv(1); const r = await read('player'); if (r.state !== 'mode') { prevP = r; break; } } ok(`${ch}: mode berakhir kembali ke bar cooldown`, prevP && prevP.state === 'charging' && prevP.p < 1 && /^\d+ dtk$/.test(prevP.val), prevP); } // cooldown menurun: bar naik, teks sesuai cooldown asli const before = await read('player'); await adv(5); const after = await read('player'); const cdNow = await game(() => window.__game.hero.cooldowns.ultimate); ok(`${ch}: bar cooldown naik dan teks = ceil(cooldown)`, after.state === 'charging' && after.p > before.p && after.val === Math.ceil(cdNow) + ' dtk', { before, after, cdNow }); if (ch === characters[0]) await shot('charging'); // pulih penuh -> SIAP lagi await adv(40); const done = await read('player'); ok(`${ch}: cooldown habis kembali SIAP`, done.state === 'ready' && near(done.p, 1, .001), done); } // ---------- 2. meter lawan (sisi P2 dan CPU) memakai data yang sama ---------- if (want('enemy')) for (const [i, ch] of characters.entries()) { const me = characters[(i + 1) % characters.length]; await start(me, ch, { ai: false, p2: true }); const a = await read('enemy'); ok(`lawan ${ch}: terlihat dan SIAP di awal (ringkas, tanpa tombol)`, !a.hidden && a.vis === 'visible' && a.state === 'ready' && a.kbd === '', a); ok(`lawan ${ch}: warna meter = warna karakter`, a.color.toLowerCase() === BASE[ch], { got: a.color, want: BASE[ch] }); await press('.'); let seen = null, steps = 0; for (; steps < 200 && !seen; steps++) { await adv(.1); const r = await read('enemy'); if (r.state === 'mode') seen = r; } ok(`lawan ${ch}: mode ${MODES[ch]} tampil di sisi lawan`, seen && seen.name === MODES[ch] && seen.color.toLowerCase() === COLORS[ch] && seen.p > .8, { seen, steps }); // efek mode sungguhan terjadi di sisi lawan (cooldown skill diganti) sehingga indikator tidak sekadar kosmetik const rage = await game(() => window.__game.dummy.bwRage); ok(`lawan ${ch}: mode aktif juga di mesin (dummy.bwRage)`, rage === true, rage); if (ch === 'anice' || ch === 'browo') await shot('enemy-mode-' + ch); await adv(12); const back = await read('enemy'); const cdNow = await game(() => window.__game.dummy.cooldowns.ultimate); ok(`lawan ${ch}: setelah mode bar cooldown sesuai`, back.state === 'charging' && back.val === Math.ceil(cdNow) + ' dtk', { back, cdNow }); } // ---------- 3. lawan CPU memakai ultimate sendiri; latihan tanpa AI menyembunyikan meter lawan ---------- if (want('cpu')) { await start('gufron', 'megachan', { ai: true, wait: .2 }); const states = new Set(); let modeSeen = null; for (let k = 0; k < 400 && !modeSeen; k++) { await adv(.25); const r = await read('enemy'); states.add(r.state); if (r.state === 'mode') modeSeen = r; } ok('CPU: meter lawan melewati SIAP lalu mode saat CPU memakai ultimate', states.has('ready') && modeSeen && modeSeen.name === 'MURKA', { states: [...states], modeSeen }); await start('gufron', 'megachan', { mode: 'training', ai: false }); const off = await read('enemy'); ok('latihan tanpa AI: meter lawan tersembunyi', off.hidden && off.vis === 'hidden', off); await game(() => window.__game.setAI(true)); await adv(.1); const on = await read('enemy'); ok('latihan dengan AI: meter lawan tampil', !on.hidden && on.vis === 'visible', on); const pl = await read('player'); ok('latihan: meter pemain tetap tampil', !pl.hidden, pl); } // ---------- 4. karakter lama (ARCO, FENR) tidak merusak meter ---------- if (want('legacy')) { for (const [p, e] of [['arco', 'fenr'], ['fenr', 'arco']]) { await start(p, e, { ai: true, wait: 3 }); const a = await read('player'), b = await read('enemy'); ok(`${p} vs ${e}: meter terisi`, a.state === 'ready' && b.state === 'ready', { a, b }); await press('p'); await adv(4); const c = await read('player'); ok(`${p}: setelah ultimate meter berubah (mode/cooldown)`, ['mode', 'charging'].includes(c.state), c); } } // ---------- 4b. menu jeda untuk karakter lama dan jendela sempit ---------- if (want('pause')) { await game(() => { for (const s of [...document.querySelectorAll('style')]) if (s.textContent.includes('#pause-panel{display:none!important}')) s.remove(); try { localStorage.removeItem('konoha.moveList'); } catch (_) {} }); for (const ch of ['arco', 'fenr', 'mira']) { await start(ch, 'gufron', { ai: true, wait: .5 }); const r = await game(() => { window.__game.setPaused(true); const d = document.querySelector('#pause-panel'), rows = d.querySelectorAll('.mv-row').length, keys = [...d.querySelectorAll('.mv-key')].map(k => k.textContent); window.__game.setPaused(false); return { open: true, rows, keys }; }); ok(`jeda ${ch} (karakter lama): daftar terisi tanpa galat`, r.rows >= 12 && ['Spasi', 'I', 'O', 'P', 'K', 'L', 'J'].every(k => r.keys.includes(k)), r); } if (!touch) { await page.setViewportSize({ width: 600, height: 420 }); await page.waitForTimeout(100); await start('browo', 'anice', { ai: true, wait: .5 }); await game(() => window.__game.setPaused(true)); const n = await game(() => { const d = document.querySelector('#pause-panel'), r = d.getBoundingClientRect(), b = document.querySelector('#resume-btn').getBoundingClientRect(), det = document.querySelector('#move-list'); const was = det.open; det.open = true; return new Promise(res => setTimeout(() => { d.scrollTop = d.scrollHeight; const rows = [...d.querySelectorAll('.mv-row')], last = rows[rows.length - 1].getBoundingClientRect(), dr = d.getBoundingClientRect(); res({ was, fits: r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth, resume: b.top >= 0 && b.bottom <= innerHeight, lastVisible: last.bottom <= dr.bottom + 1, canScroll: d.scrollHeight > d.clientHeight, oy: getComputedStyle(d).overflowY, hs: d.scrollWidth > d.clientWidth }); }, 60)); }); ok('jeda 600x420: terlipat di jendela sempit, dialog muat, dapat digulir saat dibuka, tanpa gulir samping', n.was === false && n.fits && n.resume && n.lastVisible && n.oy === 'auto' && !n.hs, n); await shot('pause-600x420'); await game(() => { document.querySelector('#pause-panel').scrollTop = 0; window.__game.setPaused(false); }); await page.setViewportSize({ width: 1280, height: 520 }); } } // ---------- 5. menu jeda: daftar gerakan ---------- if (want('pause')) { await game(() => { for (const s of [...document.querySelectorAll('style')]) if (s.textContent.includes('#pause-panel{display:none!important}')) s.remove(); try { localStorage.removeItem('konoha.moveList'); } catch (_) {} }); const dump = () => game(() => { const d = document.querySelector('#pause-panel'), det = document.querySelector('#move-list'), body = document.querySelector('#move-list-body'); const r = d.getBoundingClientRect(), rb = document.querySelector('#resume-btn').getBoundingClientRect(); return { open: d.open, detOpen: det.open, text: d.innerText, keys: [...body.querySelectorAll('.mv-key')].map(k => k.textContent), names: [...body.querySelectorAll('.mv-row > span:last-child > span')].map(n => n.textContent), titles: [...body.querySelectorAll('h3')].map(h => h.childNodes[0].textContent), rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), b: Math.round(r.bottom) }, resume: { y: Math.round(rb.y), b: Math.round(rb.bottom) }, scroll: { sh: d.scrollHeight, ch: d.clientHeight, oy: getComputedStyle(d).overflowY }, vw: innerWidth, vh: innerHeight, paused: window.__game.snapshot().paused }; }); const FIGHT = await game(() => Object.fromEntries(Object.entries(window.FIGHTERS).map(([id, f]) => [id, { names: f.names, mode: f.mode && { label: f.mode.label, names: f.mode.names } }]))); const BW = await game(() => ({ names: window.Browo.names, rage: window.Browo.rageNames })); for (const ch of characters) { await start(ch, characters[(characters.indexOf(ch) + 2) % characters.length], { ai: true, wait: .5 }); await game(() => window.__game.setPaused(true)); const d = await dump(); const want4 = ch === 'browo' ? BW.names : FIGHT[ch].names, wantMode = ch === 'browo' ? BW.rage : FIGHT[ch].mode.names; ok(`jeda ${ch}: dialog terbuka dan game dijeda`, d.open && d.paused, d); ok(`jeda ${ch}: berisi 11 tombol dasar`, ['A', 'D', 'W', 'S', 'Spasi', 'I', 'O', 'P', 'K', 'L', 'J'].every(k => d.keys.includes(k)), d.keys); ok(`jeda ${ch}: gerakan perintah`, d.text.includes('Uppercut') && d.text.includes('Tinju maju') && d.keys.filter(k => k === '↓').length === 3 && d.keys.includes('→'), d.keys); ok(`jeda ${ch}: nama skill karakter`, want4.every(n => d.names.includes(n)), { want4, names: d.names }); ok(`jeda ${ch}: nama skill mode ${MODES[ch]}`, wantMode.every(n => d.names.includes(n)) && d.titles.some(t => t === 'Mode ' + MODES[ch]), { wantMode, titles: d.titles }); ok(`jeda ${ch}: tanpa bagian Pemain 2 di mode lawan CPU`, !d.titles.includes('Pemain 2') && !d.keys.includes('N'), d.titles); ok(`jeda ${ch}: teks Indonesia, tidak ada "PAUSED"`, !/PAUSED/.test(d.text) && d.text.includes('JEDA'), d.text.slice(0, 80)); ok(`jeda ${ch}: panel muat di jendela (atau dapat digulir)`, d.rect.y >= 0 && d.rect.b <= d.vh && d.resume.y >= 0 && d.resume.b <= d.vh, d); ok(`jeda ${ch}: seluruh daftar terlihat tanpa menggulir`, d.scroll.sh <= d.scroll.ch + 1, d.scroll); if (ch === 'browo' || ch === 'anice') await shot('pause-' + ch); await game(() => window.__game.setPaused(false)); } // dua pemain: kontrol P2 + nama skill lawan await start('gufron', 'anice', { ai: false, p2: true, wait: .5 }); await game(() => window.__game.setPaused(true)); let d = await dump(); ok('jeda 2P: bagian Pemain 2 dengan tombol P2', d.titles.includes('Pemain 2') && ['←', '→', '↑', 'N', 'M', ',', '.'].every(k => d.keys.includes(k)), d); ok('jeda 2P: nama skill lawan (ANICE) di bagian P2', FIGHT.anice.names.every(n => d.names.includes(n)), d.names); ok('jeda 2P: panel muat atau dapat digulir', d.rect.y >= 0 && d.rect.b <= d.vh && d.resume.b <= d.vh, d); await shot('pause-2p'); const reach = await game(() => { const dlg = document.querySelector('#pause-panel'); dlg.scrollTop = dlg.scrollHeight; const rows = [...dlg.querySelectorAll('.mv-row')], last = rows[rows.length - 1].getBoundingClientRect(), dr = dlg.getBoundingClientRect(); const r = { canScroll: dlg.scrollHeight > dlg.clientHeight, lastVisible: last.bottom <= dr.bottom + 1 && last.top >= dr.top, oy: getComputedStyle(dlg).overflowY }; dlg.scrollTop = 0; return r; }); ok('jeda 2P: baris terakhir dapat dijangkau (muat atau digulir)', reach.lastVisible && (!reach.canScroll || reach.oy === 'auto'), reach); // tombol lipat + preferensi diingat const toggled = await game(() => { const det = document.querySelector('#move-list'), was = det.open; det.querySelector('summary').click(); return new Promise(r => setTimeout(() => r({ was, now: det.open, pref: localStorage.getItem('konoha.moveList') }), 60)); }); ok('jeda: ringkasan melipat/membuka daftar dan menyimpan pilihan', toggled.was !== toggled.now && toggled.pref === (toggled.now ? '1' : '0'), toggled); d = await dump(); await shot('pause-' + (toggled.now ? 'open' : 'folded')); ok('jeda: terlipat tetap muat di jendela', d.rect.y >= 0 && d.rect.b <= d.vh, d); await game(() => window.__game.setPaused(false)); await game(() => window.__game.setPaused(true)); d = await dump(); ok('jeda: pilihan lipat diingat saat dibuka lagi', d.detOpen === toggled.now, { d: d.detOpen, want: toggled.now }); // Esc melanjutkan await page.keyboard.press('Escape'); await page.waitForTimeout(80); d = await dump(); ok('jeda: Esc melanjutkan permainan', !d.open && !d.paused, d); // jeda -> lanjut tidak merusak meter const m = await read('player'); ok('jeda: meter pemain tetap berfungsi setelah lanjut', ['ready', 'charging', 'mode'].includes(m.state), m); } // ---------- 6. tata letak: meter muat, tidak menimpa HP, tombol sentuh, dan kartu skill ---------- if (want('layout')) { await start('anice', 'browo', { ai: true, wait: .5 }); const lay = await game(() => { const R = s => { const e = document.querySelector(s); if (!e || e.offsetParent === null && getComputedStyle(e).position !== 'fixed') return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, r: r.right, b: r.bottom, w: r.width, h: r.height }; }; const hit = (a, b) => a && b && a.x < b.r && b.x < a.r && a.y < b.b && b.y < a.b; const pm = R('#ult-player'), em = R('#ult-enemy'), pl = R('.fighter-one .life-frame'), el = R('.fighter-two .life-frame'), pc = R('.fighter-one .bar-caption'), ec = R('.fighter-two .bar-caption'); const pp = R('.fighter-one .portrait-frame'), ep = R('.fighter-two .portrait-frame'), clock = R('.match-center'), acts = R('.match-actions'), ft = R('#player-form-timer'); const touchBtns = [...document.querySelectorAll('#touch-controls .dbtn, #touch-controls .tbtn')].map(b => b.getBoundingClientRect()).filter(r => r.width > 0); const meters = [pm, em].filter(Boolean); return { vw: innerWidth, vh: innerHeight, pm, em, overlapHp: hit(pm, pl) || hit(em, el), overlapCaption: hit(pm, pc) || hit(em, ec), overlapPortrait: hit(pm, pp) || hit(em, ep), overlapClock: hit(pm, clock) || hit(em, clock), overlapActions: hit(pm, acts) || hit(em, acts), overlapTouch: touchBtns.some(t => meters.some(m => m.x < t.right && t.x < m.r && m.y < t.bottom && t.y < m.b)), touchCount: touchBtns.length, inView: meters.every(m => m.x >= 0 && m.r <= innerWidth && m.y >= 0 && m.b <= innerHeight), hscroll: document.documentElement.scrollWidth > innerWidth, vscroll: document.documentElement.scrollHeight > innerHeight, cards: R('.skill-deck') }; }); ok(`layout ${tag}: meter pemain dan lawan terlihat di dalam jendela`, lay.pm && lay.em && lay.inView, lay); ok(`layout ${tag}: tidak menimpa bar HP, keterangan, potret, jam, atau tombol jeda`, !lay.overlapHp && !lay.overlapCaption && !lay.overlapPortrait && !lay.overlapClock && !lay.overlapActions, lay); ok(`layout ${tag}: tidak menimpa tombol sentuh`, !lay.overlapTouch && (!touch || lay.touchCount >= 8), lay); ok(`layout ${tag}: tidak ada gulir halaman`, !lay.hscroll && !lay.vscroll, lay); await shot('layout'); // jendela lain pada pengujian desktop: sempit (950 / 620) dan tinggi (720) if (!touch) for (const [w, h] of [[1280, 720], [960, 520], [844, 390], [600, 420]]) { await page.setViewportSize({ width: w, height: h }); await page.waitForTimeout(120); const l = await game(() => { const R = s => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x, y: r.y, r: r.right, b: r.bottom }; }, hit = (a, b) => a.x < b.r && b.x < a.r && a.y < b.b && b.y < a.b; const pm = R('#ult-player'), em = R('#ult-enemy'); return { pm, em, hp: hit(pm, R('.fighter-one .life-frame')) || hit(em, R('.fighter-two .life-frame')), cap: hit(pm, R('.fighter-one .bar-caption')) || hit(em, R('.fighter-two .bar-caption')), por: hit(pm, R('.fighter-one .portrait-frame')) || hit(em, R('.fighter-two .portrait-frame')), mid: hit(pm, R('.match-center')) || hit(em, R('.match-center')), inView: pm.x >= 0 && em.r <= innerWidth && em.b <= innerHeight, vs: document.documentElement.scrollHeight > innerHeight }; }); ok(`layout ${w}x${h}: meter tidak bertabrakan dan muat`, !l.hp && !l.cap && !l.por && !l.mid && l.inView && !l.vs, l); await shot(`layout-${w}x${h}`); } if (!touch) await page.setViewportSize({ width: 1280, height: 520 }); } const failed = checks.filter(c => !c.pass); return { passed: checks.length - failed.length, total: checks.length, failed, pageErrors: errors.length }; };