fighting-konoha/tests/t1-feel.mjs

288 lines
26 KiB
JavaScript

// Tahap 1.7-1.9: rasa kena (hit-stop, getaran, kilat, K.O. slow-motion, angka damage pop, peringkat kombo).
// node tests/harness.mjs tests/t1-feel.mjs screenshot ke FEEL_SHOTS (default <tmp>/t1-feel-shots)
// 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 } };
const OUT = (process.env.FEEL_SHOTS || path.join(os.tmpdir(), 't1-feel-shots')).replace(/\/?$/, '/');
export default async ({ page, game }) => {
fs.mkdirSync(OUT, { recursive: true });
const checks = [], ok = (name, pass, info) => { checks.push({ name, pass: !!pass, info }); };
const shot = async name => { await game(() => window.__game.draw()); await page.screenshot({ path: OUT + name + '.png' }); };
const wait = ms => page.waitForTimeout(ms);
const T = await game(() => window.__game.feel.table);
// ---------- 1. jenis pukulan pada lawan (sisi pemain memukul) ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); });
const hitKinds = {};
for (const [kind, dmg, shake] of [['light', 6, 1], ['medium', 14, 1], ['heavy', 24, 1], ['ultimate', 28, 2]]) {
hitKinds[kind] = await game(({ dmg, shake }) => {
const g = window.__game; g.advance(3); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle';
const okHit = g.hitDummy(dmg, 160, '#c6fff0', g.hero.x, g.hero.y - 90, false, true, shake), s = g.feelState();
return { okHit, hitstop: s.hitstop, trauma: s.trauma, flash: g.dummy.flash, numbers: s.numbers.map(n => n.kind), kickX: s.kick.x };
}, { dmg, shake });
const h = hitKinds[kind], t = T.hit[kind];
ok(`pukulan ${kind}: hit-stop sesuai tabel`, h.okHit && Math.abs(h.hitstop - t.stop) < 1e-6, h);
ok(`pukulan ${kind}: angka damage berjenis ${kind}`, h.numbers.at(-1) === kind, h.numbers);
ok(`pukulan ${kind}: kilat putih pada lawan`, Math.abs(h.flash - t.flash) < 1e-6, h.flash);
ok(`pukulan ${kind}: getaran condong ke arah pukulan (kanan)`, h.kickX > 0 && Math.abs(h.kickX - t.kick) < 1.5, h.kickX);
await shot('hit-' + kind + '-flash'); await game(() => window.__game.advance(.1)); await shot('hit-' + kind);
}
ok('hit-stop naik menurut jenis (ringan < sedang < berat < ultimate)', hitKinds.light.hitstop < hitKinds.medium.hitstop && hitKinds.medium.hitstop < hitKinds.heavy.hitstop && hitKinds.heavy.hitstop < hitKinds.ultimate.hitstop);
ok('getaran naik menurut jenis', hitKinds.light.trauma < hitKinds.medium.trauma && hitKinds.medium.trauma < hitKinds.heavy.trauma && hitKinds.heavy.trauma < hitKinds.ultimate.trauma, ['light', 'medium', 'heavy', 'ultimate'].map(k => hitKinds[k].trauma));
// peluru berantai (freeze=false): tidak membekukan dunia kecuali jenis berat, itupun setengah
const noFreeze = await game(() => {
const g = window.__game; g.advance(3); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle';
g.hitDummy(6, 60, '#fff', g.hero.x, g.hero.y - 90, false, false, .6); const a = g.feelState().hitstop;
g.advance(3); g.dummy.state = 'idle'; g.hitDummy(24, 60, '#fff', g.hero.x, g.hero.y - 90, false, false); const b = g.feelState().hitstop; return { light: a, heavy: b };
});
ok('serangan tanpa freeze: ringan 0, berat setengah', noFreeze.light === 0 && Math.abs(noFreeze.heavy - T.hit.heavy.stop * T.microScale) < 1e-6, noFreeze);
// kilat putih: sprite lawan putih saat flash > 0
await game(() => { const g = window.__game; g.advance(3); g.dummy.state = 'idle'; g.hitDummy(24, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.hero.flash = 0; });
await shot('flash-dummy');
// ---------- 2. counter, parry, guard ----------
const counter = await game(() => {
const g = window.__game; g.advance(3); g.dummy.state = 'idle'; g.dummy.action = { type: 'attack', fired: false, t: 0, duration: .5, index: 1 };
const before = g.dummy.damage; g.hitDummy(10, 120, '#fff0b0', g.hero.x, g.hero.y - 90); const s = g.feelState(); g.dummy.action = null;
return { dealt: g.dummy.damage - before, hitstop: s.hitstop, last: s.numbers.at(-1), kickX: s.kick.x };
});
ok('counter: damage x1,25, angka oranye + label COUNTER, hit-stop counter', counter.dealt === 13 && counter.last.kind === 'counter' && counter.last.label === 'COUNTER' && counter.hitstop === T.hit.counter.stop, counter);
await game(() => window.__game.advance(.1)); await shot('counter-dummy');
const parry = await game(() => {
const g = window.__game; g.advance(3); g.hero.parry = .25; g.hero.action = null; g.hero.blocking = false;
const r = g.receiveHit(12), s = g.feelState(); return { r, hitstop: s.hitstop, last: s.numbers.at(-1), kickX: s.kick.x, hp: g.hero.hp };
});
ok('parry: tertangkis, angka biru PARRY, hit-stop parry, getaran balik ke arah pukulan', parry.r && parry.last.kind === 'parry' && parry.last.amount === 'PARRY' && parry.hitstop === T.hit.parry.stop && parry.kickX < 0, parry);
await game(() => window.__game.advance(.1)); await shot('parry-hero');
const guard = await game(() => {
const g = window.__game; g.advance(3); g.hero.parry = 0; g.hero.blocking = true; g.hero.action = null;
const r = g.receiveHit(12), s = g.feelState(); g.hero.blocking = false; return { r, hitstop: s.hitstop, last: s.numbers.at(-1) };
});
ok('guard: angka abu GUARD, hit-stop guard', guard.r && guard.last.kind === 'guard' && guard.hitstop === T.hit.guard.stop, guard);
await game(() => window.__game.advance(.1)); await shot('guard-hero');
// sisi lawan: CPU memukul pemain (jenis dari damage, angka muncul di pemain, kilat pada sprite pemain, getaran ke kiri)
const cpuHits = {};
for (const [kind, dmg, opt] of [['light', 6, {}], ['medium', 14, {}], ['heavy', 24, {}], ['ultimate', 28, { shake: 2 }]]) {
cpuHits[kind] = await game(({ dmg, opt }) => {
const g = window.__game; g.advance(3); g.hero.action = null; g.hero.hp = 200; g.hero.invuln = 0; g.hero.parry = 0; g.hero.blocking = false;
const r = g.receiveHit(dmg, opt), s = g.feelState(); return { r, hitstop: s.hitstop, flash: g.hero.flash, last: s.numbers.at(-1), kickX: s.kick.x };
}, { dmg, opt });
const h = cpuHits[kind], t = T.hit[kind];
ok(`CPU memukul pemain (${kind}): efek sama dengan sisi pemain`, h.r && h.last.kind === kind && Math.abs(h.hitstop - t.stop) < 1e-6 && Math.abs(h.flash - t.flash) < 1e-6 && h.kickX < 0, h);
}
await game(() => window.__game.advance(.1)); await shot('cpu-hits-hero');
const heroCounter = await game(() => {
const g = window.__game; g.advance(3); g.hero.invuln = 0; g.hero.action = { type: 'attack', fired: false, t: 0, duration: .4, index: 1, name: 'attack1' };
const hp0 = g.hero.hp; g.receiveHit(10); const s = g.feelState(); return { lost: hp0 - g.hero.hp, last: s.numbers.at(-1) };
});
ok('pemain terkena saat menyiapkan serangan: label COUNTER, damage tidak berubah', heroCounter.last.label === 'COUNTER' && heroCounter.lost === 10, heroCounter);
// CPU menangkis (guard) saat dipukul: acak berbenih, cari sampai muncul
const cpuGuard = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'excellent', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(true); g.dummy.action = null; let found = null;
for (let i = 0; i < 80 && !found; i++) { g.advance(.2); g.dummy.state = 'idle'; g.dummy.action = null; g.dummy.invuln = 0; g.dummy.x = g.hero.x + 120; g.dummy.y = g.config.groundY; g.dummy.damage = 0; g.hitDummy(8, 100, '#fff', g.hero.x, g.hero.y - 90); const l = g.feelState().numbers.at(-1); if (l && l.kind === 'guard') found = { tries: i + 1, hitstop: g.feelState().hitstop }; }
return found || { tries: 80, none: true };
});
ok('CPU menangkis: angka abu GUARD + hit-stop guard', !cpuGuard.none && cpuGuard.hitstop === T.hit.guard.stop, cpuGuard);
// ---------- 3. batas jumlah angka aktif ----------
const cap = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1);
let peak = 0; for (let i = 0; i < 40; i++) { g.dummy.state = 'idle'; g.hitDummy(6, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.01); peak = Math.max(peak, g.feelState().numbers.length); }
return { peak, limit: g.feel.table.maxNumbers };
});
ok('angka damage aktif dibatasi', cap.peak <= cap.limit, cap);
// ---------- 4. peringkat kombo + tidak menimpa HUD (jendela 520 px) ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1); });
const rankSeen = {};
const hitN = async n => game(n => { const g = window.__game; for (let i = 0; i < n; i++) { g.dummy.state = 'idle'; g.dummy.invuln = 0; g.hitDummy(6, 0, '#c6fff0', g.hero.x, g.hero.y - 90, false, true); g.advance(.02); } g.draw(); return g.snapshot().comboHits; }, n);
const rankInfo = () => page.evaluate(() => { const el = document.getElementById('combo-rank'), r = el.getBoundingClientRect(), cs = getComputedStyle(el); return { rank: el.dataset.rank, letter: el.querySelector('b').textContent, label: el.querySelector('small').textContent, visible: el.classList.contains('visible'), opacity: cs.opacity, rect: { x: r.x, y: r.y, w: r.width, h: r.height } }; });
let hits = 0;
for (const [target, rank] of [[3, 'C'], [5, 'B'], [8, 'A'], [12, 'S']]) {
hits = await hitN(target - hits); // tambah sampai target
await wait(80); await shot('rank-' + rank + '-enter');
await wait(600);
const info = await rankInfo(); rankSeen[rank] = info;
ok(`peringkat ${rank} muncul pada ${target} pukulan`, info.letter === rank && info.visible, info);
await shot('rank-' + rank);
}
// pantulan tiap pukulan: transform pada huruf berubah sesaat setelah pukulan
const bounce = await page.evaluate(async () => { const g = window.__game, b = document.querySelector('#combo-rank b'); const anims0 = b.getAnimations().length; g.dummy.state = 'idle'; g.dummy.invuln = 0; g.hitDummy(6, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.02); g.draw(); return { before: anims0, after: b.getAnimations().length }; });
ok('huruf peringkat memantul tiap pukulan (animasi baru)', bounce.after > bounce.before, bounce);
// tidak menimpa HUD: bandingkan kotak peringkat dengan semua blok HUD
const overlap = await page.evaluate(() => {
const r = document.getElementById('combo-rank').getBoundingClientRect(), hits = [];
for (const sel of ['.combat-header .fighter-one', '.combat-header .fighter-two', '.match-center', '.match-actions', '.skill-deck', '.training-data', '.stage-sign', '.bottom-hud .skill-card']) {
for (const e of document.querySelectorAll(sel)) { const b = e.getBoundingClientRect(); if (b.width && b.height && r.left < b.right && r.right > b.left && r.top < b.bottom && r.bottom > b.top) hits.push(sel); }
}
return { hits, rect: { top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), right: Math.round(r.right) }, vh: innerHeight };
});
ok('peringkat kombo tidak menimpa HUD atas/bawah pada tinggi 520 px', overlap.hits.length === 0 && overlap.vh === 520, overlap);
// peringkat hilang saat kombo habis
await game(() => { window.__game.advance(3); window.__game.draw(); });
const gone = await rankInfo(); ok('peringkat hilang setelah kombo berakhir', !gone.visible, gone);
// ---------- 5. K.O. ronde di versus ----------
const koRound = await game(() => {
const g = window.__game; g.startMatch({ player: 'sonofsolo', enemy: 'megachan', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.5);
g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
const hit = g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true, 1);
const preHitstop = g.feelState().hitstop; let steps = 0;
while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
const s0 = g.snapshot(); return { hit, preHitstop, hitstopSteps: steps, phase: s0.match.phase, koDuration: s0.match.koDuration, fx: g.feelState().ko, enemyWins: s0.match.enemyWins, playerWins: s0.match.playerWins };
});
ok('K.O. ronde: fase ko, pemain menang ronde, hit-stop pembunuh sebelum slow-motion', koRound.hit && koRound.phase === 'ko' && koRound.playerWins === 1 && koRound.preHitstop >= .12, koRound);
ok('K.O. ronde: slow-motion ~0,25 dan zoom ke yang kalah (lawan)', koRound.fx.on && koRound.fx.who === 'dummy' && !koRound.fx.final && Math.abs(koRound.fx.scale - T.ko.round.scale) < 1e-6, koRound.fx);
// slow-motion: waktu simulasi visual melambat, jam K.O. tetap nyata
const slow = await game(() => {
const g = window.__game, st0 = g.dummy.stateTime, ph0 = g.snapshot().match.phaseTime; g.advance(.3);
const s1 = g.snapshot(); return { dState: s1.dummy.stateTime - st0, dPhase: s1.match.phaseTime - ph0, scale: g.feelState().ko.scale, zoom: g.feelState().ko.zoom };
});
ok('K.O. ronde: stateTime simulasi ~0,25x, jam fase memakai waktu nyata', slow.dState < .3 * 0.4 && Math.abs(slow.dPhase - .3) < .02, slow);
await shot('ko-round-slowmo');
await game(() => window.__game.advance(.3)); await shot('ko-round-slowmo-2');
// penutup K.O. selesai tepat pada koDuration (waktu nyata) lalu ronde 2 dimulai
const koTiming = await game(() => {
const g = window.__game, s = g.snapshot(); let steps = 0; const dur = s.match.koDuration, t0 = s.match.phaseTime; let ph = s.match.phase;
while (ph === 'ko' && steps++ < 2000) { g.advance(1 / 120); ph = g.snapshot().match.phase; }
const s2 = g.snapshot(); return { koDuration: dur, startedAt: t0, realSecondsInKo: steps / 120 + t0, endPhase: ph, round: s2.match.round, fxOn: g.feelState().ko.on, zoom: g.feelState().ko.zoom, heroHp: s2.hero.hp, dummyDamage: s2.dummy.damage };
});
ok('K.O. ronde: penutup selesai tepat pada koDuration', Math.abs(koTiming.realSecondsInKo - koTiming.koDuration) <= 1 / 120 + 1e-6 && koTiming.endPhase === 'intro' && koTiming.round === 2, koTiming);
ok('ronde berikutnya: efek K.O. bersih, nyawa penuh', !koTiming.fxOn && koTiming.zoom === 1 && koTiming.heroHp === 200 && koTiming.dummyDamage === 0, koTiming);
// ronde berlanjut: lawan bisa dipukul lagi dan K.O. akhir memicu hasil
const r2 = await game(() => {
const g = window.__game; let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.advance(.5); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0; const dmg0 = g.dummy.damage; const hit = g.hitDummy(10, 100, '#fff', g.hero.x, g.hero.y - 90); g.advance(.5);
return { phase: g.snapshot().match.phase, hit, dealt: g.dummy.damage - dmg0 };
});
ok('ronde 2 berjalan normal (fase fight, pukulan mengenai)', r2.phase === 'fight' && r2.hit && r2.dealt === 10, r2);
// K.O. akhir: lebih dramatis
const koFinal = await game(() => {
const g = window.__game; g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true, 1); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
const s = g.snapshot(); return { phase: s.match.phase, winner: s.match.winner, fx: g.feelState().ko, koDuration: s.match.koDuration };
});
ok('K.O. akhir: ditandai final, slow-motion lebih pelan dan lebih lama dari K.O. ronde', koFinal.phase === 'ko' && koFinal.winner === 'player' && koFinal.fx.final && koFinal.fx.scale < T.ko.round.scale && koFinal.fx.cfg.dur > T.ko.round.dur && koFinal.fx.cfg.zoom > T.ko.round.zoom, koFinal);
await game(() => window.__game.advance(.25)); await shot('ko-final-1');
await game(() => window.__game.advance(.45)); await shot('ko-final-2');
const finalEnd = await game(() => {
const g = window.__game; let steps = 0; const s0 = g.snapshot(), dur = s0.match.koDuration, t0 = s0.match.phaseTime, win = []; let ph = s0.match.phase;
while (ph === 'ko' && steps++ < 2000) { g.advance(1 / 120); ph = g.snapshot().match.phase; }
const fm = document.querySelector('#front-menu'), s = g.snapshot();
return { endPhase: ph, realSecondsInKo: steps / 120 + t0, koDuration: dur, menuOpen: s.menuOpen, bodyInMenu: document.body.classList.contains('in-menu'), menuText: (fm?.innerText || '').slice(0, 160), fxOn: g.feelState().ko.on };
});
ok('K.O. akhir: penutup tepat waktu, hasil akhir tampil, efek bersih', finalEnd.endPhase === 'complete' && Math.abs(finalEnd.realSecondsInKo - finalEnd.koDuration) <= 1 / 120 + 1e-6 && finalEnd.menuOpen && finalEnd.bodyInMenu && !finalEnd.fxOn, finalEnd);
await wait(400); await shot('ko-final-result');
// ---------- 6. pemain dikalahkan CPU (zoom ke pemain), double K.O., waktu habis ----------
const heroKo = await game(() => {
const g = window.__game; g.startMatch({ player: 'anice', enemy: 'gufron', stage: 'harbor', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.5); g.hero.hp = 12; g.hero.invuln = 0; const r = g.receiveHit(40, { shake: 1 }); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
g.advance(.3); const s = g.snapshot(); return { r, phase: s.match.phase, enemyWins: s.match.enemyWins, fx: g.feelState().ko };
});
ok('K.O. pemain oleh CPU: slow-motion zoom ke pemain', heroKo.r && heroKo.phase === 'ko' && heroKo.enemyWins === 1 && heroKo.fx.on && heroKo.fx.who === 'hero', heroKo);
await shot('ko-hero-slowmo');
const timeUp = await game(() => {
const g = window.__game; g.startMatch({ player: 'soloman', enemy: 'ethanolman', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.dummy.damage = 50; let k = 0; while (g.snapshot().match.phase === 'fight' && k++ < 40) g.advance(5);
const s = g.snapshot(); return { phase: s.match.phase, text: s.match.endText, fxOn: g.feelState().ko.on };
});
ok('waktu habis: tanpa slow-motion K.O.', timeUp.phase === 'ko' && timeUp.text === 'TIME UP' && !timeUp.fxOn, timeUp);
const dbl = await game(() => {
const g = window.__game; g.startMatch({ player: 'soloman', enemy: 'ethanolman', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.3); g.hero.hp = 0; g.dummy.damage = 200; g.dummy.ko = true; g.dummy.state = 'down'; g.advance(.6); const s = g.snapshot();
return { phase: s.match.phase, text: s.match.endText, fx: g.feelState().ko };
});
ok('double K.O.: kamera di tengah, tanpa galat', dbl.phase === 'ko' && dbl.fx.on && dbl.fx.who === 'both', dbl);
await shot('ko-double');
// K.O. di dekat dinding (pemain 2 manusia): zoom tidak membuka tepi kosong
const edge = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'elderwood', level: 'easy', mode: 'versus' }); g.manual = true; g.setP2(true);
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.advance(.4); g.hero.x = 190; g.dummy.x = 120; g.dummy.state = 'idle'; g.dummy.invuln = 0; g.dummy.damage = 190;
g.hitDummy(30, 0, '#ffd36a', g.hero.x, g.hero.y - 90, false, true); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120); g.advance(.35);
const s = g.snapshot(); return { phase: s.match.phase, fx: g.feelState().ko, dummyX: s.dummy.x };
});
ok('K.O. dekat dinding saat 2 pemain: zoom aktif', edge.phase === 'ko' && edge.fx.on && edge.fx.zoom > 1.2, edge);
await shot('ko-edge-left');
// ---------- 7. K.O. di mode latihan (tidak ada event ronde) ----------
const trainKo = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1);
g.dummy.damage = 195; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.hitDummy(12, 100, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.3);
const mid = g.feelState().ko; let t = 0, recovered = false; while (t < 12) { g.advance(.25); t += .25; if (!g.dummy.ko && g.dummy.damage === 0) { recovered = true; break; } }
return { mid, recovered, afterT: t, fxOn: g.feelState().ko.on };
});
ok('latihan: K.O. memicu slow-motion lalu lawan pulih', trainKo.mid.on && trainKo.recovered && !trainKo.fxOn, trainKo);
// ---------- 8. simulasi nyata melawan CPU di 3 karakter: tidak ada galat, angka dan efek tidak macet ----------
const fights = [];
for (const [p, e] of [['browo', 'gufron'], ['ethanolman', 'anice'], ['soloman', 'megachan']]) {
fights.push(await game(({ p, e }) => {
const g = window.__game; g.startMatch({ player: p, enemy: e, stage: 'moonrise', level: 'hard', mode: 'versus' }); g.manual = true; g.setAI(true);
let heroNumbers = 0, dummyNumbers = 0, kinds = new Set(), maxNum = 0, koSeen = false;
for (let i = 0; i < 420; i++) {
g.advance(.25); if (i % 3 === 0) { g.press('d'); g.press(' '); g.release(' '); g.release('d'); } if (i % 7 === 0) { g.press('i'); g.release('i'); g.press('o'); g.release('o'); } if (i % 17 === 0) { g.press('p'); g.release('p'); }
const s = g.feelState(); maxNum = Math.max(maxNum, s.numbers.length); for (const n of s.numbers) kinds.add(n.kind || typeof n.amount); if (s.ko.on) koSeen = true;
if (g.snapshot().menuOpen) break;
}
const s = g.snapshot(); return { p, e, phase: s.match?.phase, round: s.match?.round, wins: [s.match?.playerWins, s.match?.enemyWins], maxNum, kinds: [...kinds], koSeen };
}, { p, e }));
}
ok('simulasi pertandingan melawan CPU berjalan (angka dibatasi, K.O. terjadi)', fights.every(f => f.maxNum <= T.maxNumbers), fights);
// ---------- 8b. jalur nyata (bukan panggilan langsung): 7 karakter memukul lawan, lalu CPU memukul balik ----------
const sweep = [];
for (const id of ['browo', 'megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron']) {
sweep.push(await game(id => {
const g = window.__game; g.startMatch({ player: id, enemy: 'sonofsolo', stage: 'bellora', level: 'hard', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5);
g.dummy.x = g.hero.x + 85; let mine = null, t = 0;
g.press(' '); g.release(' ');
while (t < 3 && !mine) { g.advance(.02); t += .02; const s = g.snapshot(); if (s.comboHits > 0) mine = { t: +t.toFixed(2), flash: g.dummy.flash, numbers: g.feelState().numbers.map(n => n.kind || 'x'), hitstop: g.feelState().hitstop }; }
g.advance(2); g.setAI(true); g.dummy.x = g.hero.x + 100; g.dummy.state = 'idle'; g.hero.hp = 200; let theirs = null; t = 0;
while (t < 25 && !theirs) { g.advance(.05); t += .05; if (g.hero.hp < 200) theirs = { t: +t.toFixed(2), hp: g.hero.hp, flash: g.hero.flash, numbers: g.feelState().numbers.map(n => n.kind || 'x') }; }
return { id, mine, theirs };
}, id));
}
ok('jalur nyata: pemain memukul -> angka + kilat + hit-stop (7 karakter)', sweep.every(r => r.mine && r.mine.numbers.length && r.mine.flash > 0), sweep.map(r => r.mine));
ok('jalur nyata: CPU memukul pemain -> angka muncul di pemain (7 karakter)', sweep.every(r => r.theirs && r.theirs.numbers.length), sweep.map(r => r.theirs));
// ---------- 9. waktu nyata (loop rAF biasa, tanpa advance): K.O. ronde selesai kira-kira pada koDuration ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'ethanolman', enemy: 'anice', stage: 'sunspire', level: 'easy', mode: 'versus' }); g.manual = false; g.setAI(false); });
await page.waitForFunction(() => window.__game.snapshot().match.phase === 'fight', null, { timeout: 30000 });
const live = await page.evaluate(async () => {
const g = window.__game; g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true); const t0 = performance.now(), log = []; let koAt = null, introAt = null, slowSeen = 99;
while (performance.now() - t0 < 7000) {
await new Promise(r => requestAnimationFrame(r)); const s = g.snapshot(), f = g.feelState(), t = performance.now() - t0;
if (s.match.phase === 'ko' && koAt === null) koAt = t; if (f.ko.on) slowSeen = Math.min(slowSeen, f.ko.scale);
if (s.match.phase === 'intro' && introAt === null) { introAt = t; break; }
}
return { koAt: Math.round(koAt), introAt: Math.round(introAt), koSeconds: (introAt - koAt) / 1000, slowSeen, koDuration: g.snapshot().match.koDuration, round: g.snapshot().match.round };
});
ok('waktu nyata: K.O. ronde berjalan ~koDuration lalu ronde 2', live.koSeconds > live.koDuration - .25 && live.koSeconds < live.koDuration + .4 && live.slowSeen <= .26 && live.round === 2, live);
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', info: Object.fromEntries(checks.filter(c => /jalur nyata|waktu nyata|K.O. ronde: penutup|K.O. akhir: penutup/.test(c.name)).map(c => [c.name, c.info])) };
if (failed.length) throw new Error('GAGAL ' + JSON.stringify(out, null, 1));
return out;
};