From 3839869adb8568ec1f2195843a43b9facccf0fc9 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Mon, 5 Oct 2026 14:12:57 +0700 Subject: [PATCH] Simulator keseimbangan (RENCANA 5.1): bot pemain sederhana untuk sisi hero (jarak, kombo yang pasti kena, skill menurut jangkauan data, blok/parry/lompat setelah jeda reaksi, ultimate sebagai balasan), simulator 7 x 7 di kedua posisi tanpa render (BAL_N, BAL_WORKERS, BAL_ONLY, BAL_BOT, BAL_BASE, BAL_JSON), laporan Markdown dan hasil ringkas sebelum penyetelan --- docs/balance-before.json | 135 ++++++++++++++++++++++++++++++ tests/balance.mjs | 176 +++++++++++++++++++++++++++++++++++++++ tests/bot.mjs | 175 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 docs/balance-before.json create mode 100644 tests/balance.mjs create mode 100644 tests/bot.mjs diff --git a/docs/balance-before.json b/docs/balance-before.json new file mode 100644 index 0000000..00bda66 --- /dev/null +++ b/docs/balance-before.json @@ -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 + } + } +} diff --git a/tests/balance.mjs b/tests/balance.mjs new file mode 100644 index 0000000..dc1924b --- /dev/null +++ b/tests/balance.mjs @@ -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 = ''; +// 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: 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'); +}; diff --git a/tests/bot.mjs b/tests/bot.mjs new file mode 100644 index 0000000..7f56802 --- /dev/null +++ b/tests/bot.mjs @@ -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; +}