Gabung wf/t5-balance: simulator keseimbangan, tier list, uji otomatis
This commit is contained in:
commit
1605f0f1c4
20
README.md
20
README.md
|
|
@ -43,6 +43,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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
@ -66,7 +67,7 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
|
|||
├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival
|
||||
├── mystery.js # slot misteri yang hidup: siluet dan petunjuk bertahap dari kemajuan pemain
|
||||
├── 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
|
||||
├── touch.js # kontrol sentuh di layar
|
||||
├── touchlayout.js # editor tata letak tombol sentuh (+ touchlayout.css)
|
||||
|
|
@ -75,8 +76,8 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
|
|||
├── preload.js # layar boot kunjungan pertama, mengisi cache
|
||||
├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py)
|
||||
├── sw.js # service worker: gambar dari cache, kode dari jaringan dulu
|
||||
├── tests/ # uji headless (Playwright): node tests/harness.mjs smoke
|
||||
├── docs/ # catatan mesin untuk pengembang (ENGINE_NOTES.md)
|
||||
├── tests/ # uji headless (Playwright): node tests/all.mjs (semua), harness.mjs smoke, balance.mjs (simulator)
|
||||
├── docs/ # catatan mesin untuk pengembang (ENGINE_NOTES.md) dan laporan keseimbangan (balance-report.md)
|
||||
├── guide/ # panduan produksi warisan AETHER CLASH
|
||||
├── LICENSE # MIT (kode)
|
||||
├── LICENSE-ASSETS.md # CC BY-NC 4.0 (seni, audio, panduan) dan pengecualian
|
||||
|
|
@ -96,6 +97,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.
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
browo.js
2
browo.js
|
|
@ -21,7 +21,7 @@
|
|||
// holds, then `treeCount` palms erupt in a rapid run toward the rival (`treeDamage` each) while the rival is held in place.
|
||||
// At `giantAt` one giant palm rises (`giantRise`), leans back (`giantHold`) and is brought down on the rival (`slam`),
|
||||
// dealing `giantDamage` and a knockdown. Afterwards BRO WO rampages (see `rage`).
|
||||
const forest = { cutinDuration:.78, zoomIn:.45, lockAt:.3, treesAt:1.45, treeCount:10, treeGap:.11, treeDamage:3,
|
||||
const forest = { cutinDuration:.78, zoomIn:.45, lockAt:.3, treesAt:1.45, treeCount:10, treeGap:.11, treeDamage:4,
|
||||
giantAt:2.85, giantRise:.55, giantHold:.3, slam:.16, giantDamage:40, tail:.55 };
|
||||
forest.release = forest.giantAt + forest.giantRise + forest.giantHold + forest.slam;
|
||||
forest.duration = forest.release + forest.tail;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
cdSkill kecepatan pulih skill 1 dan 2 cdUlt kecepatan pulih ultimate
|
||||
parry jendela parry (pemain) dan peluang blok (CPU) modeDur durasi mode pasca-ultimate
|
||||
Kunci penambah (0 = tanpa perubahan): regen = HP per detik yang pulih bila tidak terkena serangan selama 3 detik.
|
||||
Angka sengaja kecil (sekitar 5 sampai 12 persen). Teks angka yang tampil ke pemain disusun otomatis dari `mods`, jadi deskripsi di sini hanya cerita
|
||||
Angka sengaja kecil (sekitar 3 sampai 12 persen); disetel dengan simulator keseimbangan, riwayatnya di docs/balance-report.md. Teks angka yang tampil ke pemain disusun otomatis dari `mods`, jadi deskripsi di sini hanya cerita
|
||||
dan tidak boleh memuat angka (supaya tidak ketinggalan saat angka disetel ulang). Pemisahan perk/kelemahan dibaca dari arah angkanya. */
|
||||
window.PASSIVES = {
|
||||
browo: {
|
||||
|
|
@ -19,7 +19,7 @@ window.PASSIVES = {
|
|||
megachan: {
|
||||
name: 'KEPALA BANTENG', desc: 'Keras kepala, keras tekad: pukulan lawan terasa lebih ringan.',
|
||||
flaw: { name: 'LANGKAH BERAT', desc: 'Badan kokoh membuat jalannya pelan.' },
|
||||
mods: { dmgIn: .92, walk: .92 }
|
||||
mods: { dmgIn: .95, walk: .92 }
|
||||
},
|
||||
sonofsolo: {
|
||||
name: 'RACIKAN PEKAT', desc: 'Asam racikannya makin pekat: skill dan ultimate lebih nyelekit.',
|
||||
|
|
@ -29,7 +29,7 @@ window.PASSIVES = {
|
|||
ethanolman: {
|
||||
name: 'BAHAN BAKAR HIJAU', desc: 'Energi terbarukan: perlahan memulihkan HP bila lama tidak diserang.',
|
||||
flaw: { name: 'MESIN DINGIN', desc: 'Selalu butuh pemanasan, jadi semua cooldown pulih lebih lambat.' },
|
||||
mods: { regen: .5, cd: .95 }
|
||||
mods: { regen: .5, cd: .96 }
|
||||
},
|
||||
anice: {
|
||||
name: 'JAWABAN TEPAT', desc: 'Bicara terukur, bertahan terukur: jendela parry lebih lebar.',
|
||||
|
|
@ -39,12 +39,12 @@ window.PASSIVES = {
|
|||
soloman: {
|
||||
name: 'LANGKAH RINGAN', desc: 'Si sederhana melangkah enteng: jalan dan lari lebih cepat.',
|
||||
flaw: { name: 'TENAGA SEADANYA', desc: 'Serba sederhana, termasuk tenaganya: semua damage lebih kecil.' },
|
||||
mods: { walk: 1.1, dmgOut: .94 }
|
||||
mods: { walk: 1.1, dmgOut: .97 }
|
||||
},
|
||||
gufron: {
|
||||
name: 'BAHASA SEMUT', desc: 'Koloni cepat siap lagi: kedua skill pulih lebih cepat.',
|
||||
flaw: { name: 'JAB NYENTRIK', desc: 'Pukulan dasarnya kurang meyakinkan, jadi damage kombo dasar lebih kecil.' },
|
||||
mods: { cdSkill: 1.12, dmgBasic: .88 }
|
||||
mods: { cdSkill: 1.05, dmgBasic: .88 }
|
||||
}
|
||||
};
|
||||
// Gaya bertarung per karakter: dipilih pemain di menu Karakter (disimpan di Meta.save.style[id]); CPU memakai gaya acak per pertandingan.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
/* Peringkat karakter versi simulator (RENCANA 5.2). DIBUAT OTOMATIS oleh tests/balance.mjs (BAL_TIERS=1): jangan diedit tangan, jalankan ulang simulatornya.
|
||||
Angka dari simulasi bot melawan CPU (tingkat hard, 8 ronde per pasangan arah, kedua posisi), bukan kebenaran mutlak. Dipakai menu Koleksi (meta.js renderBook).
|
||||
updated = tanggal jalan; order = urutan dari yang tertinggi; rate = persen menang; tier = S (>= 58), A (>= 52), B (>= 46), C. */
|
||||
window.TIERS = {
|
||||
updated: '2026-10-05',
|
||||
order: ['megachan', 'gufron', 'sonofsolo', 'anice', 'browo', 'ethanolman', 'soloman'],
|
||||
rate: { megachan: 58.3, gufron: 56.3, sonofsolo: 53.1, anice: 49, browo: 44.8, ethanolman: 44.8, soloman: 43.8 },
|
||||
tier: { megachan: 'S', gufron: 'A', sonofsolo: 'A', anice: 'B', browo: 'C', ethanolman: 'C', soloman: 'C' }
|
||||
};
|
||||
|
|
@ -40,7 +40,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.
|
||||
|
||||
## 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
|
||||
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"date": "2026-10-05",
|
||||
"label": "Setelah penyetelan",
|
||||
"n": 8,
|
||||
"level": "hard",
|
||||
"seed": 11,
|
||||
"chr": [
|
||||
{
|
||||
"id": "megachan",
|
||||
"w": 56,
|
||||
"l": 40,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 58.333333333333336,
|
||||
"hpWin": 45.107142857142854,
|
||||
"hpAll": 26.3125,
|
||||
"dur": 18.772395833333334,
|
||||
"to": 0,
|
||||
"asBot": 100,
|
||||
"asCpu": 16.666666666666664,
|
||||
"tier": "S"
|
||||
},
|
||||
{
|
||||
"id": "gufron",
|
||||
"w": 54,
|
||||
"l": 42,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 56.25,
|
||||
"hpWin": 35.407407407407405,
|
||||
"hpAll": 19.916666666666668,
|
||||
"dur": 18.824270833333333,
|
||||
"to": 0,
|
||||
"asBot": 81.25,
|
||||
"asCpu": 31.25,
|
||||
"tier": "A"
|
||||
},
|
||||
{
|
||||
"id": "sonofsolo",
|
||||
"w": 51,
|
||||
"l": 45,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 53.125,
|
||||
"hpWin": 28.607843137254903,
|
||||
"hpAll": 15.197916666666666,
|
||||
"dur": 17.826875,
|
||||
"to": 0,
|
||||
"asBot": 77.08333333333334,
|
||||
"asCpu": 29.166666666666668,
|
||||
"tier": "A"
|
||||
},
|
||||
{
|
||||
"id": "anice",
|
||||
"w": 47,
|
||||
"l": 49,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 48.95833333333333,
|
||||
"hpWin": 28.70212765957447,
|
||||
"hpAll": 14.052083333333334,
|
||||
"dur": 17.625312499999996,
|
||||
"to": 0,
|
||||
"asBot": 87.5,
|
||||
"asCpu": 10.416666666666668,
|
||||
"tier": "B"
|
||||
},
|
||||
{
|
||||
"id": "browo",
|
||||
"w": 43,
|
||||
"l": 53,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 44.79166666666667,
|
||||
"hpWin": 33.41860465116279,
|
||||
"hpAll": 14.96875,
|
||||
"dur": 17.465416666666666,
|
||||
"to": 0,
|
||||
"asBot": 66.66666666666666,
|
||||
"asCpu": 22.916666666666664,
|
||||
"tier": "C"
|
||||
},
|
||||
{
|
||||
"id": "ethanolman",
|
||||
"w": 43,
|
||||
"l": 53,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 44.79166666666667,
|
||||
"hpWin": 45.604651162790695,
|
||||
"hpAll": 20.427083333333332,
|
||||
"dur": 17.44145833333333,
|
||||
"to": 0,
|
||||
"asBot": 79.16666666666666,
|
||||
"asCpu": 10.416666666666668,
|
||||
"tier": "C"
|
||||
},
|
||||
{
|
||||
"id": "soloman",
|
||||
"w": 42,
|
||||
"l": 54,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 43.75,
|
||||
"hpWin": 46.38095238095238,
|
||||
"hpAll": 20.291666666666668,
|
||||
"dur": 17.6671875,
|
||||
"to": 0,
|
||||
"asBot": 72.91666666666666,
|
||||
"asCpu": 14.583333333333334,
|
||||
"tier": "C"
|
||||
}
|
||||
],
|
||||
"side": {
|
||||
"hero": {
|
||||
"w": 271,
|
||||
"l": 65,
|
||||
"d": 0,
|
||||
"n": 336
|
||||
},
|
||||
"mirror": {
|
||||
"w": 43,
|
||||
"l": 13,
|
||||
"d": 0,
|
||||
"n": 56
|
||||
},
|
||||
"stuck": 0,
|
||||
"ult": {
|
||||
"bot": 0,
|
||||
"cpu": 135,
|
||||
"both": 257,
|
||||
"none": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"date": "2026-10-05",
|
||||
"label": "Sebelum penyetelan",
|
||||
"n": 8,
|
||||
"level": "hard",
|
||||
"seed": 11,
|
||||
"chr": [
|
||||
{
|
||||
"id": "megachan",
|
||||
"w": 75,
|
||||
"l": 21,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 78.125,
|
||||
"hpWin": 61.026666666666664,
|
||||
"hpAll": 47.677083333333336,
|
||||
"dur": 17.686666666666667,
|
||||
"to": 0,
|
||||
"asBot": 91.66666666666666,
|
||||
"asCpu": 64.58333333333334,
|
||||
"tier": "S"
|
||||
},
|
||||
{
|
||||
"id": "gufron",
|
||||
"w": 69,
|
||||
"l": 27,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 71.875,
|
||||
"hpWin": 46.42028985507246,
|
||||
"hpAll": 33.364583333333336,
|
||||
"dur": 18.181250000000002,
|
||||
"to": 0,
|
||||
"asBot": 83.33333333333334,
|
||||
"asCpu": 60.416666666666664,
|
||||
"tier": "S"
|
||||
},
|
||||
{
|
||||
"id": "anice",
|
||||
"w": 50,
|
||||
"l": 46,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 52.083333333333336,
|
||||
"hpWin": 37.1,
|
||||
"hpAll": 19.322916666666668,
|
||||
"dur": 17.40114583333332,
|
||||
"to": 0,
|
||||
"asBot": 83.33333333333334,
|
||||
"asCpu": 20.833333333333336,
|
||||
"tier": "A"
|
||||
},
|
||||
{
|
||||
"id": "sonofsolo",
|
||||
"w": 48,
|
||||
"l": 48,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 50,
|
||||
"hpWin": 41.25,
|
||||
"hpAll": 20.625,
|
||||
"dur": 17.139791666666667,
|
||||
"to": 0,
|
||||
"asBot": 72.91666666666666,
|
||||
"asCpu": 27.083333333333332,
|
||||
"tier": "B"
|
||||
},
|
||||
{
|
||||
"id": "browo",
|
||||
"w": 40,
|
||||
"l": 56,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 41.66666666666667,
|
||||
"hpWin": 28.3,
|
||||
"hpAll": 11.791666666666666,
|
||||
"dur": 17.878229166666667,
|
||||
"to": 0,
|
||||
"asBot": 75,
|
||||
"asCpu": 8.333333333333332,
|
||||
"tier": "C"
|
||||
},
|
||||
{
|
||||
"id": "soloman",
|
||||
"w": 28,
|
||||
"l": 68,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 29.166666666666668,
|
||||
"hpWin": 29.571428571428573,
|
||||
"hpAll": 8.625,
|
||||
"dur": 17.612604166666664,
|
||||
"to": 0,
|
||||
"asBot": 58.333333333333336,
|
||||
"asCpu": 0,
|
||||
"tier": "C"
|
||||
},
|
||||
{
|
||||
"id": "ethanolman",
|
||||
"w": 26,
|
||||
"l": 70,
|
||||
"d": 0,
|
||||
"n": 96,
|
||||
"rate": 27.083333333333332,
|
||||
"hpWin": 56.07692307692308,
|
||||
"hpAll": 15.1875,
|
||||
"dur": 17.34135416666667,
|
||||
"to": 0,
|
||||
"asBot": 54.166666666666664,
|
||||
"asCpu": 0,
|
||||
"tier": "C"
|
||||
}
|
||||
],
|
||||
"side": {
|
||||
"hero": {
|
||||
"w": 249,
|
||||
"l": 87,
|
||||
"d": 0,
|
||||
"n": 336
|
||||
},
|
||||
"mirror": {
|
||||
"w": 46,
|
||||
"l": 10,
|
||||
"d": 0,
|
||||
"n": 56
|
||||
},
|
||||
"stuck": 0,
|
||||
"ult": {
|
||||
"bot": 2,
|
||||
"cpu": 142,
|
||||
"both": 248,
|
||||
"none": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# Laporan keseimbangan karakter
|
||||
|
||||
Dibuat oleh `node tests/harness.mjs tests/balance.mjs` (simulator RENCANA 5.1). Tanggal: **2026-10-05** · Jalan: **Setelah penyetelan**.
|
||||
|
||||
- Tingkat CPU: **hard** · ronde per pasangan arah: **8** · karakter: BRO WO, MEGA CHAN, SON OF SOLO MAN, ETHANOL MAN, A NICE, SOLO MAN, GUFRON.
|
||||
- Satu **ronde** = satu pertarungan sampai K.O. atau waktu habis (90 dtk), arena Bellora, gaya Seimbang, tanpa layar VS. Pasif dan kelemahan karakter ikut aktif.
|
||||
- Tiap pasangan dimainkan di **kedua posisi** (A sebagai bot lalu B sebagai bot) dan digabung. Pertandingan cermin (karakter sama) hanya dipakai sebagai pembanding bot terhadap CPU, tidak masuk persentase karakter.
|
||||
- Bot pemain (tests/bot.mjs) mendekat, memukul dan menyambung kombo, memakai skill saat jaraknya cocok dan akan kena, memblok atau parry sebagian serangan yang terbaca setelah jeda reaksi, kadang melompati proyektil, dan **membalas ultimate lawan dengan ultimate** (atau memakainya sendiri setelah 8 dtk). Tidak memakai lempar, jurus perintah, atau jurus toko, dan tidak menghindari genangan.
|
||||
- Total ronde dimainkan: 392 (termasuk cermin). Ronde macet: 0.
|
||||
|
||||
## Persentase menang per karakter
|
||||
|
||||
Persen menang = (menang + setengah seri) dibagi jumlah ronde lawan non-cermin, gabungan kedua posisi. Sisa HP dari 200.
|
||||
|
||||
| # | Karakter | Menang % | Tier | M-K-S | Menang sbg bot % | Menang sbg CPU % | Sisa HP saat menang | Sisa HP akhir ronde | Durasi rata-rata (dtk) |
|
||||
| ---: | --- | ---: | :---: | :---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | MEGA CHAN | **58,3** | S | 56-40-0 | 100,0 | 16,7 | 45,1 | 26,3 | 18,8 |
|
||||
| 2 | GUFRON | **56,3** | A | 54-42-0 | 81,3 | 31,3 | 35,4 | 19,9 | 18,8 |
|
||||
| 3 | SON OF SOLO MAN | **53,1** | A | 51-45-0 | 77,1 | 29,2 | 28,6 | 15,2 | 17,8 |
|
||||
| 4 | A NICE | **49,0** | B | 47-49-0 | 87,5 | 10,4 | 28,7 | 14,1 | 17,6 |
|
||||
| 5 | BRO WO | **44,8** | C | 43-53-0 | 66,7 | 22,9 | 33,4 | 15,0 | 17,5 |
|
||||
| 6 | ETHANOL MAN | **44,8** | C | 43-53-0 | 79,2 | 10,4 | 45,6 | 20,4 | 17,4 |
|
||||
| 7 | SOLO MAN | **43,8** | C | 42-54-0 | 72,9 | 14,6 | 46,4 | 20,3 | 17,7 |
|
||||
|
||||
## Posisi: bot melawan CPU
|
||||
|
||||
- Pertandingan non-cermin: bot menang **80,7%**, CPU menang 19,3%, seri 0,0% (336 ronde).
|
||||
- Pertandingan cermin (karakter sama, hanya cermin posisi): bot menang **76,8%**, CPU menang 23,2%, seri 0,0% (56 ronde). Selisih dari 50% menunjukkan keunggulan struktural satu posisi (urutan pemrosesan, kebal saat jurus, bonus counter hanya untuk pemain); karena tiap karakter dimainkan di kedua posisi, selisih ini saling meniadakan di persentase per karakter.
|
||||
|
||||
- Ultimate pertama dalam ronde (semua ronde): bot lebih dulu **0,0%**, CPU lebih dulu **34,4%**, hampir bersamaan (selisih di bawah 0,3 dtk, saling kunci) 65,6%, tak ada yang memakai 0,0%.
|
||||
|
||||
## Tabel menang-kalah 7 x 7
|
||||
|
||||
Persen menang karakter **baris** terhadap karakter **kolom**, gabungan kedua posisi (`M-K-S` ada di tabel berikutnya).
|
||||
|
||||
| baris \ kolom | BRO WO | MEGA CHAN | SON OF SOLO MAN | ETHANOL MAN | A NICE | SOLO MAN | GUFRON |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| **BRO WO** | · | 50% | 44% | 50% | 56% | 56% | 13% |
|
||||
| **MEGA CHAN** | 50% | · | 50% | 56% | 69% | 56% | 69% |
|
||||
| **SON OF SOLO MAN** | 56% | 50% | · | 75% | 44% | 63% | 31% |
|
||||
| **ETHANOL MAN** | 50% | 44% | 25% | · | 50% | 63% | 38% |
|
||||
| **A NICE** | 44% | 31% | 56% | 50% | · | 50% | 63% |
|
||||
| **SOLO MAN** | 44% | 44% | 38% | 38% | 50% | · | 50% |
|
||||
| **GUFRON** | 88% | 31% | 69% | 63% | 38% | 50% | · |
|
||||
|
||||
### Menang-kalah-seri (ronde)
|
||||
|
||||
| baris \ kolom | BRO WO | MEGA CHAN | SON OF SOLO MAN | ETHANOL MAN | A NICE | SOLO MAN | GUFRON |
|
||||
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| **BRO WO** | · | 8-8-0 | 7-9-0 | 8-8-0 | 9-7-0 | 9-7-0 | 2-14-0 |
|
||||
| **MEGA CHAN** | 8-8-0 | · | 8-8-0 | 9-7-0 | 11-5-0 | 9-7-0 | 11-5-0 |
|
||||
| **SON OF SOLO MAN** | 9-7-0 | 8-8-0 | · | 12-4-0 | 7-9-0 | 10-6-0 | 5-11-0 |
|
||||
| **ETHANOL MAN** | 8-8-0 | 7-9-0 | 4-12-0 | · | 8-8-0 | 10-6-0 | 6-10-0 |
|
||||
| **A NICE** | 7-9-0 | 5-11-0 | 9-7-0 | 8-8-0 | · | 8-8-0 | 10-6-0 |
|
||||
| **SOLO MAN** | 7-9-0 | 7-9-0 | 6-10-0 | 6-10-0 | 8-8-0 | · | 8-8-0 |
|
||||
| **GUFRON** | 14-2-0 | 5-11-0 | 11-5-0 | 10-6-0 | 6-10-0 | 8-8-0 | · |
|
||||
|
||||
## Sebelum dan sesudah penyetelan
|
||||
|
||||
Sebelum: jalan "Sebelum penyetelan" (2026-10-05, 8 ronde per pasangan arah, benih 11). Sesudah: jalan ini (benih 11).
|
||||
|
||||
| Karakter | Sebelum % | Sesudah % | Selisih | Tier sebelum | Tier sesudah |
|
||||
| --- | ---: | ---: | ---: | :---: | :---: |
|
||||
| MEGA CHAN | 78,1 | **58,3** | −19,8 | S | S |
|
||||
| GUFRON | 71,9 | **56,3** | −15,6 | S | A |
|
||||
| SON OF SOLO MAN | 50,0 | **53,1** | +3,1 | B | A |
|
||||
| A NICE | 52,1 | **49,0** | −3,1 | A | B |
|
||||
| BRO WO | 41,7 | **44,8** | +3,1 | C | C |
|
||||
| ETHANOL MAN | 27,1 | **44,8** | +17,7 | C | C |
|
||||
| SOLO MAN | 29,2 | **43,8** | +14,6 | C | C |
|
||||
|
||||
## Cara membaca dan batasan
|
||||
|
||||
- Angka berasal dari simulasi bot melawan CPU, **bukan kebenaran mutlak**. Dengan 8 ronde per pasangan arah, tiap karakter punya sekitar 96 ronde; galat acak sekitar ±5,1 poin persen (satu simpangan baku), jadi selisih di bawah itu belum berarti.
|
||||
- Ultimate dan mode pasca-ultimate mengunci lawan beberapa detik, sehingga siapa yang lebih dulu dan seberapa mematikan rangkaian ultimate-nya menentukan sebagian besar ronde. Bot sengaja membalas ultimate lawan agar yang diukur adalah adu rangkaian, bukan sekadar siapa menekan duluan.
|
||||
- Bot tidak menghindari genangan api atau asam dan tidak memakai lempar atau jurus toko, jadi karakter berbasis genangan mungkin sedikit terlalu tinggi dibanding pemain manusia yang pandai menghindar.
|
||||
- Batas tier: S ≥ 58% (Dewa Arena), A ≥ 52% (Jagoan Kampung), B ≥ 46% (Lumayan Lah), selebihnya C (Butuh Semangat).
|
||||
|
||||
<!-- catatan-manual: bagian di bawah baris ini dipertahankan saat laporan dibuat ulang -->
|
||||
|
||||
## Penyetelan angka (RENCANA 5.3)
|
||||
|
||||
Hasil awal (benih 11, 8 ronde per pasangan arah): **MEGA CHAN 78,1%** dan **GUFRON 71,9%** di atas batas 65%, **ETHANOL MAN 27,1%** dan **SOLO MAN 29,2%** di bawah 35%. Angka disetel dalam empat putaran kecil: hanya angka data (manifest karakter, `browo.js`, `content/passives.js`), tanpa mengubah mekanik.
|
||||
|
||||
| Karakter | Perubahan angka (sebelum → sesudah) |
|
||||
| --- | --- |
|
||||
| MEGA CHAN | ultimate: banteng 8 → 7; mode ultimate TANDUK NERAKA: damage per banteng 7 → 6; pasif KEPALA BANTENG: damage diterima −8% → −5% |
|
||||
| GUFRON | skill 1 BAHASA SEMUT: damage per semut 4 → 3 dan jumlah semut 10 → 8; ultimate: gelombang semut 9 → 8; mode ultimate: 12 → 11; pasif: pulih skill +12% → +5% |
|
||||
| SOLO MAN | skill 1 ESEMKA RUSH 20 → 22; skill 2 SARUNG PUTAR 16 → 18; ultimate KONVOI ESEMKA: mobil 6 → 7 dan damage per mobil 8 → 9; mode ultimate: mobil 9 → 10; kelemahan: damage keluar −6% → −3% |
|
||||
| ETHANOL MAN | skill 2 ETHANOL SPLASH 18 → 20; ultimate BANJIR BIOETANOL: damage per gelombang 9 → 10; mode ultimate: damage per pilar 4 → 5; kelemahan: pulih cooldown −5% → −4% |
|
||||
| BRO WO | ultimate HUTAN SAWIT: damage per pohon 3 → 4 |
|
||||
| A NICE, SON OF SOLO MAN | tidak diubah |
|
||||
|
||||
Riwayat (persen menang, benih 11, 8 ronde per pasangan arah; satu putaran = satu kali jalan simulator sesudah beberapa perubahan sekaligus):
|
||||
|
||||
| Karakter | Awal | Putaran 1 | Putaran 2 | Putaran 3 | Akhir (putaran 4, tabel di atas) |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| MEGA CHAN | 78,1 | 53,1 | 47,9 | 52,1 | **58,3** |
|
||||
| GUFRON | 71,9 | 70,8 | 71,9 | 54,2 | **56,3** |
|
||||
| SON OF SOLO MAN | 50,0 | 46,9 | 55,2 | 52,1 | **53,1** |
|
||||
| A NICE | 52,1 | 47,9 | 46,9 | 47,9 | **49,0** |
|
||||
| BRO WO | 41,7 | 45,8 | 45,8 | 46,9 | **44,8** |
|
||||
| ETHANOL MAN | 27,1 | 49,0 | 51,0 | 55,2 | **44,8** |
|
||||
| SOLO MAN | 29,2 | 36,5 | 31,3 | 41,7 | **43,8** |
|
||||
|
||||
Putaran 4 menambah buff kecil BRO WO (belum tersentuh sebelumnya, terendah pada jalan konfirmasi). Jalan konfirmasi dengan benih bot lain (benih 23, keadaan akhir, tidak tertulis ke laporan): GUFRON 62,5 · MEGA CHAN 52,1 · ETHANOL MAN 51,0 · A NICE 51,0 · SON OF SOLO MAN 49,0 · BRO WO 42,7 · SOLO MAN 41,7. Pada kedua jalan semua karakter berada di antara 41% dan 63%. Selisih antar putaran sebesar 5 sampai 10 poin masih wajar dari galat acak, jadi jangan membaca satu angka sebagai ukuran pasti.
|
||||
|
||||
## Temuan penting
|
||||
|
||||
1. **Rangkaian ultimate menentukan hampir semua ronde.** Terhadap sasaran pasif, ultimate pertama ditambah ultimate mode (pasca-ultimate) menghasilkan 110 sampai 148 damage dari 200 HP sebelum penyetelan (MEGA CHAN 148, A NICE 134, SON OF SOLO MAN 129, BRO WO 127, SOLO MAN 126, GUFRON 126, ETHANOL MAN 110), dan selama itu lawan terkunci. Bot yang dikunci CPU kehilangan rata-rata sekitar 155 HP per ronde dari rangkaian ultimate dan ultimate mode (diukur pada GUFRON dan MEGA CHAN sebagai CPU, 18 ronde masing-masing). Karena itu hampir semua penyetelan di atas menyentuh angka ultimate, dan satu atau dua damage per denyut bisa menggeser persen menang belasan poin.
|
||||
2. **Penutup ultimate menjatuhkan lawan.** Penutup (finisher) membuat sasaran jatuh dan bangun sekitar 2,1 dtk, dan denyut ultimate mode yang jatuh pada waktu itu tidak mengenai (sasaran jatuh dianggap tidak hidup oleh `bwAlive`). Memakai ultimate mode segera setelah rangkaian pertama membuang beberapa denyut; bot menunggu lawan bangun.
|
||||
3. **CPU hampir tidak pernah memakai skill 2 ETHANOL MAN dan SOLO MAN** (pada uji pasangan `tests/pairs.mjs`: SOLO MAN 0 dari 7 ronde, ETHANOL MAN 1 dari 7). Keduanya berjenis area tanpa `ahead` (jangkauan 135 dan 140), lebih pendek dari pukulan dasar (161), dan `cpuPick` selalu mendahulukan pukulan dasar, sedangkan penutup kombo (`cpuFollowUp`) praktis tidak pernah memenuhi `cpuLands` (sisa stun pemain lebih pendek dari waktu kena skill). Akibatnya kedua karakter ini lebih lemah sebagai lawan CPU daripada angka kitnya. Ini celah AI (mekanik), bukan angka; uji pasangan (`tests/pairs.mjs`) mencatatnya sebagai peringatan. Bila AI atau datanya diperbaiki (misalnya `ahead` di manifest), jalankan ulang simulator dan setel ulang kedua karakter.
|
||||
4. **Posisi bot punya keunggulan besar.** Bot menang 77 sampai 85% pada semua jalan, juga pada pertandingan cermin. Penyebabnya struktural (pemain kebal saat skill dan ultimate, bonus counter 25% hanya untuk pukulan pemain, urutan pemrosesan hero lebih dulu) ditambah bot yang cukup rapi. Persen menang per karakter menggabungkan kedua posisi sehingga keunggulan ini saling meniadakan, tetapi banyak pasangan berakhir 8-8-0 (bot menang di kedua posisi), yang berarti hasilnya ditentukan posisi dan bukan karakter. Resolusi pengukuran jadi lebih kasar; untuk keputusan penyetelan pakai `BAL_N` 12 atau lebih, atau lemahkan bot (`BAL_BOT`).
|
||||
5. **Pasangan paling timpang** (jalan akhir): GUFRON atas BRO WO 88% (14-2), SON OF SOLO MAN atas ETHANOL MAN 75%, MEGA CHAN atas A NICE dan atas GUFRON 69%. Pasangan GUFRON dan BRO WO layak diperiksa lebih dulu pada penyetelan berikutnya.
|
||||
6. **Kebijakan ultimate bot menentukan apa yang diukur.** Pada percobaan awal bot memakai ultimate sendiri pada detik ke-5 seperti CPU tanpa membalas, dan menang sekitar 93% ronde apa pun karakternya (siapa yang menekan duluan menang; tidak informatif). Maka bot kini membalas ultimate lawan dalam waktu reaksi: sekitar dua pertiga ronde menjadi adu rangkaian yang hampir bersamaan, dan sepertiga sisanya CPU lebih dulu (biasanya bot sedang terkena serangan atau sedang menjalankan skill saat CPU mulai).
|
||||
|
||||
## Menjalankan ulang
|
||||
|
||||
```bash
|
||||
BAL_N=8 BAL_WORKERS=2 BAL_LABEL="Setelah penyetelan" BAL_BASE=docs/balance-before.json BAL_JSON=docs/balance-after.json BAL_TIERS=1 \
|
||||
node tests/harness.mjs tests/balance.mjs 2026-10-05
|
||||
```
|
||||
|
||||
`BAL_BASE` memakai hasil sebelum penyetelan (`docs/balance-before.json`, benih 11, N=8) untuk tabel perbandingan, `BAL_TIERS=1` menulis ulang `content/tiers.js`, dan bagian ini (di bawah penanda catatan manual) dipertahankan. Hasilnya sama untuk benih, N, dan jumlah pekerja yang sama.
|
||||
12
meta.css
12
meta.css
|
|
@ -61,3 +61,15 @@
|
|||
@media(max-height:700px){.mv-row small.pas-desc{display:none}}
|
||||
@media(min-width:900px){#pause-panel{width:min(1160px,calc(100% - 24px))}.move-body{columns:200px;column-gap:20px}}
|
||||
@media(min-height:421px) and (max-height:640px){.mv-sec{margin-bottom:9px}.mv-row{padding:2px 0}#pause-panel[open]{padding-top:20px;padding-bottom:20px}}
|
||||
|
||||
/* ---- Peringkat karakter versi simulator (RENCANA 5.2): menu Koleksi ---- */
|
||||
.tiers{display:grid;gap:6px}
|
||||
.tier-row{display:grid;grid-template-columns:96px 1fr;gap:10px;align-items:center;padding:6px 8px;background:#101c26;border:1px solid #ffffff1c;border-left:4px solid var(--tc,#e8b84a);border-radius:6px;min-width:0}
|
||||
.tier-row>b{display:grid;font-size:26px;line-height:1;color:var(--tc,#e8b84a);letter-spacing:.04em}
|
||||
.tier-row>b small{font-size:10px;letter-spacing:.08em;color:#9fb3c4;margin-top:3px}
|
||||
.tier-row>div{display:flex;flex-wrap:wrap;gap:6px;min-width:0}
|
||||
.tier-chip{display:inline-flex;align-items:center;gap:6px;padding:2px 10px 2px 2px;background:#0b1520;border:1px solid #ffffff22;border-radius:6px;font-size:14px;letter-spacing:.04em}
|
||||
.tier-chip img{width:28px;height:28px;border-radius:4px;object-fit:cover}
|
||||
.tier-chip em{font-style:normal;color:#8fa3b4;font-size:12px}
|
||||
.t-S{--tc:#ffd24a}.t-A{--tc:#8dffa8}.t-B{--tc:#9fe8ff}.t-C{--tc:#ff9d8f}
|
||||
@media(max-width:560px){.tier-row{grid-template-columns:1fr}}
|
||||
|
|
|
|||
18
meta.js
18
meta.js
|
|
@ -271,10 +271,26 @@
|
|||
const n = fnames(who), pas = window.Passives?.lockerHtml(who, styleOf(who)) || '';
|
||||
return `<div class="roster">${pick}</div><div class="locker"><div class="preview" style="filter:${SKINS[sk]?.filter || 'none'}"><img src="${portrait(who)}" alt=""></div><div class="info"><h3>${fname(who)}</h3>${pas}<h5>Skin</h5><div class="pills">${skins}</div><h5>Jurus toko (tombol K)</h5><div class="pills">${moves}</div><p class="meta-hint">${save.owned.move.length ? '' : 'Belum punya jurus toko. Beli di tab TOKO.'}</p>${altRows}<h5>Daftar gerakan</h5><ul class="moves">${n.length ? `<li><b>SPASI</b> ${esc(n[0] || '')}</li><li><b>I</b> ${esc(n[1] || '')}</li><li><b>O</b> ${esc(n[2] || '')}</li><li><b>P</b> ${esc(n[3] || '')}</li>` : ''}${COMMANDS.map(c => `<li><b>${c[0]}</b> ${c[1]} — ${c[2]}</li>`).join('')}</ul></div></div>`;
|
||||
}
|
||||
// ---- Peringkat karakter versi simulator (RENCANA 5.2) ----
|
||||
// Data content/tiers.js dibuat otomatis oleh simulator (tests/balance.mjs). Dimuat malas saat Koleksi pertama dibuka; bila tidak ada atau gagal, bagian ini tidak tampil.
|
||||
const TIER_LABEL = { S: 'DEWA ARENA', A: 'JAGOAN KAMPUNG', B: 'LUMAYAN LAH', C: 'BUTUH SEMANGAT' };
|
||||
let tiersLoad = 0;
|
||||
function loadTiers() {
|
||||
if (window.TIERS || tiersLoad) return; tiersLoad = 1;
|
||||
const s = document.createElement('script'); s.src = 'content/tiers.js';
|
||||
s.onload = s.onerror = () => { tiersLoad = 2; if (root && !root.hidden && tab === 'book') render(); };
|
||||
document.head.append(s);
|
||||
}
|
||||
function tiersHtml() {
|
||||
const T = window.TIERS; if (!T?.order?.length) { loadTiers(); return ''; }
|
||||
const ids = T.order.filter(id => T.tier?.[id] && (window.FIGHTERS?.[id] || id === 'browo'));
|
||||
const rows = ['S', 'A', 'B', 'C'].map(k => { const list = ids.filter(id => T.tier[id] === k); return list.length ? `<div class="tier-row t-${k}"><b>${k}<small>${TIER_LABEL[k]}</small></b><div>${list.map(id => `<span class="tier-chip"><img src="${portrait(id)}" alt="">${esc(fname(id))}<em>${String(T.rate?.[id] ?? '').replace('.', ',')}%</em></span>`).join('')}</div></div>` : ''; }).join('');
|
||||
return rows ? `<h3>Peringkat karakter (versi simulator)</h3><div class="tiers">${rows}</div><p class="meta-hint">Angka dari simulasi CPU, bukan kebenaran mutlak.${T.updated ? ' Diperbarui ' + esc(T.updated) + '.' : ''}</p>` : '';
|
||||
}
|
||||
function renderBook() {
|
||||
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
|
||||
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'penasaran_berat', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p>${window.MatchStats?.lifetimeHtml(st) || ''}<h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p>${window.MatchStats?.lifetimeHtml(st) || ''}${tiersHtml()}<h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||
}
|
||||
function onClick(e) {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
// Simulator keseimbangan (RENCANA 5.1 dan 5.2): 7 x 7 karakter, N ronde per pasangan arah, tanpa render.
|
||||
// node tests/harness.mjs tests/balance.mjs [tanggal]
|
||||
// Sisi pemain (hero) dikendalikan bot sederhana (tests/bot.mjs), sisi lawan oleh CPU bawaan game. Tiap pasangan dimainkan di KEDUA posisi
|
||||
// (A sebagai bot dan B sebagai CPU, lalu sebaliknya) dan digabung. Satu "ronde" = satu pertarungan sampai K.O. atau waktu habis (90 dtk).
|
||||
// Env: BAL_N (ronde per pasangan arah, bawaan 6) BAL_LEVEL (easy|medium|hard|excellent, bawaan hard) BAL_DATE (tanggal laporan, atau argumen ke-2; bukan Date)
|
||||
// BAL_BOT='{"react":.25,"pBlock":.3}' (timpa penyetelan bot, lihat C di tests/bot.mjs; melemahkan atau menguatkan bot) BAL_ONLY=a,b,c (batasi karakter, uji cepat) BAL_SEED (benih bot, bawaan 11) BAL_REPORT (jalur laporan, bawaan docs/balance-report.md, "none" = jangan tulis)
|
||||
// BAL_WORKERS=k (jalankan k proses Chromium paralel; hasil tetap sama untuk k yang sama) BAL_TIERS=1 (tulis juga content/tiers.js) BAL_JSON=jalur (simpan hasil ringkas) BAL_BASE=jalur (hasil "sebelum" untuk tabel perbandingan) BAL_LABEL (nama jalan ini)
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { installBalance } from './bot.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const env = process.env;
|
||||
const N = Math.max(1, Number(env.BAL_N) || 6), LEVEL = env.BAL_LEVEL || 'hard', SEED = Number(env.BAL_SEED) || 11;
|
||||
const DATE = env.BAL_DATE || process.argv[3] || '';
|
||||
const MARK = '<!-- catatan-manual: bagian di bawah baris ini dipertahankan saat laporan dibuat ulang -->';
|
||||
// Batas tier (persen menang). Rata-rata semua karakter selalu 50 karena tiap ronde punya satu pemenang.
|
||||
const TIER_CUT = [['S', 58], ['A', 52], ['B', 46], ['C', -1]];
|
||||
const TIER_NAME = { S: 'Dewa Arena', A: 'Jagoan Kampung', B: 'Lumayan Lah', C: 'Butuh Semangat' };
|
||||
|
||||
const tierOf = rate => TIER_CUT.find(([, min]) => rate >= min)[0];
|
||||
const num = x => (Math.round(x * 10) / 10).toFixed(1).replace('.', ',');
|
||||
const mean = (s, n) => (n ? s / n : 0);
|
||||
|
||||
// ---- agregasi: results[a>b] = daftar ronde (a = bot/pemain, b = CPU) ----
|
||||
export function analyze(results, chars) {
|
||||
const cells = {}, per = {};
|
||||
for (const id of chars) per[id] = { id, w: 0, l: 0, d: 0, n: 0, hpWin: 0, nWin: 0, hpAll: 0, dur: 0, to: 0, hn: 0, hw: 0, cn: 0, cw: 0 };
|
||||
const side = { hero: { w: 0, l: 0, d: 0, n: 0 }, mirror: { w: 0, l: 0, d: 0, n: 0 }, stuck: 0, ult: { bot: 0, cpu: 0, both: 0, none: 0 } };
|
||||
for (const a of chars) for (const b of chars) {
|
||||
const rows = results[a + '>' + b] || [], c = cells[a + '>' + b] = { a, b, n: 0, hw: 0, cw: 0, dr: 0, hh: 0, ch: 0, dur: 0 };
|
||||
for (const r of rows) {
|
||||
if (r.w === 'S' || r.error) { side.stuck++; continue; }
|
||||
c.n++; c.hh += r.hh; c.ch += r.ch; c.dur += r.t;
|
||||
{ const u = side.ult, hu = r.uh >= 0, cu = r.uc >= 0; if (hu && cu) { const df = r.uc - r.uh; if (df > .3) u.bot++; else if (df < -.3) u.cpu++; else u.both++; } else if (hu) u.bot++; else if (cu) u.cpu++; else u.none++; }
|
||||
if (r.w === 'H') c.hw++; else if (r.w === 'C') c.cw++; else c.dr++;
|
||||
if (a === b) { const m = side.mirror; m.n++; if (r.w === 'H') m.w++; else if (r.w === 'C') m.l++; else m.d++; continue; }
|
||||
const s = side.hero; s.n++; if (r.w === 'H') s.w++; else if (r.w === 'C') s.l++; else s.d++;
|
||||
for (const [id, own, win, lose] of [[a, r.hh, 'H', 'C'], [b, r.ch, 'C', 'H']]) {
|
||||
const p = per[id]; p.n++; p.hpAll += own; p.dur += r.t; if (r.to) p.to++;
|
||||
if (win === 'H') { p.hn++; if (r.w === 'H') p.hw++; } else { p.cn++; if (r.w === 'C') p.cw++; }
|
||||
if (r.w === win) { p.w++; p.hpWin += own; p.nWin++; } else if (r.w === lose) p.l++; else p.d++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const chr = chars.map(id => { const p = per[id]; return { id, w: p.w, l: p.l, d: p.d, n: p.n, rate: p.n ? (p.w + p.d / 2) / p.n * 100 : 0, hpWin: mean(p.hpWin, p.nWin), hpAll: mean(p.hpAll, p.n), dur: mean(p.dur, p.n), to: p.to, asBot: p.hn ? p.hw / p.hn * 100 : 0, asCpu: p.cn ? p.cw / p.cn * 100 : 0 }; });
|
||||
chr.sort((x, y) => y.rate - x.rate);
|
||||
for (const p of chr) p.tier = tierOf(p.rate);
|
||||
// matriks: skor baris terhadap kolom, gabungan kedua posisi
|
||||
const mat = {};
|
||||
for (const a of chars) for (const b of chars) {
|
||||
if (a === b) continue;
|
||||
const x = cells[a + '>' + b], y = cells[b + '>' + a], w = x.hw + y.cw, l = x.cw + y.hw, d = x.dr + y.dr, n = x.n + y.n;
|
||||
mat[a + '>' + b] = { w, l, d, n, rate: n ? (w + d / 2) / n * 100 : 50 };
|
||||
}
|
||||
return { chars, n: N, level: LEVEL, chr, cells, mat, side };
|
||||
}
|
||||
|
||||
// ---- keluaran ----
|
||||
function reportMd(S, base, keep) {
|
||||
const name = id => (S.names?.[id] || id).toUpperCase(), L = [];
|
||||
const ord = S.chr;
|
||||
L.push('# Laporan keseimbangan karakter', '');
|
||||
L.push(`Dibuat oleh \`node tests/harness.mjs tests/balance.mjs\` (simulator RENCANA 5.1). Tanggal: **${DATE || 'tidak diberikan'}**${env.BAL_LABEL ? ` · Jalan: **${env.BAL_LABEL}**` : ''}.`, '');
|
||||
L.push(`- Tingkat CPU: **${S.level}** · ronde per pasangan arah: **${S.n}** · karakter: ${S.chars.map(name).join(', ')}.`);
|
||||
L.push(`- Satu **ronde** = satu pertarungan sampai K.O. atau waktu habis (90 dtk), arena Bellora, gaya Seimbang, tanpa layar VS. Pasif dan kelemahan karakter ikut aktif.`);
|
||||
L.push(`- Tiap pasangan dimainkan di **kedua posisi** (A sebagai bot lalu B sebagai bot) dan digabung. Pertandingan cermin (karakter sama) hanya dipakai sebagai pembanding bot terhadap CPU, tidak masuk persentase karakter.`);
|
||||
L.push('- Bot pemain (tests/bot.mjs) mendekat, memukul dan menyambung kombo, memakai skill saat jaraknya cocok dan akan kena, memblok atau parry sebagian serangan yang terbaca setelah jeda reaksi, kadang melompati proyektil, dan **membalas ultimate lawan dengan ultimate** (atau memakainya sendiri setelah 8 dtk). Tidak memakai lempar, jurus perintah, atau jurus toko, dan tidak menghindari genangan.');
|
||||
L.push(`- Total ronde dimainkan: ${S.chars.length * S.chars.length * S.n} (termasuk cermin). Ronde macet: ${S.side.stuck}.${env.BAL_BOT ? ` Penyetelan bot ditimpa: \`${env.BAL_BOT}\`.` : ''}`, '');
|
||||
|
||||
L.push('## Persentase menang per karakter', '');
|
||||
L.push('Persen menang = (menang + setengah seri) dibagi jumlah ronde lawan non-cermin, gabungan kedua posisi. Sisa HP dari 200.', '');
|
||||
L.push('| # | Karakter | Menang % | Tier | M-K-S | Menang sbg bot % | Menang sbg CPU % | Sisa HP saat menang | Sisa HP akhir ronde | Durasi rata-rata (dtk) |', '| ---: | --- | ---: | :---: | :---: | ---: | ---: | ---: | ---: | ---: |');
|
||||
ord.forEach((p, i) => L.push(`| ${i + 1} | ${name(p.id)} | **${num(p.rate)}** | ${p.tier} | ${p.w}-${p.l}-${p.d} | ${num(p.asBot)} | ${num(p.asCpu)} | ${num(p.hpWin)} | ${num(p.hpAll)} | ${num(p.dur)} |`));
|
||||
L.push('');
|
||||
const hero = S.side.hero, mir = S.side.mirror, pct = (x, n) => (n ? num(x / n * 100) : '-');
|
||||
L.push('## Posisi: bot melawan CPU', '');
|
||||
L.push(`- Pertandingan non-cermin: bot menang **${pct(hero.w, hero.n)}%**, CPU menang ${pct(hero.l, hero.n)}%, seri ${pct(hero.d, hero.n)}% (${hero.n} ronde).`);
|
||||
L.push(`- Pertandingan cermin (karakter sama, hanya cermin posisi): bot menang **${pct(mir.w, mir.n)}%**, CPU menang ${pct(mir.l, mir.n)}%, seri ${pct(mir.d, mir.n)}% (${mir.n} ronde). Selisih dari 50% menunjukkan keunggulan struktural satu posisi (urutan pemrosesan, kebal saat jurus, bonus counter hanya untuk pemain); karena tiap karakter dimainkan di kedua posisi, selisih ini saling meniadakan di persentase per karakter.`, '');
|
||||
|
||||
{ const u = S.side.ult, t = u.bot + u.cpu + u.both + u.none || 1; L.push(`- Ultimate pertama dalam ronde (semua ronde): bot lebih dulu **${pct(u.bot, t)}%**, CPU lebih dulu **${pct(u.cpu, t)}%**, hampir bersamaan (selisih di bawah 0,3 dtk, saling kunci) ${pct(u.both, t)}%, tak ada yang memakai ${pct(u.none, t)}%.`, ''); }
|
||||
L.push('## Tabel menang-kalah 7 x 7', '');
|
||||
L.push('Persen menang karakter **baris** terhadap karakter **kolom**, gabungan kedua posisi (`M-K-S` ada di tabel berikutnya).', '');
|
||||
L.push('| baris \\ kolom | ' + S.chars.map(name).join(' | ') + ' |', '| --- |' + S.chars.map(() => ' ---: |').join(''));
|
||||
for (const a of S.chars) L.push(`| **${name(a)}** | ` + S.chars.map(b => (a === b ? '·' : `${Math.round(S.mat[a + '>' + b].rate)}%`)).join(' | ') + ' |');
|
||||
L.push('', '### Menang-kalah-seri (ronde)', '');
|
||||
L.push('| baris \\ kolom | ' + S.chars.map(name).join(' | ') + ' |', '| --- |' + S.chars.map(() => ' :---: |').join(''));
|
||||
for (const a of S.chars) L.push(`| **${name(a)}** | ` + S.chars.map(b => { if (a === b) return '·'; const m = S.mat[a + '>' + b]; return `${m.w}-${m.l}-${m.d}`; }).join(' | ') + ' |');
|
||||
L.push('');
|
||||
|
||||
if (base) {
|
||||
L.push('## Sebelum dan sesudah penyetelan', '');
|
||||
L.push(`Sebelum: jalan "${base.label || 'awal'}" (${base.date || 'tanpa tanggal'}, ${base.n} ronde per pasangan arah, benih ${base.seed ?? '?'}). Sesudah: jalan ini (benih ${SEED}).`, '');
|
||||
L.push('| Karakter | Sebelum % | Sesudah % | Selisih | Tier sebelum | Tier sesudah |', '| --- | ---: | ---: | ---: | :---: | :---: |');
|
||||
const bmap = Object.fromEntries(base.chr.map(p => [p.id, p]));
|
||||
for (const p of ord) { const b = bmap[p.id]; if (!b) continue; const dlt = p.rate - b.rate; L.push(`| ${name(p.id)} | ${num(b.rate)} | **${num(p.rate)}** | ${dlt >= 0 ? '+' : '−'}${num(Math.abs(dlt))} | ${b.tier} | ${p.tier} |`); }
|
||||
L.push('');
|
||||
}
|
||||
L.push('## Cara membaca dan batasan', '');
|
||||
L.push('- Angka berasal dari simulasi bot melawan CPU, **bukan kebenaran mutlak**. Dengan ' + S.n + ' ronde per pasangan arah, tiap karakter punya sekitar ' + (S.n * (S.chars.length - 1) * 2) + ' ronde; galat acak sekitar ±' + num(100 * Math.sqrt(.25 / Math.max(1, S.n * (S.chars.length - 1) * 2))) + ' poin persen (satu simpangan baku), jadi selisih di bawah itu belum berarti.');
|
||||
L.push('- Ultimate dan mode pasca-ultimate mengunci lawan beberapa detik, sehingga siapa yang lebih dulu dan seberapa mematikan rangkaian ultimate-nya menentukan sebagian besar ronde. Bot sengaja membalas ultimate lawan agar yang diukur adalah adu rangkaian, bukan sekadar siapa menekan duluan.');
|
||||
L.push('- Bot tidak menghindari genangan api atau asam dan tidak memakai lempar atau jurus toko, jadi karakter berbasis genangan mungkin sedikit terlalu tinggi dibanding pemain manusia yang pandai menghindar.');
|
||||
L.push(`- Batas tier: S ≥ ${TIER_CUT[0][1]}% (${TIER_NAME.S}), A ≥ ${TIER_CUT[1][1]}% (${TIER_NAME.A}), B ≥ ${TIER_CUT[2][1]}% (${TIER_NAME.B}), selebihnya C (${TIER_NAME.C}).`, '');
|
||||
L.push(MARK);
|
||||
if (keep) L.push(keep.replace(/^\n+/, ''));
|
||||
return L.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function tiersJs(S) {
|
||||
const q = x => `'${x}'`, ord = S.chr;
|
||||
return `/* Peringkat karakter versi simulator (RENCANA 5.2). DIBUAT OTOMATIS oleh tests/balance.mjs (BAL_TIERS=1): jangan diedit tangan, jalankan ulang simulatornya.
|
||||
Angka dari simulasi bot melawan CPU (tingkat ${S.level}, ${S.n} ronde per pasangan arah, kedua posisi), bukan kebenaran mutlak. Dipakai menu Koleksi (meta.js renderBook).
|
||||
updated = tanggal jalan; order = urutan dari yang tertinggi; rate = persen menang; tier = S (>= ${TIER_CUT[0][1]}), A (>= ${TIER_CUT[1][1]}), B (>= ${TIER_CUT[2][1]}), C. */
|
||||
window.TIERS = {
|
||||
updated: ${q(DATE)},
|
||||
order: [${ord.map(p => q(p.id)).join(', ')}],
|
||||
rate: { ${ord.map(p => `${p.id}: ${Math.round(p.rate * 10) / 10}`).join(', ')} },
|
||||
tier: { ${ord.map(p => `${p.id}: ${q(p.tier)}`).join(', ')} }
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
async function runJobs(page, game, jobs) {
|
||||
await page.evaluate(installBalance);
|
||||
if (env.BAL_BOT) await page.evaluate(c => Object.assign(window.BalSim.bot.C, c), JSON.parse(env.BAL_BOT));
|
||||
const out = {};
|
||||
for (const [a, b] of jobs) out[a + '>' + b] = await game(args => window.BalSim.play(args), { a, b, n: N, level: LEVEL, seed0: SEED, cap: 6000 });
|
||||
return out;
|
||||
}
|
||||
// Pekerja paralel: proses anak menjalankan sebagian pasangan dan mencetak satu baris BALPART:<json> yang dibaca induk.
|
||||
function spawnShard(i, k) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(process.execPath, [path.join(root, 'tests/harness.mjs'), path.join(root, 'tests/balance.mjs')], { cwd: root, env: { ...env, BAL_SHARD: `${i}/${k}`, BAL_WORKERS: '', BAL_REPORT: 'none', BAL_TIERS: '', BAL_JSON: '' } });
|
||||
let out = '', err = ''; p.stdout.on('data', x => { out += x; }); p.stderr.on('data', x => { err += x; });
|
||||
p.on('close', code => {
|
||||
const line = out.split('\n').find(l => l.startsWith('BALPART:'));
|
||||
if (code !== 0 || !line) return reject(new Error(`pekerja ${i}/${k} gagal (kode ${code}): ${(out + err).slice(-600)}`));
|
||||
resolve(JSON.parse(line.slice(8)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async ({ page, game, characters }) => {
|
||||
const chars = (env.BAL_ONLY ? characters.filter(c => env.BAL_ONLY.split(',').includes(c)) : characters);
|
||||
if (chars.length < 2) return 'BAL_ONLY harus memuat minimal dua karakter.';
|
||||
const jobs = []; for (const a of chars) for (const b of chars) jobs.push([a, b]);
|
||||
const [si, sk] = env.BAL_SHARD ? env.BAL_SHARD.split('/').map(Number) : [0, Math.max(1, Math.floor(Number(env.BAL_WORKERS)) || 1)];
|
||||
const mine = jobs.filter((_, i) => i % sk === si);
|
||||
if (env.BAL_SHARD) return 'BALPART:' + JSON.stringify(await runJobs(page, game, mine));
|
||||
const t0 = Date.now();
|
||||
const kids = []; for (let i = 1; i < sk; i++) kids.push(spawnShard(i, sk));
|
||||
const own = await runJobs(page, game, mine);
|
||||
const results = Object.assign({}, own, ...(await Promise.all(kids)));
|
||||
const names = await game(() => Object.fromEntries(Object.entries(window.FIGHTERS || {}).map(([id, f]) => [id, f.name]).concat([['browo', 'BRO WO']])));
|
||||
const S = analyze(results, chars); S.names = names;
|
||||
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
// simpan hasil ringkas / baca hasil "sebelum"
|
||||
const brief = { date: DATE, label: env.BAL_LABEL || '', n: N, level: LEVEL, seed: SEED, chr: S.chr, side: S.side };
|
||||
if (env.BAL_JSON) fs.writeFileSync(path.resolve(root, env.BAL_JSON), JSON.stringify(brief, null, 1) + '\n');
|
||||
let base = null;
|
||||
if (env.BAL_BASE) try { base = JSON.parse(fs.readFileSync(path.resolve(root, env.BAL_BASE), 'utf8')); } catch (e) { return 'BAL_BASE tidak terbaca: ' + e.message; }
|
||||
const rep = path.resolve(root, env.BAL_REPORT || 'docs/balance-report.md');
|
||||
if (env.BAL_REPORT !== 'none') {
|
||||
let keep = ''; try { const old = fs.readFileSync(rep, 'utf8'), i = old.indexOf(MARK); if (i >= 0) keep = old.slice(i + MARK.length); } catch (_) { /* belum ada */ }
|
||||
fs.writeFileSync(rep, reportMd(S, base, keep));
|
||||
}
|
||||
if (env.BAL_TIERS === '1') fs.writeFileSync(path.join(root, 'content/tiers.js'), tiersJs(S));
|
||||
const lines = [`Simulasi selesai: ${S.chars.length * S.chars.length * N} ronde dalam ${secs} dtk (tingkat ${LEVEL}, N=${N}, benih ${SEED}).`];
|
||||
for (const p of S.chr) lines.push(`${(S.names[p.id] || p.id).padEnd(15)} ${num(p.rate).padStart(5)}% ${p.tier} ${p.w}-${p.l}-${p.d} bot ${num(p.asBot).padStart(5)}% cpu ${num(p.asCpu).padStart(5)}% sisaHP(menang) ${num(p.hpWin)} durasi ${num(p.dur)}`);
|
||||
const h = S.side.hero, m = S.side.mirror;
|
||||
{ const u = S.side.ult, tt = u.bot + u.cpu + u.both + u.none || 1; lines.push(`Bot menang ${num(h.w / Math.max(1, h.n) * 100)}% (non-cermin), ${num(m.w / Math.max(1, m.n) * 100)}% (cermin); ronde macet ${S.side.stuck}. Ultimate pertama: bot ${num(u.bot / tt * 100)}% CPU ${num(u.cpu / tt * 100)}% serentak ${num(u.both / tt * 100)}% tak ada ${num(u.none / tt * 100)}%.`); }
|
||||
if (env.BAL_REPORT !== 'none') lines.push('Laporan: ' + path.relative(root, rep));
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// Bot pemain sederhana + pemutar ronde untuk simulator keseimbangan (RENCANA 5.1) dan uji pasangan (RENCANA 5.4).
|
||||
// installBalance() dijalankan DI DALAM halaman (page.evaluate(installBalance)) dan memasang window.BalSim = { bot, play, round, think, reset, TICK, ... }:
|
||||
// round({a, b, level, rseed, cap}) memainkan satu ronde (a = bot, b = CPU) dan mengembalikan hasilnya, play({a, b, n, level, seed0, cap}) memainkan n ronde.
|
||||
// Tidak mengubah game: hanya memakai API uji window.__game (startMatch, press, release, advance, snapshot, hero, dummy, projectiles, config, balance).
|
||||
// Sisi pemain (hero) dikendalikan bot ini, sisi lawan (dummy) dikendalikan CPU bawaan game pada tingkat yang diminta.
|
||||
// Bot sengaja "manusia biasa": bereaksi setelah ~0,17 dtk, tidak selalu memblok, tidak memakai lempar/jurus perintah/jurus toko, dan buta terhadap genangan api/asam.
|
||||
export function installBalance() {
|
||||
const g = window.__game, h = g.hero, d = g.dummy, FI = window.FIGHTERS || {}, BW = window.Browo;
|
||||
const TICK = 1 / 30, REACH = g.config.reachScale || 1;
|
||||
const sign = x => (x > 0) - (x < 0);
|
||||
let seed = 1;
|
||||
const rnd = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
||||
// Penyetelan bot. react = jeda membaca gerakan lawan; pBlock/pParry = peluang bertahan terhadap ancaman yang terbaca; pJump = peluang melompati proyektil rendah;
|
||||
// ultFallback/modeWait = ultimate dipakai sendiri bila lawan belum memakai ultimate-nya sampai detik itu (di mode pasca-ultimate: sekian detik sesudah masuk mode).
|
||||
const C = { react: .17, jitter: .08, pBlock: .5, pParry: .15, pJump: .3, pSkill: .9, ultFallback: 8, modeWait: 2, pHop: .002, runFrom: 380 };
|
||||
const bot = { C, t: 0, id: null, foe: null, run: { dir: 0, stage: 0 }, seen: new WeakMap(), blockFrom: 0, blockUntil: 0, jumpAt: -1, stats: {} };
|
||||
|
||||
// ---- data jurus: jangkauan efektif tiap skill (dibaca dari data karakter, jadi ikut berubah saat angka disetel) ----
|
||||
const comboOf = id => (FI[id] ? FI[id].combo : BW.combo);
|
||||
const reachOf = id => (FI[id] ? Math.round((FI[id].combo[0].reach || 105) * REACH) : BW.combo[0].reach) + 27; // jangkauan pukulan dasar yang sungguh mengenai
|
||||
function skillSpec(id, name, rage) {
|
||||
if (!FI[id]) { // Bro Wo: jurus buatan tangan (browo.js)
|
||||
if (rage) return name === 'skill1' ? { min: 160, max: 900, tau: .3 } : { min: 90, max: BW.fire.first + BW.fire.gap * (BW.fire.palms - 1), tau: .35 };
|
||||
return name === 'skill1' ? { min: BW.tree.ahead - BW.tree.radius + 14, max: BW.tree.ahead + BW.tree.radius - 14, tau: .34 } : { min: 0, max: BW.dance.radius - 30, tau: .1 };
|
||||
}
|
||||
const F = FI[id], k = (rage && F.mode?.skills?.[name]) || F.skills[name], dur = k.duration || .6, hit = k.hitAt ?? .5;
|
||||
if (['projectile', 'wave', 'homing', 'lob'].includes(k.kind)) return { min: 170, max: (k.range || 560) - 40, tau: dur * hit, speed: k.speed || 700 };
|
||||
if (k.kind === 'dash') return { min: 130, max: (k.reach || 120) + 27 + k.dash * dur * Math.max(0, hit - .2) - 15, tau: dur * hit };
|
||||
const a = k.ahead || 0, r = k.radius || 100; // area / pillar
|
||||
return { min: Math.max(0, a - r + 14), max: a + r - 14, tau: dur * hit };
|
||||
}
|
||||
|
||||
// ---- membaca keadaan ----
|
||||
// Apakah pukulan yang tiba tau detik lagi akan mengenai lawan (bukan jatuh/bangun, bukan masa kebal, masih dalam stun)?
|
||||
function landsAt(tau) {
|
||||
if (d.state === 'down' || d.state === 'recover') return false;
|
||||
if (d.state === 'hurt') return d.invuln < tau - .03 && tau < .5 - d.stateTime - .02;
|
||||
return d.invuln < tau - .03;
|
||||
}
|
||||
const inDanger = (a, dist) => {
|
||||
if (a.tree) return dist < BW.tree.ahead + BW.tree.radius + 20;
|
||||
if (a.dance || a.fire || a.truck || a.projectile) return false; // tidak bisa ditahan, atau proyektilnya dibaca nanti
|
||||
if (a.area) return dist < (a.ahead || 0) + (a.radius || 100) + 25;
|
||||
return dist < (a.reach || 140) + 27 + (a.dash ? a.dash * a.duration * Math.max(0, (a.hitAt ?? .5) - .2) : 0) + 20;
|
||||
};
|
||||
function defend(t, tHit, parry) { // jendela blok: parry = tekan L tepat sebelum kena (jendela parry 0,16 dtk), selain itu tekan segera
|
||||
const from = parry ? t + Math.max(0, tHit - (.03 + rnd() * .08)) : t;
|
||||
if (t >= bot.blockFrom && t < bot.blockUntil) { bot.blockUntil = Math.max(bot.blockUntil, t + tHit + .12); return; }
|
||||
bot.blockFrom = from; bot.blockUntil = t + tHit + .12;
|
||||
}
|
||||
function sense(t, dist) {
|
||||
const a = d.action;
|
||||
if (a && !a.fired && a.type !== 'ultimate' && inDanger(a, dist)) {
|
||||
let rec = bot.seen.get(a);
|
||||
if (!rec) bot.seen.set(a, rec = { start: t - a.t, react: C.react + rnd() * C.jitter, done: false });
|
||||
if (!rec.done && t >= rec.start + rec.react) {
|
||||
rec.done = true; const tHit = a.duration * (a.hitAt ?? .5) - a.t, r = rnd();
|
||||
if (tHit > .03) { if (r < C.pParry) defend(t, tHit, true); else if (r < C.pParry + C.pBlock) defend(t, tHit, false); }
|
||||
}
|
||||
}
|
||||
for (const p of g.projectiles) {
|
||||
if (p.owner !== 'enemy' || p.spent || p.wait > 0) continue;
|
||||
let rec = bot.seen.get(p);
|
||||
if (!rec) bot.seen.set(p, rec = { start: t, react: C.react + rnd() * C.jitter, done: false });
|
||||
if (rec.done || t < rec.start + rec.react) continue;
|
||||
rec.done = true;
|
||||
const vx = p.vx || 0, toward = vx && sign(vx) === sign(h.x - p.x), tti = vx ? (Math.abs(h.x - p.x) - 28) / Math.abs(vx) : 0, r = rnd();
|
||||
if (!vx) { if (dist < 240 && r < C.pBlock) defend(t, .5, false); continue; } // semut berputar/mengejar: tahan sebentar bila dekat
|
||||
if (!toward || tti <= .03) continue;
|
||||
if ((p.tall || 75) <= 95 && tti > .18 && tti < .44 && h.grounded && r < C.pJump) bot.jumpAt = t + Math.max(0, tti - .27);
|
||||
else if (r < C.pJump + C.pParry) defend(t, tti, true);
|
||||
else if (r < C.pJump + C.pParry + C.pBlock) defend(t, tti, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- masukan tombol ----
|
||||
function sync(dir, block) { for (const [k, on] of [['a', dir < 0], ['d', dir > 0], ['l', block]]) { if (on) g.press(k); else g.release(k); } }
|
||||
function tap(k) { g.press(k); g.release(k); }
|
||||
|
||||
// Skill yang siap dipakai (skill 2 lebih dulu, lalu skill 1) bila jaraknya cocok dan akan mengenai; mengembalikan tombolnya atau null. Ultimate diatur terpisah di think().
|
||||
function pickSkill(dist, side) {
|
||||
if (side !== h.facing) return null;
|
||||
const rage = !!h.bwRage;
|
||||
for (const [name, key] of [['skill2', 'o'], ['skill1', 'i']]) {
|
||||
if (h.cooldowns[name] > 0) continue;
|
||||
const s = skillSpec(bot.id, name, rage); if (!s) continue;
|
||||
const tau = s.tau + (s.speed ? Math.max(0, dist - 100) / s.speed : 0);
|
||||
if (dist >= s.min && dist <= s.max && landsAt(tau) && rnd() < C.pSkill) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function think() {
|
||||
const t = (bot.t += TICK);
|
||||
if (h.hp <= 0 || d.ko) { sync(0, false); return; }
|
||||
if (h.hurtTime > 0) { sync(0, false); bot.run.stage = 0; return; }
|
||||
const dx = d.x - h.x, dist = Math.abs(dx), side = sign(dx) || h.facing, reach = bot.reach, act = h.action;
|
||||
sense(t, dist);
|
||||
const defending = t >= bot.blockFrom && t < bot.blockUntil;
|
||||
// Ultimate menentukan hampir semua ronde (lawan terkunci beberapa detik), jadi bot menjawab ultimate lawan dengan ultimate sendiri dalam waktu reaksi
|
||||
// (seperti pemain yang menahan ultimate sebagai balasan). Bila lawan tak kunjung memakainya, bot memakainya sendiri setelah ultFallback detik.
|
||||
if (h.bwRage && !bot.rage) bot.rageAt = t; bot.rage = !!h.bwRage;
|
||||
let ultGo = false;
|
||||
if (d.action?.type === 'ultimate') { let rec = bot.seen.get(d.action); if (!rec) bot.seen.set(d.action, rec = { start: t - d.action.t, react: C.react + rnd() * C.jitter }); ultGo = t >= rec.start + rec.react; }
|
||||
else ultGo = bot.rage ? t - bot.rageAt > C.modeWait : t > C.ultFallback;
|
||||
if (ultGo && h.cooldowns.ultimate <= 0 && d.state !== 'down' && d.state !== 'recover' && (!act || act.type === 'attack')) { tap('p'); if (h.action?.type === 'ultimate') { bot.stats.ult = (bot.stats.ult || 0) + 1; sync(0, false); return; } }
|
||||
if (act) { // sedang menyerang: sambung kombo bila pukulan berikutnya akan mengenai, atau akhiri dengan skill
|
||||
if (act.type === 'attack' && act.fired && !act.special && d.state === 'hurt') {
|
||||
const key = pickSkill(dist, side);
|
||||
if (key) { tap(key); if (h.action?.type === 'skill1' || h.action?.type === 'skill2') bot.stats[h.action.type] = (bot.stats[h.action.type] || 0) + 1; }
|
||||
else if (act.index < 3 && !act.queued && !defending) { const n = comboOf(bot.id)[act.index], tau = act.duration - act.t + n.duration * (n.hitAt || .5); if (dist < reach - 8 && landsAt(tau)) tap(' '); }
|
||||
}
|
||||
sync(0, false); return;
|
||||
}
|
||||
// lompat terjadwal (menghindari proyektil rendah) dan lompat kadang-kadang
|
||||
if (bot.jumpAt >= 0 && t >= bot.jumpAt) { bot.jumpAt = -1; if (h.grounded) { tap('w'); bot.stats.jump = (bot.stats.jump || 0) + 1; } }
|
||||
else if (h.grounded && dist > 260 && !defending && rnd() < C.pHop) tap('w');
|
||||
if (defending) { sync(0, h.grounded); bot.run.stage = 0; return; }
|
||||
const key = pickSkill(dist, side);
|
||||
if (key) { tap(key); if (h.action?.type === 'skill1' || h.action?.type === 'skill2') { bot.stats[h.action.type] = (bot.stats[h.action.type] || 0) + 1; sync(0, false); return; } }
|
||||
const flat = Math.abs(d.y - h.y) < 100;
|
||||
if (dist < reach - 10 && side === h.facing && flat && landsAt(comboOf(bot.id)[0].duration * (comboOf(bot.id)[0].hitAt || .5))) { tap(' '); bot.stats.basic = (bot.stats.basic || 0) + 1; sync(0, false); return; }
|
||||
// gerak: lawan sedang kebal -> jaga jarak di luar jangkauannya sampai kebalnya hampir habis; selain itu dekati
|
||||
let want = 0;
|
||||
const foeReach = reachOf(bot.foe), shielded = (d.invuln > .12 && d.state !== 'hurt') || d.state === 'down' || d.state === 'recover';
|
||||
const closeIn = Math.max(0, dist - (reach - 14)) / 320 + .06;
|
||||
if (shielded && Math.max(d.invuln, d.state === 'recover' ? .4 : 0) > closeIn) { const keep = foeReach + 36; want = dist < keep - 8 && h.x > 130 && h.x < 1150 ? -side : dist > keep + 24 ? side : 0; }
|
||||
else if (dist > reach - 22) want = side;
|
||||
else if (side !== h.facing) want = side; // putar badan menghadap lawan
|
||||
// lari (ketuk dua kali) bila jauh: lepas, tekan, lepas, tekan (dalam jendela 0,26 dtk), lalu tahan
|
||||
const R = bot.run;
|
||||
if (want !== 0 && want === side && dist > C.runFrom) {
|
||||
if (R.dir !== want) { R.dir = want; R.stage = 0; }
|
||||
if (R.stage === 4 && h.runDirection !== want) R.stage = 0;
|
||||
if (R.stage === 0 || R.stage === 2) { sync(0, false); R.stage++; return; }
|
||||
sync(want, false); if (R.stage < 4) R.stage++; return;
|
||||
}
|
||||
R.dir = 0; R.stage = 0;
|
||||
sync(want, false);
|
||||
}
|
||||
|
||||
function reset(id, foe, s) {
|
||||
bot.id = id; bot.foe = foe; bot.t = 0; bot.reach = reachOf(id); bot.blockFrom = bot.blockUntil = 0; bot.jumpAt = -1; bot.run = { dir: 0, stage: 0 }; bot.seen = new WeakMap(); bot.stats = {}; bot.rage = false; bot.rageAt = 0;
|
||||
seed = (s >>> 0) || 1; sync(0, false);
|
||||
}
|
||||
|
||||
// Satu ronde: startMatch -> lewati intro -> bot bermain sampai fase K.O. Hasil dari sisi pemain (hero = bot) dan sisi CPU.
|
||||
function round({ a, b, level, rseed, cap }) {
|
||||
if (!g.startMatch({ player: a, enemy: b, stage: 'bellora', level, mode: 'versus', styles: { player: 'seimbang', enemy: 'seimbang' } })) return { error: `startMatch gagal: ${a} vs ${b}` };
|
||||
reset(a, b, rseed);
|
||||
let m = null, guard = 0;
|
||||
while (guard++ < 400) { g.advance(.05, true); m = g.snapshot().match; if (m.phase !== 'intro') break; }
|
||||
let ticks = 0, phase = m.phase, uh = -1, uc = -1, hs = 0, cs = 0; // hs/cs: bit 1 = skill 1, 2 = skill 2, 4 = ultimate pernah dipakai (sisi bot / sisi CPU)
|
||||
while (phase === 'fight' && ticks < cap) {
|
||||
think(); g.advance(TICK, true); ticks++;
|
||||
hs |= (h.cooldowns.skill1 > 0) | ((h.cooldowns.skill2 > 0) << 1) | ((h.cooldowns.ultimate > 0) << 2); cs |= (d.cooldowns.skill1 > 0) | ((d.cooldowns.skill2 > 0) << 1) | ((d.cooldowns.ultimate > 0) << 2);
|
||||
if (uh < 0 && h.cooldowns.ultimate > 0) uh = ticks * TICK; // detik (waktu bot) saat ultimate pertama tiap sisi dipakai
|
||||
if (uc < 0 && d.cooldowns.ultimate > 0) uc = ticks * TICK;
|
||||
if (h.hp <= 0 || d.ko || ticks % 10 === 0) phase = g.snapshot().match.phase;
|
||||
}
|
||||
sync(0, false);
|
||||
m = g.snapshot().match;
|
||||
const eHp = Math.max(0, g.balance.dummyMax - d.damage), hHp = Math.max(0, h.hp), end = m.phase === 'ko';
|
||||
return { w: !end ? 'S' : m.lastWinner === 'player' ? 'H' : m.lastWinner === 'enemy' ? 'C' : 'D', hh: Math.round(hHp), ch: Math.round(eHp), t: +(90 - m.seconds).toFixed(2), to: end && hHp > 0 && eHp > 0, uh: +uh.toFixed(1), uc: +uc.toFixed(1), hs, cs, nan: ![h.x, d.x, h.hp, d.damage].every(Number.isFinite), st: { ...bot.stats } };
|
||||
}
|
||||
// Banyak ronde untuk satu pasangan arah (a = bot/pemain, b = CPU). seed0 tetap -> hasil sama bila urutan sama.
|
||||
function play({ a, b, n, level, seed0, cap }) {
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) out.push(round({ a, b, level, rseed: (seed0 + i * 7919 + a.length * 131 + b.length * 17) >>> 0, cap: cap || 6000 }));
|
||||
return out;
|
||||
}
|
||||
g.manual = true; // hentikan loop waktu nyata supaya simulasi hanya maju lewat advance()
|
||||
window.BalSim = { bot, play, round, think, reset, TICK, skillSpec, reachOf };
|
||||
return true;
|
||||
}
|
||||
|
|
@ -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');
|
||||
};
|
||||
Loading…
Reference in New Issue