fighting-konoha/tests/balance.mjs

177 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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