fighting-konoha/tests/t1-fx.mjs

150 lines
12 KiB
JavaScript

// 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
// <tmp>/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 };
};