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