Uji otomatis satu perintah (RENCANA 5.4): node tests/all.mjs menjalankan sintaks semua JS, smoke, 49 pasangan arah singkat oleh bot (galat konsol, angka tidak wajar, jurus terpakai, data peringkat, tampilan Koleksi); perintah dicatat di ENGINE_NOTES dan README

This commit is contained in:
rizkyaz0 2026-10-05 14:12:57 +07:00
parent bf22eeee42
commit 806dec41b6
4 changed files with 132 additions and 4 deletions

View File

@ -41,6 +41,7 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
- Ultimate sinematik dan mode pasca-ultimate untuk setiap karakter (A Nice berubah jadi Senator). - Ultimate sinematik dan mode pasca-ultimate untuk setiap karakter (A Nice berubah jadi Senator).
- Efek jurus yang bergerak sendiri (gelombang, jurus area, lari dengan bayangan, genangan) dan rasa kena (hit-stop, getaran layar, K.O. gerak lambat, angka damage, peringkat kombo). - Efek jurus yang bergerak sendiri (gelombang, jurus area, lari dengan bayangan, genangan) dan rasa kena (hit-stop, getaran layar, K.O. gerak lambat, angka damage, peringkat kombo).
- Blok, parry, dan lempar; koin, toko (jurus toko, varian skill, skin warna), menu Karakter, 20 pencapaian, dan easter egg. - Blok, parry, dan lempar; koin, toko (jurus toko, varian skill, skin warna), menu Karakter, 20 pencapaian, dan easter egg.
- Peringkat karakter versi simulator (tier S sampai C, bagian kecil di menu Koleksi), dibuat otomatis dari ratusan ronde bot melawan CPU; angkanya bukan kebenaran mutlak.
- Lima arena, pengumuman ronde, dan kutipan acak di layar loading. - Lima arena, pengumuman ronde, dan kutipan acak di layar loading.
- Menu utama berlatar kolase tujuh petarung, tanpa gambar tambahan: seluruhnya disusun dengan CSS dari potret yang sudah ada, dan geraknya menghormati pengaturan "kurangi gerak" di perangkat. - Menu utama berlatar kolase tujuh petarung, tanpa gambar tambahan: seluruhnya disusun dengan CSS dari potret yang sudah ada, dan geraknya menghormati pengaturan "kurangi gerak" di perangkat.
- Daftar "Yang baru" di layar About, dengan pemberitahuan singkat satu kali saat ada pembaruan. - Daftar "Yang baru" di layar About, dengan pemberitahuan singkat satu kali saat ada pembaruan.
@ -64,15 +65,15 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival ├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival
├── mystery.js # slot misteri yang hidup: siluet dan petunjuk bertahap dari kemajuan pemain ├── mystery.js # slot misteri yang hidup: siluet dan petunjuk bertahap dari kemajuan pemain
├── brand.js # kutipan layar loading dan daftar "Yang baru" ├── brand.js # kutipan layar loading dan daftar "Yang baru"
├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru), mystery.js (petunjuk slot misteri) ├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru), mystery.js (petunjuk slot misteri), tiers.js (peringkat, dibuat simulator)
├── announcer.js # antrean suara pengumuman ├── announcer.js # antrean suara pengumuman
├── touch.js # kontrol sentuh di layar ├── touch.js # kontrol sentuh di layar
├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js) ├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js)
├── preload.js # layar boot kunjungan pertama, mengisi cache ├── preload.js # layar boot kunjungan pertama, mengisi cache
├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py) ├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py)
├── sw.js # service worker: gambar dari cache, kode dari jaringan dulu ├── sw.js # service worker: gambar dari cache, kode dari jaringan dulu
├── tests/ # uji headless (Playwright): node tests/harness.mjs smoke ├── tests/ # uji headless (Playwright): node tests/all.mjs (semua), harness.mjs smoke, balance.mjs (simulator)
├── docs/ # catatan mesin untuk pengembang (ENGINE_NOTES.md) ├── docs/ # catatan mesin untuk pengembang (ENGINE_NOTES.md) dan laporan keseimbangan (balance-report.md)
├── guide/ # panduan produksi warisan AETHER CLASH ├── guide/ # panduan produksi warisan AETHER CLASH
├── LICENSE # MIT (kode) ├── LICENSE # MIT (kode)
├── LICENSE-ASSETS.md # CC BY-NC 4.0 (seni, audio, panduan) dan pengecualian ├── LICENSE-ASSETS.md # CC BY-NC 4.0 (seni, audio, panduan) dan pengecualian
@ -92,6 +93,19 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
- **Otak CPU.** CPU bereaksi dengan jeda mirip manusia: melompati proyektil, mundur dari serangan, menghukum saat lawan lengah, dan melanjutkan kombo yang benar-benar kena. Tiap tingkat kesulitan mengubah waktu reaksi, agresi, dan kesalahan. - **Otak CPU.** CPU bereaksi dengan jeda mirip manusia: melompati proyektil, mundur dari serangan, menghukum saat lawan lengah, dan melanjutkan kombo yang benar-benar kena. Tiap tingkat kesulitan mengubah waktu reaksi, agresi, dan kesalahan.
- **Loading.** `precache.js` mendaftar semua berkas runtime beserta hash isinya. `preload.js` mengunduhnya ke Cache Storage di balik layar boot, dan `sw.js` melayani gambar dan font dari sana. Pada kunjungan berikutnya hanya berkas yang berubah yang diunduh lagi. Setelah menambah atau mengubah berkas, jalankan `python3 tools/build_precache.py`. - **Loading.** `precache.js` mendaftar semua berkas runtime beserta hash isinya. `preload.js` mengunduhnya ke Cache Storage di balik layar boot, dan `sw.js` melayani gambar dan font dari sana. Pada kunjungan berikutnya hanya berkas yang berubah yang diunduh lagi. Setelah menambah atau mengubah berkas, jalankan `python3 tools/build_precache.py`.
## Pengujian dan keseimbangan
Uji headless memakai Playwright (sudah terpasang di mesin pengembang) dan tidak perlu `npm install`:
```bash
node tests/all.mjs # satu perintah: sintaks semua JS, smoke, 49 pasangan karakter singkat, data peringkat; kode keluar 0 bila lolos
node tests/harness.mjs smoke # smoke saja (7 karakter melawan CPU, gagal bila ada galat halaman)
BAL_N=8 BAL_WORKERS=2 node tests/harness.mjs tests/balance.mjs 2026-10-05 # simulator keseimbangan, tulis docs/balance-report.md
BAL_TIERS=1 BAL_N=8 node tests/harness.mjs tests/balance.mjs 2026-10-05 # juga membuat ulang content/tiers.js (peringkat di menu Koleksi)
```
Simulator memainkan setiap pasangan 7 x 7 karakter di kedua posisi (bot pemain melawan CPU tingkat hard) tanpa render, lalu menulis tabel menang-kalah dan persen menang per karakter. Cara membaca hasil, riwayat penyetelan angka, dan batasannya ada di [docs/balance-report.md](docs/balance-report.md); rincian env dan struktur kode ada di [docs/ENGINE_NOTES.md](docs/ENGINE_NOTES.md).
## Penerapan ## Penerapan
Unggah folder ke hosting file statis (nginx, Apache, LiteSpeed, Caddy, GitHub Pages, Netlify, dan sejenisnya). Semua jalur bersifat relatif, jadi bisa di akar domain atau subfolder. Buka game dengan garis miring penutup, kirim `Cache-Control: no-cache` untuk berkas game, dan sajikan MP3 dan MP4 dengan permintaan rentang dan tanpa gzip. Langkah rinci untuk nginx, Apache/LiteSpeed, dan Caddy, beserta skrip pemeriksa berkas setelah unggah, ada di [DEPLOY.md](DEPLOY.md) (berbahasa Inggris, dari proyek asal). `.htaccess` untuk Apache/LiteSpeed sudah disertakan. Unggah folder ke hosting file statis (nginx, Apache, LiteSpeed, Caddy, GitHub Pages, Netlify, dan sejenisnya). Semua jalur bersifat relatif, jadi bisa di akar domain atau subfolder. Buka game dengan garis miring penutup, kirim `Cache-Control: no-cache` untuk berkas game, dan sajikan MP3 dan MP4 dengan permintaan rentang dan tanpa gzip. Langkah rinci untuk nginx, Apache/LiteSpeed, dan Caddy, beserta skrip pemeriksa berkas setelah unggah, ada di [DEPLOY.md](DEPLOY.md) (berbahasa Inggris, dari proyek asal). `.htaccess` untuk Apache/LiteSpeed sudah disertakan.

View File

@ -34,7 +34,14 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
`__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot. `__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
## Alat uji headless ## Alat uji headless
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. Skenario `tests/t2-vsresult.mjs` menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena layar VS ber-dialog 3,3 dtk. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase. `node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. Skenario `tests/t2-vsresult.mjs` menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena layar VS ber-dialog 3,3 dtk. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase. **Satu perintah untuk semuanya: `node tests/all.mjs`** (sintaks semua berkas JS, smoke, 49 pasangan arah singkat, data peringkat dan Koleksi; lihat bagian Tahap 5 di bawah).
## Simulator keseimbangan, peringkat, dan uji otomatis (Tahap 5)
- `tests/bot.mjs`: `installBalance()` dijalankan di dalam halaman (`page.evaluate(installBalance)`) dan memasang `window.BalSim = { bot, play, round, think, reset, ... }`. Game tidak diubah: bot hanya memakai API uji (`__game.press/release/advance/startMatch/snapshot/hero/dummy/projectiles/config/balance`) dan `__game.manual = true`. Bot menggerakkan sisi hero; sisi lawan dikendalikan CPU bawaan. Bot mendekat (berlari lewat ketuk dua kali bila jauh), menyambung kombo hanya bila pukulan berikutnya akan mengenai (`landsAt`: lawan tidak kebal, jatuh, atau di luar stun), memakai skill 2 lalu skill 1 saat jaraknya cocok (jangkauan dibaca dari data karakter, jadi ikut berubah saat angka disetel; Bro Wo ditulis tangan), mengakhiri kombo dengan skill, menjaga jarak saat lawan kebal, memblok atau parry sebagian serangan yang terbaca setelah jeda reaksi ~0,17 dtk, melompati proyektil rendah, dan **membalas ultimate lawan dengan ultimate** (atau memakainya sendiri setelah 8 dtk; di mode pasca-ultimate setelah 2 dtk) sambil menunggu lawan bangun. Tidak memakai lempar, jurus perintah, atau jurus toko, dan tidak menghindari genangan api/asam. Penyetelan bot ada di objek `C` (bisa ditimpa lewat env `BAL_BOT`).
- `tests/balance.mjs` (simulator): `node tests/harness.mjs tests/balance.mjs [tanggal]`. Tiap pasangan 7 x 7 (termasuk cermin) dimainkan `BAL_N` ronde per arah (bawaan 6), di kedua posisi, tanpa render (`advance(dt, true)`). Satu ronde = `startMatch` (gaya Seimbang, arena Bellora, tingkat `BAL_LEVEL`, bawaan hard) lalu bot bermain sampai fase K.O.; simulator berhenti tepat di fase itu, jadi `Meta.award` dan layar hasil tidak pernah terpanggil. Hasil per ronde: pemenang, HP akhir, durasi, waktu ultimate pertama tiap sisi, bendera jurus terpakai. Env: `BAL_N`, `BAL_LEVEL`, `BAL_DATE` (atau argumen ke-2; tanggal diberikan, bukan `Date`), `BAL_SEED`, `BAL_ONLY=a,b`, `BAL_WORKERS=k` (k proses Chromium paralel), `BAL_BOT` (json), `BAL_REPORT` (bawaan `docs/balance-report.md`, `none` = jangan tulis), `BAL_JSON` (simpan hasil ringkas), `BAL_BASE` (hasil "sebelum" untuk tabel perbandingan), `BAL_TIERS=1` (tulis `content/tiers.js`), `BAL_LABEL`. Deterministik untuk benih, N, dan jumlah pekerja yang sama (pengacak mesin tidak diatur ulang tiap ronde, jadi urutan ronde ikut menentukan). Satu ronde memakan ~0,1 dtk nyata; N=8 (392 ronde) sekitar 30 sampai 50 dtk dengan 2 pekerja. Bagian laporan di bawah penanda `catatan-manual` dipertahankan saat laporan dibuat ulang. Cara membaca hasilnya dan batasannya ada di `docs/balance-report.md` (bot menang ~80% karena keunggulan posisi hero; persen menang per karakter menggabungkan kedua posisi).
- Peringkat (RENCANA 5.2): `content/tiers.js` (`window.TIERS = {updated, order, rate, tier}`) **dibuat otomatis** oleh simulator (`BAL_TIERS=1`), jangan diedit tangan; batas tier di `TIER_CUT` pada `tests/balance.mjs` (S >= 58, A >= 52, B >= 46, selebihnya C). `meta.js` (`tiersHtml`, `loadTiers`) memuat berkas itu secara malas lewat elemen script saat Koleksi pertama dibuka (index.html tidak diubah) dan menampilkan "Peringkat karakter (versi simulator)" dengan keterangan bahwa angkanya bukan kebenaran mutlak; tanpa berkas atau bila gagal dimuat, bagian itu tidak tampil. Berkas baru ini ikut `tools/build_precache.py` (integrator membangun ulang precache).
- Penyetelan angka (RENCANA 5.3): riwayat sebelum dan sesudah, daftar angka yang diubah, dan temuan (rangkaian ultimate menentukan hampir semua ronde, CPU hampir tidak pernah memakai skill 2 area tanpa `ahead` milik Ethanol Man dan Solo Man) ada di `docs/balance-report.md`. Setelah mengubah angka jurus atau pasif, jalankan ulang simulator dengan `BAL_BASE=docs/balance-after.json` supaya laporan membandingkan dengan keadaan sebelumnya.
- Uji otomatis satu perintah (RENCANA 5.4): `node tests/all.mjs` menjalankan (1) `node --check` untuk semua berkas JS proyek, (2) `node tests/harness.mjs smoke`, (3) `node tests/harness.mjs tests/pairs.mjs`: 49 pasangan arah dimainkan singkat oleh bot (env `PAIRS_SECONDS`, bawaan 24 dtk permainan), memeriksa galat halaman/konsol, angka tidak wajar (NaN, HP di luar 0..200), bahwa tiap karakter memakai skill 1, skill 2, dan ultimate di kedua sisi (sedikitnya satu dari 7 ronde per sisi; celah CPU yang sudah diketahui, `KNOWN_CPU_GAPS`, hanya diperingatkan), data `TIERS`, dan bagian peringkat di Koleksi. Keluar dengan kode 0 bila lolos dan 1 bila ada yang gagal; `ALL_SKIP=sintaks,smoke,pasangan` melewati langkah. Total sekitar 1 menit.
## Aturan kerja ## Aturan kerja
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap). - Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).

48
tests/all.mjs Normal file
View File

@ -0,0 +1,48 @@
// Uji otomatis satu perintah (RENCANA 5.4): node tests/all.mjs
// 1. sintaks node --check untuk semua berkas JS proyek (game, menu, konten, manifest karakter, uji); cepat, tanpa peramban
// 2. smoke node tests/harness.mjs smoke 7 karakter x easy/hard melawan CPU, gagal bila ada galat halaman
// 3. pasangan node tests/harness.mjs tests/pairs.mjs 49 pasangan arah dimainkan singkat oleh bot, galat konsol, jurus terpakai, data TIERS, Koleksi
// Keluar dengan kode 0 bila semuanya lolos, 1 bila ada yang gagal. Tidak mengubah berkas apa pun.
// Env: ALL_SKIP=sintaks,smoke,pasangan (lewati langkah) PAIRS_SECONDS (detik permainan per pasangan, bawaan 24) PW_CORE, CHROME_BIN, HEADED seperti tests/harness.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const skip = new Set((process.env.ALL_SKIP || '').split(',').map(x => x.trim()).filter(Boolean));
const run = (args, opt = {}) => spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', maxBuffer: 64 << 20, ...opt });
const tail = (text, n) => String(text || '').trim().split('\n').slice(-n).join('\n');
// berkas JS yang diperiksa sintaksnya: tingkat atas, content/, manifest karakter, dan uji
function jsFiles() {
const out = [];
for (const f of fs.readdirSync(root)) if (/\.(js|mjs)$/.test(f)) out.push(f);
for (const dir of ['content', 'tests', 'tools']) if (fs.existsSync(path.join(root, dir))) for (const f of fs.readdirSync(path.join(root, dir))) if (/\.(js|mjs)$/.test(f)) out.push(`${dir}/${f}`);
for (const d of fs.readdirSync(path.join(root, 'assets'))) { const m = `assets/${d}/manifest.js`; if (fs.existsSync(path.join(root, m))) out.push(m); }
return out.sort();
}
const steps = [
['sintaks', () => {
const bad = [], files = jsFiles();
for (const f of files) { const r = run(['--check', f]); if (r.status !== 0) bad.push(`${f}: ${tail(r.stderr, 3)}`); }
return { ok: !bad.length, text: bad.length ? bad.join('\n') : `${files.length} berkas JS lolos node --check` };
}],
['smoke', () => { const r = run(['tests/harness.mjs', 'smoke']); return { ok: r.status === 0, text: tail(r.stdout + r.stderr, r.status === 0 ? 2 : 25) }; }],
['pasangan', () => { const r = run(['tests/harness.mjs', 'tests/pairs.mjs']); return { ok: r.status === 0, text: tail(r.stdout + r.stderr, r.status === 0 ? 8 : 30) }; }]
];
const rows = [];
for (const [name, fn] of steps) {
if (skip.has(name)) { rows.push([name, 'DILEWATI', 0, '']); continue; }
const t = Date.now(); let res;
try { res = fn(); } catch (e) { res = { ok: false, text: String(e.stack || e) }; }
const dt = (Date.now() - t) / 1000;
rows.push([name, res.ok ? 'LOLOS' : 'GAGAL', dt, res.text]);
console.log(`\n== ${name}: ${res.ok ? 'LOLOS' : 'GAGAL'} (${dt.toFixed(1)} dtk)\n${res.text}`);
}
const failed = rows.filter(r => r[1] === 'GAGAL');
console.log('\nRingkasan: ' + rows.map(r => `${r[0]} ${r[1]}${r[1] === 'DILEWATI' ? '' : ` (${r[2].toFixed(1)} dtk)`}`).join(' | '));
console.log(failed.length ? `ADA YANG GAGAL: ${failed.map(r => r[0]).join(', ')}` : 'SEMUA UJI LOLOS');
process.exit(failed.length ? 1 : 0);

59
tests/pairs.mjs Normal file
View File

@ -0,0 +1,59 @@
// Uji pasangan (RENCANA 5.4): semua 49 pasangan arah (7 x 7, termasuk cermin) dimainkan singkat oleh bot melawan CPU.
// node tests/harness.mjs tests/pairs.mjs env: PAIRS_SECONDS = detik waktu permainan per pasangan (bawaan 24, cukup untuk ultimate dan mode)
// Memeriksa: galat halaman/konsol (ditangkap harness), startMatch berhasil, angka tidak wajar (NaN, HP di luar 0..200), tiap karakter memakai skill 1,
// skill 2, dan ultimate sedikitnya sekali di sisi bot DAN sisi CPU, data peringkat content/tiers.js, dan bagian peringkat di menu Koleksi.
import { installBalance } from './bot.mjs';
// Celah CPU yang sudah diketahui: AI memilih pukulan dasar lebih dulu, dan skill 2 berjenis area tanpa `ahead` (jangkauannya lebih pendek dari pukulan dasar) tidak pernah terpilih.
// Lihat docs/balance-report.md. Bila datanya disetel supaya CPU memakainya, peringatan berubah jadi catatan agar daftar ini dibersihkan.
const KNOWN_CPU_GAPS = { ethanolman: ['skill 2'], soloman: ['skill 2'] };
export default async ({ page, game, characters }) => {
await page.evaluate(installBalance);
const secs = Number(process.env.PAIRS_SECONDS) || 24, cap = Math.round(secs * 30), problems = [], seen = { bot: {}, cpu: {} }, t0 = Date.now(), per = characters.length; // seen[sisi][id] = [ronde memakai skill 1, skill 2, ultimate]
let rounds = 0, ended = 0;
for (const a of characters) for (const b of characters) {
const r = await game(args => window.BalSim.round({ a: args.a, b: args.b, level: 'hard', rseed: 5, cap: args.cap }), { a, b, cap });
rounds++;
if (r.error) { problems.push(r.error); continue; }
if (r.w !== 'S') ended++;
if (r.nan || ![r.hh, r.ch, r.t].every(Number.isFinite) || r.hh < 0 || r.hh > 200 || r.ch < 0 || r.ch > 200) problems.push(`angka tidak wajar: ${a} (bot) vs ${b} (CPU) -> ${JSON.stringify({ hh: r.hh, ch: r.ch, t: r.t, nan: r.nan })}`);
for (const [side, id, bits] of [['bot', a, r.hs], ['cpu', b, r.cs]]) { const c = seen[side][id] || (seen[side][id] = [0, 0, 0]); for (let k = 0; k < 3; k++) if (bits & (1 << k)) c[k]++; }
}
// tiap karakter harus memakai skill 1, skill 2, dan ultimate di kedua sisi (sedikitnya satu dari 7 ronde); celah CPU yang sudah diketahui hanya diperingatkan
const warnings = [], NAMES = ['skill 1', 'skill 2', 'ultimate'];
for (const [side, map] of Object.entries(seen)) for (const id of characters) {
const c = map[id] || [0, 0, 0], known = side === 'cpu' ? KNOWN_CPU_GAPS[id] || [] : [];
const bad = NAMES.filter((n, k) => !c[k] && !known.includes(n));
if (bad.length) problems.push(`${id} di sisi ${side} tidak pernah memakai: ${bad.join(', ')}`);
for (const n of known) { const k = NAMES.indexOf(n); warnings.push(c[k] > per / 2 ? `${id} (CPU) kini sering memakai ${n} (${c[k]} dari ${per} ronde): hapus dari KNOWN_CPU_GAPS di tests/pairs.mjs` : `${id} (CPU) jarang memakai ${n} (${c[k]} dari ${per} ronde): celah AI yang sudah diketahui`); }
}
// Koleksi: buka tab (bagian peringkat memuat content/tiers.js secara malas), tunggu sampai tampil, lalu periksa tampilan dan datanya
const shown = await page.evaluate(async () => {
window.Meta.open('book');
for (let i = 0; i < 50 && !document.querySelector('#meta-overlay .tier-chip'); i++) await new Promise(r => setTimeout(r, 100));
const text = document.querySelector('#meta-overlay .meta-body')?.textContent || '';
const out = { chips: document.querySelectorAll('#meta-overlay .tier-chip').length, title: text.includes('Peringkat karakter (versi simulator)'), note: text.includes('bukan kebenaran mutlak') };
window.Meta.close(); return out;
});
if (shown.chips !== characters.length || !shown.title || !shown.note) problems.push('Koleksi tidak menampilkan peringkat karakter dengan benar: ' + JSON.stringify(shown));
const tiers = await page.evaluate(() => window.TIERS || null);
if (!tiers) problems.push('content/tiers.js tidak termuat (window.TIERS kosong)');
else {
const ids = tiers.order || [];
if (ids.length !== characters.length || new Set(ids).size !== ids.length || !ids.every(id => characters.includes(id))) problems.push('TIERS.order harus memuat tepat ke-' + characters.length + ' karakter tanpa ganda: ' + JSON.stringify(ids));
for (const id of characters) {
const r = tiers.rate?.[id], t = tiers.tier?.[id];
if (!Number.isFinite(r) || r < 0 || r > 100) problems.push(`TIERS.rate.${id} tidak wajar: ${r}`);
if (!['S', 'A', 'B', 'C'].includes(t)) problems.push(`TIERS.tier.${id} tidak wajar: ${t}`);
}
for (let i = 1; i < ids.length; i++) if ((tiers.rate?.[ids[i]] ?? 0) > (tiers.rate?.[ids[i - 1]] ?? 0)) problems.push('TIERS.order tidak urut menurun menurut rate');
if (typeof tiers.updated !== 'string') problems.push('TIERS.updated harus teks (tanggal)');
}
const lines = [`Pasangan: ${rounds} ronde singkat (${secs} dtk permainan), ${ended} selesai K.O., ${((Date.now() - t0) / 1000).toFixed(1)} dtk nyata.`, `Peringkat: ${tiers ? tiers.order.join(' > ') + ' (' + tiers.updated + ')' : 'tidak ada'}; chip di Koleksi: ${shown.chips}.`];
if (warnings.length) lines.push('Peringatan (tidak menggagalkan):', ...warnings.map(w => ' - ' + w));
if (problems.length) { lines.push('MASALAH:', ...problems.map(p => ' - ' + p)); throw new Error(lines.join('\n')); }
lines.push('semua pasangan lolos');
return lines.join('\n');
};