177 lines
15 KiB
JavaScript
177 lines
15 KiB
JavaScript
// 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');
|
||
};
|