// Tahap 1, perbaikan temuan review: regresi untuk lima perbaikan di game.js. // node tests/harness.mjs tests/t1-review.mjs // REVIEW_ONLY=regex hanya jalankan bagian yang namanya cocok (split|dot|cutin|puddle|feel) // Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px. export const options = { viewport: { width: 1280, height: 520 } }; export default async ({ page, game, url, errors }) => { const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info }); const only = process.env.REVIEW_ONLY ? new RegExp(process.env.REVIEW_ONLY) : null, want = n => !only || only.test(n); // ---------- 1. gelombang membelah tidak memukul dua kali ---------- if (want('split')) { const side = (kind, D) => game(({ kind, D }) => { const g = window.__game; g.startMatch({ player: kind === 'player' ? 'anice' : 'browo', enemy: kind === 'player' ? 'browo' : 'anice', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); if (kind === 'enemy') g.setP2(true); g.manual = true; g.advance(1.5); const mode = kind === 'player' ? 'p' : '.', fire = kind === 'player' ? 'o' : ','; g.press(mode); g.advance(1 / 60); g.release(mode); g.advance(7.6); if (kind === 'player') { g.hero.x = 350; g.dummy.x = 350 + D; } else { g.dummy.x = 930; g.hero.x = 930 - D; } const before = kind === 'player' ? g.dummy.damage : g.hero.hp; g.press(fire); g.advance(1 / 60); g.release(fire); let hits = 0, last = before; for (let i = 0; i < 120; i++) { g.advance(1 / 60); const now = kind === 'player' ? g.dummy.damage : g.hero.hp, d = Math.abs(now - last); if (d > 0) hits++; last = now; } return { total: Math.abs(last - before), hits }; }, { kind, D }); for (const kind of ['player', 'enemy']) { const base = await side(kind, 160); ok(`split ${kind}: jarak 160 memukul sekali`, base.total > 0 && base.hits === 1, base); for (const D of kind === 'player' ? [230, 260, 290, 400] : [280, 400]) { const r = await side(kind, D); ok(`split ${kind}: jarak ${D} tidak memukul dua kali`, r.total === base.total && r.hits === 1, { r, base }); } } } // ---------- 2. DoT tidak menaikkan hitungan kombo ---------- if (want('dot')) { // maks = jumlah pukulan nyata/ledakan terjadwal yang sah pada jurus itu (lob Son of Solo: dampak + ledakan = 2) for (const [ch, key, name, maks] of [['megachan', 'o', 'jejak api', 1], ['ethanolman', 'i', 'jejak mode', 1], ['sonofsolo', 'o', 'genangan lob', 2], ['sonofsolo', 'i', 'korosi', 1]]) { const r = await game(({ ch, key }) => { const g = window.__game; g.startMatch({ player: ch, enemy: ch === 'megachan' ? 'anice' : 'browo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); g.press('p'); g.advance(1 / 60); g.release('p'); g.advance(7.6); g.hero.x = 350; g.dummy.x = 550; g.press(key); g.advance(1 / 60); g.release(key); let maxHits = 0, rank = '', dmgFromDot = 0; for (let i = 0; i < 4; i++) { g.advance(.75); maxHits = Math.max(maxHits, g.snapshot().comboHits); g.draw(); const el = document.getElementById('combo-rank'); if (el && el.classList.contains('visible')) rank = el.dataset.rank; } dmgFromDot = Math.round(g.dummy.damage); g.dummy.state = 'idle'; g.dummy.action = null; const b = g.dummy.damage; g.hitDummy(20, 100, '#fff', g.hero.x, g.hero.y - 90, false, true, 1); const s = g.snapshot(); return { maxHits, rank, dmgFromDot, real: Math.round(g.dummy.damage - b), hitsAfter: s.comboHits, best: s.bestCombo }; }, { ch, key }); ok(`dot ${ch} '${key}' (${name}): tick tidak menaikkan kombo`, r.maxHits <= maks && !r.rank, r); ok(`dot ${ch} '${key}' (${name}): DoT tetap memberi damage`, r.dmgFromDot > 5, r); ok(`dot ${ch} '${key}' (${name}): pukulan nyata berikutnya penuh (20)`, r.real === 20 && r.hitsAfter <= maks + 1, r); } // pukulan nyata tetap menaikkan kombo const real = await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); for (let i = 0; i < 3; i++) { g.dummy.state = 'idle'; g.dummy.action = null; g.hitDummy(6, 60, '#fff', g.hero.x, g.hero.y - 90, false, true, 1); } return g.snapshot().comboHits; }); ok('dot: tiga pukulan nyata = 3 kombo', real === 3, real); } // ---------- 3. banner cut-in ultimate ---------- if (want('cutin')) { const read = async (player, enemy, p2) => { await game(({ player, enemy, p2 }) => { const g = window.__game; g.startMatch({ player, enemy, stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); if (p2) g.setP2(true); g.manual = true; g.advance(1.5); const key = p2 ? '.' : 'p'; if (!p2) g.hero.cooldowns.ultimate = 0; g.press(key); g.advance(1 / 60); g.release(key); g.advance(.04); g.draw(); }, { player, enemy, p2 }); return game(() => { const c = document.getElementById('ultimate-cutin'); return { visible: c.classList.contains('visible'), title: c.querySelector('strong').innerText, kicker: c.querySelector('.cutin-kicker').textContent, detail: c.querySelector('.cutin-detail').textContent.trim(), aria: c.getAttribute('aria-label') }; }); }; for (const ch of ['megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron']) { const r = await read(ch, 'browo', false); ok(`cutin ${ch}: judul/kicker/detail terisi dari manifest`, r.visible && r.title && !/undefined/.test(r.title + r.kicker + r.detail + r.aria) && r.kicker.includes('PLAYER') && r.detail, r); } const bw = await read('browo', 'megachan', false); ok('cutin browo: tetap HUTAN SAWIT', /HUTAN/.test(bw.title) && bw.kicker === 'BRO WO / PLAYER' && bw.detail === 'TEN-PALM RUN + GIANT SLAM', bw); const rv = await read('browo', 'megachan', true); ok('cutin sisi lawan: Mega Chan / RIVAL', rv.visible && /RIVAL/.test(rv.kicker) && !/undefined/.test(rv.title + rv.aria) && rv.title, rv); } // ---------- 4. genangan asam berada di lantai ---------- if (want('puddle')) { const draws = await game(() => { const g = window.__game, proto = CanvasRenderingContext2D.prototype, orig = proto.drawImage, log = []; proto.drawImage = function (im, ...a) { const s = im && im.src || ''; if (/fx-(big)?puddle/.test(s) && a.length === 4) log.push({ src: s.split('/').pop(), y: a[1], h: a[3] }); return orig.call(this, im, ...a); }; const out = {}; try { for (const [name, mode, key] of [['lob skill1', false, 'i'], ['mode skill2 genangan luas', true, 'o']]) { g.startMatch({ player: 'sonofsolo', enemy: 'browo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); if (mode) { g.press('p'); g.advance(1 / 60); g.release('p'); g.advance(7.6); } g.hero.x = 350; g.dummy.x = 800; g.press(key); g.advance(1 / 60); g.release(key); g.advance(1.4); log.length = 0; g.draw(); out[name] = { ground: g.dummy.y, draws: log.slice() }; } } finally { proto.drawImage = orig; } return out; }); for (const [name, d] of Object.entries(draws)) { const c = d.draws.at(-1); ok(`puddle ${name}: tergambar`, !!c, d); if (c) ok(`puddle ${name}: pusat tampak genangan dalam 20 px dari lantai`, Math.abs(c.y + c.h / 2 - d.ground) <= 20, { center: c.y + c.h / 2, ground: d.ground }); } } // ---------- 5. boot tanpa feel.js (cold start offline) ---------- if (want('feel')) { const p2 = await page.context().newPage(), errs = []; p2.on('pageerror', e => errs.push(e.message)); await p2.route('**/feel.js', r => r.abort()); await p2.goto(url + '?touch=0'); let ready = true; try { await p2.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 20000 }); } catch (_) { ready = false; } ok('feel hilang: game tetap siap tanpa galat', ready && !errs.length, { ready, errs }); if (ready) { const r = await p2.evaluate(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'versus' }); g.setAI(true); for (let i = 0; i < 80; i++) { g.advance(.25); if (i % 5 === 0) { g.press('d'); g.press(' '); g.release(' '); g.release('d'); } if (i % 9 === 0) { g.press('i'); g.release('i'); } if (i % 13 === 0) { g.press('p'); g.release('p'); } } const s = g.snapshot(); return { stepCount: s.stepCount, phase: s.match?.phase, feel: typeof window.Feel, hp: Math.round(g.hero.hp), dmg: Math.round(g.dummy.damage) }; }); ok('feel hilang: pertandingan berjalan tanpa galat', r.stepCount > 1000 && !errs.length, { r, errs }); // K.O. tanpa Feel: pukulan penentu ke lawan, lalu ke pemain (versus berakhir/ronde berganti tanpa galat) const ko = await p2.evaluate(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'versus' }); g.setAI(false); g.advance(6); g.dummy.state = 'idle'; g.dummy.damage = g.dummy.damage + 190; g.hitDummy(30, 120, '#fff', g.hero.x, g.hero.y - 90, false, true, 1); g.advance(5); const r1 = { round: g.snapshot().match?.round, phase: g.snapshot().match?.phase }; g.advance(6); g.hero.hp = 1; g.receiveHit(40); g.advance(6); return { r1, hero: Math.round(g.hero.hp), phase: g.snapshot().match?.phase, round: g.snapshot().match?.round }; }); ok('feel hilang: K.O. dan pergantian ronde tanpa galat', !errs.length && ko.r1.round >= 1, { ko, errs }); } await p2.close(); } const failed = checks.filter(c => !c.pass); const out = { total: checks.length, failed: failed.length, failedChecks: failed, passed: checks.filter(c => c.pass).length + ' lulus' }; if (failed.length) throw new Error('GAGAL ' + JSON.stringify(out, null, 1)); return out; };