// Tahap 1.1-1.6: efek jurus (gelombang, area, dash, jurus mode, semut Gufron). // node tests/harness.mjs tests/t1-fx.mjs // Setiap jurus (skill1, skill2, versi mode) enam karakter berbasis data dilepas dari sisi pemain DAN sisi lawan (P2/CPU memakai // data jurus yang sama). Menguji: damage tetap dalam batas, waktu kena tidak bergeser, efek benar-benar bergerak, mekanik jurus // mode (rebound, trail, split, dot, bind, convoy, aftershock, pull), dan mengambil tangkapan layar ke FX_SHOTS (default // /t1-fx-shots) untuk ditinjau mata. Gagal bila ada pernyataan yang salah atau galat halaman. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export const options = { viewport: { width: 1280, height: 720 } }; const OUT = process.env.FX_SHOTS || path.join(os.tmpdir(), 't1-fx-shots'); const DT = 1 / 60; // Daftar jurus: id, skill, mode?, jarak target (D) agar kena, kind, dan waktu tangkapan layar setelah dilepas. const MOVES = [ { c: 'megachan', s: 'skill1', k: 'dash', D: 300, T: [.15, .25, .36, .55] }, { c: 'megachan', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 16 }, { c: 'megachan', s: 'skill1', m: 1, k: 'rebound', D: 300, T: [.3, .5, .8, 1.1] }, { c: 'megachan', s: 'skill2', m: 1, k: 'trail', D: 520, T: [.45, .6, .9, 1.6] }, { c: 'sonofsolo', s: 'skill1', k: 'lob', D: 450, T: [.4, .7, 1.0, 1.4] }, { c: 'sonofsolo', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 15 }, { c: 'sonofsolo', s: 'skill1', m: 1, k: 'dot', D: 480, T: [.4, .55, .8, 1.5] }, { c: 'sonofsolo', s: 'skill2', m: 1, k: 'lob', D: 450, T: [.4, .7, 1.0, 1.6] }, { c: 'ethanolman', s: 'skill1', k: 'wave', D: 480, T: [.4, .5, .62, .8], base: 17, lift: true }, { c: 'ethanolman', s: 'skill2', k: 'area', D: 100, T: [.15, .3, .4, .7] }, { c: 'ethanolman', s: 'skill1', m: 1, k: 'trail', D: 500, T: [.45, .6, .9, 1.6] }, { c: 'ethanolman', s: 'skill2', m: 1, k: 'aftershock', D: 100, T: [.15, .32, .45, .7] }, { c: 'anice', s: 'skill1', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 14 }, { c: 'anice', s: 'skill2', k: 'area', D: 110, T: [.15, .34, .45, .8] }, { c: 'anice', s: 'skill1', m: 1, k: 'dash', D: 300, T: [.15, .25, .36, .55] }, { c: 'anice', s: 'skill2', m: 1, k: 'split', D: 560, T: [.45, .65, .85, 1.1] }, { c: 'soloman', s: 'skill1', k: 'dash', D: 300, T: [.15, .25, .36, .55] }, { c: 'soloman', s: 'skill2', k: 'area', D: 100, T: [.15, .3, .4, .7] }, { c: 'soloman', s: 'skill1', m: 1, k: 'convoy', D: 520, T: [.4, .55, .7, .95] }, { c: 'soloman', s: 'skill2', m: 1, k: 'pull', D: 330, T: [.1, .25, .34, .6] }, { c: 'gufron', s: 'skill1', k: 'ants', D: 480, T: [.3, .6, 1.0, 1.4] }, { c: 'gufron', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 15 }, { c: 'gufron', s: 'skill1', m: 1, k: 'ants', D: 480, T: [.3, .6, 1.0, 1.4] }, { c: 'gufron', s: 'skill2', m: 1, k: 'bind', D: 480, T: [.4, .55, .7, .95] }, ]; const OTHER = { megachan: 'browo', sonofsolo: 'browo', ethanolman: 'browo', anice: 'browo', soloman: 'browo', gufron: 'browo' }; export default async ({ page, game, errors }) => { fs.mkdirSync(OUT, { recursive: true }); const fails = [], log = []; const check = (name, ok, info = '') => { if (!ok) fails.push(`${name} ${info}`); }; await game(() => { // Pembantu di dalam halaman: menyiapkan satu jurus, melepasnya, lalu memajukan waktu sambil mencatat sampel. window.__fxt = { setup({ c, o, side, mode, D }) { const g = window.__game; g.manual = true; g.startMatch({ player: side === 'e' ? o : c, enemy: side === 'e' ? c : o, stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); if (side === 'e') g.setP2(true); g.advance(1.5); if (mode) { const k = side === 'e' ? '.' : 'p'; g.press(k); g.release(k); g.advance(7.6); } g.hero.hp = 200; g.dummy.damage = 0; g.hero.invuln = 0; g.dummy.invuln = 0; g.hero.cooldowns.skill1 = g.hero.cooldowns.skill2 = 0; g.dummy.cooldowns.skill1 = g.dummy.cooldowns.skill2 = 0; if (side === 'e') { g.dummy.x = 930; g.hero.x = 930 - D; } else { g.hero.x = 350; g.dummy.x = 350 + D; } g.advance(.2); }, cast(side, skill) { const g = window.__game, k = side === 'e' ? (skill === 'skill1' ? 'm' : ',') : (skill === 'skill1' ? 'i' : 'o'); g.press(k); g.release(k); }, sample(side) { const g = window.__game, t = side === 'e' ? g.hero : g.dummy, fx = g.fx.snapshot(); return { dmg: side === 'e' ? 200 - g.hero.hp : g.dummy.damage, x: t.x, y: t.y, st: side === 'e' ? g.hero.state : g.dummy.state, hurt: side === 'e' ? g.hero.hurtTime > 0 : g.dummy.state === 'hurt', fx }; }, }; }); for (const side of ['p', 'e']) for (const mv of MOVES) { const name = `${side === 'p' ? 'pemain' : 'lawan'}:${mv.c}.${mv.m ? 'mode.' : ''}${mv.s}`; if (process.env.FX_ONLY && !new RegExp(process.env.FX_ONLY).test(name)) continue; await game(o => window.__fxt.setup(o), { c: mv.c, o: OTHER[mv.c], side, mode: !!mv.m, D: mv.D }); const x0 = await game(side => window.__fxt.sample(side).x, side); await game(({ side, s }) => window.__fxt.cast(side, s), { side, s: mv.s }); const rows = []; let t = 0, shot = 0; const end = Math.max(...mv.T) + (mv.k === 'dot' || mv.k === 'trail' ? 2.2 : mv.k === 'ants' ? 2.8 : .6); while (t < end) { await game(d => window.__game.advance(d), DT); t += DT; const r = await game(side => window.__fxt.sample(side), side); r.t = +t.toFixed(3); rows.push(r); if (shot < mv.T.length && t >= mv.T[shot] - 1e-6) { await page.screenshot({ path: path.join(OUT, `${side}_${mv.c}_${mv.m ? 'm_' : ''}${mv.s}_${shot}.png`), clip: { x: 0, y: 330, width: 1280, height: 290 } }); shot++; } } const dmgEnd = rows[rows.length - 1].dmg, firstHit = rows.find(r => r.dmg > 0)?.t ?? null; const hitsBig = rows.reduce((n, r, i) => n + (i && r.dmg - rows[i - 1].dmg >= 5 ? 1 : 0), 0); const moved = rows.some(r => r.fx.projectiles.some(p => p.age > .1)) || rows.some(r => r.fx.ghosts > 0 || r.fx.tele.hero >= 0 || r.fx.tele.dummy >= 0 || r.fx.effects.length); log.push(`${name}: dmg ${dmgEnd} 1stHit ${firstHit} hits>=5 ${hitsBig}`); check(name + ' efek tidak tampil/bergerak', moved); check(name + ' tidak ada partikel berlebih', rows.every(r => r.fx.particles <= 470), String(Math.max(...rows.map(r => r.fx.particles)))); if (mv.k === 'wave' && !mv.m) { // damage total tetap dalam +-10% dari angka sebelum efek dipoles (satu pukulan) check(name + ' damage ' + dmgEnd, dmgEnd >= mv.base * .9 && dmgEnd <= mv.base * 1.1 + .01, `base ${mv.base}`); const ids = new Set(rows.flatMap(r => r.fx.projectiles.map(p => p.fx))); log.push(` gaya: ${[...ids].join(',')}`); if (mv.lift) { const ground = rows[0].y; check(name + ' lawan terangkat', rows.some(r => r.y < ground - 20), 'y min ' + Math.min(...rows.map(r => r.y))); } } if (mv.k === 'area') { const maxTele = Math.max(...rows.map(r => Math.max(r.fx.tele.hero, r.fx.tele.dummy))); check(name + ' ada aba-aba cincin', maxTele > .9, String(maxTele)); const firstTele = rows.find(r => r.fx.tele.hero >= 0 || r.fx.tele.dummy >= 0)?.t; check(name + ' aba-aba ~0,25 s sebelum kena', firstHit !== null && firstTele !== undefined && firstHit - firstTele > .18 && firstHit - firstTele < .32, `tele ${firstTele} hit ${firstHit}`); check(name + ' kena di waktu semula (0,29-0,32 s)', firstHit !== null && firstHit >= .27 && firstHit <= .35, String(firstHit)); check(name + ' gelombang kejut + retakan', rows.some(r => r.fx.effects.includes('cf-shock')) && rows.some(r => r.fx.effects.includes('cf-crack'))); } if (mv.k === 'dash') check(name + ' bayangan jejak', rows.some(r => r.fx.ghosts >= 3), String(Math.max(...rows.map(r => r.fx.ghosts)))); if (mv.k === 'rebound') { check(name + ' menembus lalu memantul (2 pukulan)', hitsBig === 2, String(hitsBig)); check(name + ' total ~35', dmgEnd >= 32 && dmgEnd <= 40, String(dmgEnd)); } if (mv.k === 'trail') { check(name + ' genangan api', Math.max(...rows.map(r => r.fx.hazards)) >= 3, String(Math.max(...rows.map(r => r.fx.hazards)))); check(name + ' membakar berkala setelah kena', dmgEnd > 31, String(dmgEnd)); } if (mv.k === 'split') { check(name + ' membelah jadi dua', rows.some(r => r.fx.projectiles.filter(p => p.fx === 'wave').length >= 2)); check(name + ' kedua belahan kena (total ~36)', dmgEnd >= 34 && dmgEnd <= 40, String(dmgEnd)); } if (mv.k === 'dot') { check(name + ' korosi aktif', rows.some(r => r.fx.dots > 0)); const h = rows.findIndex(r => r.dmg > 0); check(name + ' damage berkala', h >= 0 && dmgEnd - rows[h + 20].dmg >= 4, `${rows[h + 20]?.dmg} -> ${dmgEnd}`); } if (mv.k === 'bind') { const h = rows.findIndex(r => r.dmg > 0); const held = h >= 0 ? rows.slice(h, h + 33).filter(r => r.hurt).length * DT : 0; check(name + ' lawan terbelit >= 0,5 s', held >= .5, String(held)); } if (mv.k === 'convoy') check(name + ' tiga mobil beruntun', hitsBig >= 3, String(hitsBig)); if (mv.k === 'aftershock') { check(name + ' ledakan kedua', hitsBig >= 2, String(hitsBig)); check(name + ' ledakan kedua tertunda', rows.some(r => r.fx.later > 0)); } if (mv.k === 'pull') { const xs = rows.filter(r => r.t < .35).map(r => r.x); const pulled = Math.abs(xs[0] - xs[xs.length - 1]); check(name + ' lawan tersedot', pulled > 30, String(pulled)); } if (mv.k === 'ants') { check(name + ' semut mengitari dulu', rows.some(r => r.fx.projectiles.some(p => p.orbit))); const n = Math.max(...rows.map(r => r.fx.projectiles.filter(p => p.fx === 'ant').length)); check(name + ' jumlah semut ' + n, n >= (mv.m ? 11 : 10), String(n)); check(name + ' total damage <= 45', dmgEnd <= 46, String(dmgEnd)); check(name + ' semut mengenai', dmgEnd >= 28, String(dmgEnd)); } } // Semut mengitari dulu lalu mengejar: tidak ada semut yang mengenai selama mengitari. await game(o => window.__fxt.setup(o), { c: 'gufron', o: 'browo', side: 'p', mode: false, D: 150 }); await game(() => window.__fxt.cast('p', 'skill1')); const early = await game(() => { const g = window.__game; g.advance(.5); return g.dummy.damage; }); check('semut tidak mengenai sebelum selesai mengitari (lawan di dekat)', early === 0, String(early)); // CPU memakai data jurus dan efek yang sama: 45 detik melawan pemain yang diam, lalu periksa gaya efek yang muncul. const EXPECT = { megachan: ['rings', 'ghost'], sonofsolo: ['shout'], ethanolman: ['roll', 'cf-shock'], anice: ['bolt', 'cf-shock'], soloman: ['ghost', 'cf-shock'], gufron: ['ant', 'chain'] }; for (const [c, want] of Object.entries(EXPECT)) { const seen = await game(({ c }) => { const g = window.__game; g.manual = true; g.startMatch({ player: 'browo', enemy: c, stage: 'bellora', level: 'hard', mode: 'training' }); g.setAI(true); const set = new Set(); for (let i = 0; i < 900; i++) { g.advance(.05); const f = g.fx.snapshot(); f.projectiles.forEach(p => set.add(p.fx)); f.effects.forEach(e => set.add(e)); if (f.ghosts) set.add('ghost'); if (g.hero.hp <= 0) g.hero.hp = 200; } return [...set]; }, { c }); log.push(`CPU ${c}: ${seen.join(',')}`); check(`CPU ${c} memakai efek jurus`, want.some(w => seen.includes(w)), `diharapkan salah satu dari ${want}, terlihat ${seen}`); } // Karakter lain (Bro Wo, Arco) tetap berfungsi: lepaskan semua jurus tanpa galat. for (const c of ['browo']) await game(async c => { const g = window.__game; g.manual = true; g.startMatch({ player: c, enemy: 'megachan', stage: 'bellora', level: 'hard', mode: 'versus' }); g.setAI(true); for (let i = 0; i < 40; i++) { g.advance(.25); g.press('i'); g.release('i'); g.press('o'); g.release('o'); } }, c); fs.writeFileSync(path.join(OUT, 'log.txt'), log.join('\n')); if (fails.length) throw new Error('GAGAL (' + fails.length + '):\n' + fails.join('\n') + '\n\nLog:\n' + log.join('\n')); return { ok: true, moves: MOVES.length * 2, shots: OUT, log }; };