248 lines
17 KiB
JavaScript
248 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/* balance.mjs — доходность рыб по локациям (вспомогательный скрипт балансировки).
|
||
Считает E[выручку за улов] = Σ(w·V)/Σw (V = середина диапазона веса × цена) по всем
|
||
10 таблицам ролла (Река/Озёра/Море/Океан/Болото × день/вечер-ночь) — та же модель, что
|
||
pickSpecies() в index.html, плюс сводку отношений локаций и «здоровье» (риск
|
||
теста «каждый вид ≥1»).
|
||
Данные НЕ дублируются: фрагмент секции «ДАННЫЕ» вырезается из index.html по якорям
|
||
(const SPECIES=[ … закрытие таблицы VARIANTS) и исполняется в изоляции — правки баланса
|
||
в игре скрипт видит сразу; при переименовании якорей падает с понятной ошибкой.
|
||
|
||
Запуск: node tools/balance.mjs [опции]
|
||
Опции:
|
||
--dist <0..1> дистанция заброса: при dist<minDist вес ×0.06 (по умолчанию 1 — как пины)
|
||
--lure <rare> редкость приманки: вес ×(1+rare·rarity) (по умолчанию 0 — нейтрально)
|
||
--set id:вес what-if: переопределить вес вида во ВСЕХ его таблицах (можно несколько
|
||
раз; НЕ сохраняется — финальные числа переносить в index.html руками)
|
||
--price id:цена what-if: переопределить цену вида (🪙/кг; можно несколько раз; НЕ сохраняется)
|
||
--help справка
|
||
Примеры:
|
||
node tools/balance.mjs
|
||
node tools/balance.mjs --set bleak:5 --set zander:30
|
||
node tools/balance.mjs --dist 0.7 --lure 2
|
||
node tools/balance.mjs --price zander:8 --price tuna:46
|
||
|
||
Чистый Node, без зависимостей (стиль tools/check.mjs). Пины при нейтральной снасти:
|
||
Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 ·
|
||
Океан 329.1/552.7 (день/ночь, 🪙 за улов; этапы 38/39/40 — лестница весов + урезанные
|
||
цены + ночные веса; болото — этап 45) — скрипт обязан воспроизводить их до и после
|
||
любых правок баланса. Пары отношений в сводке строятся ПО ЦЕНЕ локации
|
||
(Река→Озёра→Болото→Море→Океан), а не по порядку массива LOCATIONS. */
|
||
import { readFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const htmlPath = join(root, 'index.html');
|
||
|
||
function fail(msg) { console.error('balance: ОШИБКА — ' + msg); process.exit(1); }
|
||
|
||
/* ---------- 1. Данные из index.html (single source of truth) ---------- */
|
||
let html;
|
||
try { html = readFileSync(htmlPath, 'utf8'); }
|
||
catch (e) { fail(`не удалось прочитать ${htmlPath}: ${e.message}`); }
|
||
|
||
const a = html.indexOf('const SPECIES=[');
|
||
if (a < 0) fail('в index.html не найден якорь «const SPECIES=[» — секцию ДАННЫЕ переименовали, обнови tools/balance.mjs');
|
||
const vStart = html.indexOf('const VARIANTS=[', a);
|
||
if (vStart < 0) fail('в index.html не найден якорь «const VARIANTS=[»');
|
||
const b = html.indexOf('\n];', vStart); // закрывающая «];» VARIANTS — конец фрагмента данных
|
||
if (b < 0) fail('не найдено закрытие «];» таблицы VARIANTS');
|
||
|
||
/* Между якорями — только литералы и map/filter (SPECIES, NIGHT_WEIGHTS, локации,
|
||
*_IDS/*_SPECIES*, SHINY_CHANCE, VARIANTS) — исполняется без DOM. */
|
||
const D = new Function(html.slice(a, b + 3) +
|
||
'\n;return {SPECIES, SPECIES_NIGHT, RIVER_SPECIES, RIVER_SPECIES_NIGHT,' +
|
||
'LAKE_SPECIES, LAKE_SPECIES_NIGHT, SEA_SPECIES, SEA_SPECIES_NIGHT,' +
|
||
'OCEAN_SPECIES, OCEAN_SPECIES_NIGHT, SWAMP_SPECIES, SWAMP_SPECIES_NIGHT,' +
|
||
'LOCATIONS, VARIANTS, SHINY_CHANCE};')();
|
||
|
||
/* ---------- 2. Опции CLI ---------- */
|
||
const argv = process.argv.slice(2);
|
||
let dist = 1, lureRare = 0;
|
||
const sets = {}; // what-if: id → вес
|
||
const prices = {}; // what-if: id → цена (🪙/кг)
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (arg === '--help' || arg === '-h') {
|
||
console.log(
|
||
`balance.mjs — доходность рыб по локациям: E[🪙 за улов] = Σ(w·V)/Σw по 10 таблицам
|
||
(Река/Озёра/Болото/Море/Океан × день/вечер-ночь), сводка отношений и «здоровье» роллов.
|
||
Данные берутся из index.html (секция «ДАННЫЕ») — правки баланса видны сразу.
|
||
Опции:
|
||
--dist <0..1> дистанция заброса: при dist<minDist вес ×0.06 (по умолчанию 1)
|
||
--lure <rare> редкость приманки: вес ×(1+rare·rarity) (по умолчанию 0)
|
||
--set id:вес what-if, несколько раз: вес вида во всех его таблицах (не сохраняется)
|
||
--price id:цена what-if, несколько раз: цена вида 🪙/кг (не сохраняется)
|
||
--help эта справка
|
||
Примеры:
|
||
node tools/balance.mjs
|
||
node tools/balance.mjs --set bleak:5 --set zander:30
|
||
node tools/balance.mjs --price zander:8 --price tuna:46`);
|
||
process.exit(0);
|
||
} else if (arg === '--dist') {
|
||
dist = Number(argv[++i]);
|
||
if (!(dist >= 0 && dist <= 1)) fail('--dist ожидает число 0..1');
|
||
} else if (arg === '--lure') {
|
||
lureRare = Number(argv[++i]);
|
||
if (!(lureRare >= 0)) fail('--lure ожидает неотрицательное число (rare-стат приманки)');
|
||
} else if (arg === '--set') {
|
||
const m = String(argv[++i] || '').split(':');
|
||
if (m.length !== 2 || !m[0].trim() || !(Number(m[1]) >= 0))
|
||
fail('--set ожидает «id:вес», например --set bleak:5');
|
||
sets[m[0].trim()] = Number(m[1]);
|
||
} else if (arg === '--price') {
|
||
const m = String(argv[++i] || '').split(':');
|
||
if (m.length !== 2 || !m[0].trim() || !(Number(m[1]) >= 0))
|
||
fail('--price ожидает «id:цена», например --price zander:8');
|
||
prices[m[0].trim()] = Number(m[1]);
|
||
} else fail(`неизвестный аргумент «${arg}» (см. node tools/balance.mjs --help)`);
|
||
}
|
||
|
||
/* ---------- 3. Таблицы локаций (та же развилка, что в pickSpecies) ---------- */
|
||
const TABLES = [
|
||
{ loc: 0, phase: 'день', tbl: D.RIVER_SPECIES },
|
||
{ loc: 0, phase: 'вечер/ночь', tbl: D.RIVER_SPECIES_NIGHT },
|
||
{ loc: 1, phase: 'день', tbl: D.LAKE_SPECIES },
|
||
{ loc: 1, phase: 'вечер/ночь', tbl: D.LAKE_SPECIES_NIGHT },
|
||
{ loc: 2, phase: 'день', tbl: D.SEA_SPECIES },
|
||
{ loc: 2, phase: 'вечер/ночь', tbl: D.SEA_SPECIES_NIGHT },
|
||
{ loc: 3, phase: 'день', tbl: D.OCEAN_SPECIES },
|
||
{ loc: 3, phase: 'вечер/ночь', tbl: D.OCEAN_SPECIES_NIGHT },
|
||
{ loc: 4, phase: 'день', tbl: D.SWAMP_SPECIES },
|
||
{ loc: 4, phase: 'вечер/ночь', tbl: D.SWAMP_SPECIES_NIGHT },
|
||
];
|
||
const ROLLS = 3000; // объём теста «каждый вид ≥1» (lc4)
|
||
const SAFE_P0 = 0.001; // порог риска: P(вид ни разу не выпал за 3000 роллов) выше — ⚠
|
||
|
||
// неизвестный id в --set — сразу ошибка со списком доступных
|
||
const knownIds = new Set();
|
||
for (const t of TABLES) for (const s of t.tbl) knownIds.add(s.id);
|
||
for (const id of Object.keys(sets))
|
||
if (!knownIds.has(id)) fail(`--set: вид «${id}» не найден ни в одной таблице (известные: ${[...knownIds].join(', ')})`);
|
||
for (const id of Object.keys(prices))
|
||
if (!knownIds.has(id)) fail(`--price: вид «${id}» не найден ни в одной таблице (известные: ${[...knownIds].join(', ')})`);
|
||
|
||
/* ---------- 4. Расчёт ---------- */
|
||
function calcTable(tbl) {
|
||
let sumWBase = 0, sumW = 0;
|
||
const rows = tbl.map(s => {
|
||
const wBase = sets[s.id] !== undefined ? sets[s.id] : s.weight; // what-if поверх данных
|
||
let w = wBase;
|
||
if (dist < s.minDist) w *= 0.06; // дальняя рыба недоплюнута — как в pickSpecies()
|
||
w *= 1 + lureRare * s.rarity; // буст приманки — как в pickSpecies()
|
||
const price = prices[s.id] !== undefined ? prices[s.id] : s.price; // what-if поверх данных
|
||
const V = ((s.wMin + s.wMax) / 2) * price;
|
||
sumWBase += wBase; sumW += w;
|
||
return { s, wBase, w, V };
|
||
});
|
||
const total = sumW > 0 ? rows.reduce((acc, x) => acc + x.w * x.V, 0) / sumW : NaN;
|
||
for (const x of rows) {
|
||
x.share = sumW > 0 ? x.w / sumW : 0;
|
||
x.contrib = sumW > 0 ? x.w * x.V / sumW : 0;
|
||
}
|
||
return { rows, total, sumW, sumWBase };
|
||
}
|
||
|
||
/* E[mult] вариантов рыб — точная модель последовательного ролла rollVariant():
|
||
P(v) = c_v · Π(1−c_пред), c_gold = SHINY_CHANCE; rainMult — множитель шансов
|
||
особых при дожде (WEATHER.rain.variantMult=3 в игре), boost — буст снасти (нейтрально 0) */
|
||
function variantE(rainMult, boost) {
|
||
let survive = 1, e = 0, pSpec = 0;
|
||
for (const v of D.VARIANTS) {
|
||
if (v.id === 'normal') continue;
|
||
let c = v.id === 'gold' ? D.SHINY_CHANCE : v.chance;
|
||
c *= rainMult;
|
||
c *= 1 + boost * D.VARIANTS.indexOf(v);
|
||
c = Math.min(1, c);
|
||
const p = survive * c;
|
||
e += p * v.mult;
|
||
pSpec += p;
|
||
survive *= 1 - c;
|
||
}
|
||
return { e: e + survive, p: pSpec }; // e = ΣP·mult + P(normal)·1
|
||
}
|
||
|
||
const f1 = n => Number.isFinite(n) ? n.toFixed(1) : '—';
|
||
const pc = n => (n * 100).toFixed(1) + '%';
|
||
const xr = n => '×' + (Number.isFinite(n) ? n.toFixed(2) : '—');
|
||
const locName = id => (D.LOCATIONS[id] || {}).name || ('#' + id);
|
||
const dash = s => '─'.repeat(Math.max(2, 62 - s.length));
|
||
|
||
/* ---------- 5. Вывод ---------- */
|
||
const results = TABLES.map(t => ({ ...t, calc: calcTable(t.tbl) }));
|
||
|
||
const vn = variantE(1, 0), vr = variantE(3, 0);
|
||
console.log('Баланс рыб по локациям — node tools/balance.mjs');
|
||
console.log('Формула: E[🪙 за улов] = Σ(w·V)/Σw, V = середина веса × цена (модель pickSpecies)');
|
||
console.log(`параметры: dist=${dist} (${dist >= 1 ? 'штраф minDist не действует' : 'виды с minDist>' + dist + ' весят ×0.06'}) · lure rare=${lureRare} (${lureRare ? 'буст веса 1+rare·rarity' : 'нейтрально'})`);
|
||
console.log(`варианты рыб: E[mult]≈${vn.e.toFixed(2)} (особая ${pc(vn.p)}; нейтральная снасть, одинакова для всех локаций) · при дожде ≈${vr.e.toFixed(2)} (${pc(vr.p)})`);
|
||
if (Object.keys(sets).length || Object.keys(prices).length)
|
||
console.log('WHAT-IF (НЕ сохранено — переносить в index.html руками): ' +
|
||
[...Object.entries(sets).map(([k, v]) => k + ' вес=' + v),
|
||
...Object.entries(prices).map(([k, v]) => k + ' цена=' + v)].join(', '));
|
||
if (D.LOCATIONS.length !== TABLES.length / 2)
|
||
console.log(`⚠ в LOCATIONS ${D.LOCATIONS.length} локаций, а в TABLES ${TABLES.length / 2} — доведи TABLES до состава LOCATIONS`);
|
||
console.log('');
|
||
|
||
for (const r of results) {
|
||
const title = `${locName(r.loc)} · ${r.phase}`;
|
||
console.log(`── ${title} ${dash('── ' + title)}`);
|
||
const rows = r.calc.rows;
|
||
const nameW = Math.max(...rows.map(x => (x.s.name + ' (' + x.s.id + ')').length));
|
||
console.log('вид'.padEnd(nameW + 2) + 'вес'.padStart(7) + 'доля'.padStart(8) + 'V 🪙'.padStart(9) + 'вклад'.padStart(9));
|
||
for (const x of rows)
|
||
console.log((x.s.name + ' (' + x.s.id + ')').padEnd(nameW + 2) + f1(x.w).padStart(7) + pc(x.share).padStart(8) + f1(x.V).padStart(9) + f1(x.contrib).padStart(9));
|
||
console.log(`Σw=${f1(r.calc.sumW)} · Итого: ${f1(r.calc.total)} 🪙 за улов (варианты ×${vn.e.toFixed(2)} → ${f1(r.calc.total * vn.e)})`);
|
||
console.log('');
|
||
}
|
||
|
||
console.log('── СВОДКА ' + dash('── СВОДКА '));
|
||
console.log('локация'.padEnd(12) + 'день'.padStart(9) + 'вечер/ночь'.padStart(13) + 'ночь/день'.padStart(11));
|
||
const by = {};
|
||
for (const r of results) (by[r.loc] ||= {})[r.phase] = r.calc.total;
|
||
// локации по ЦЕНЕ (дороже вода = доходнее): пары отношений читаются по возрастанию цены,
|
||
// а не по порядку массива LOCATIONS (90000 болото стоит перед Морем/Океаном по цене)
|
||
const locsByCost = [...D.LOCATIONS].sort((x, y) => x.cost - y.cost);
|
||
for (const loc of locsByCost) {
|
||
const d = by[loc.id]['день'], n = by[loc.id]['вечер/ночь'];
|
||
console.log(loc.name.padEnd(12) + f1(d).padStart(9) + f1(n).padStart(13) + xr(n / d).padStart(11));
|
||
}
|
||
console.log('');
|
||
// отношения соседних вод: инвариант — каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы
|
||
for (let i = 1; i < locsByCost.length; i++) {
|
||
const prev = locsByCost[i - 1], cur = locsByCost[i];
|
||
console.log(`${cur.name}/${prev.name}: день ${xr(by[cur.id]['день'] / by[prev.id]['день'])} · ночь ${xr(by[cur.id]['вечер/ночь'] / by[prev.id]['вечер/ночь'])}`);
|
||
}
|
||
console.log('');
|
||
|
||
/* Здоровье: тест «каждый вид ≥1» (lc4) гоняет 3000 роллов с нейтральной снастью и без
|
||
штрафа minDist — считаем по базовым весам (what-if учтён, dist/lure — нет). */
|
||
console.log('── ЗДОРОВЬЕ (тест «каждый вид ≥1», 3000 роллов) ' + dash('── ЗДОРОВЬЕ (тест «каждый вид ≥1», 3000 роллов) '));
|
||
for (const r of results) {
|
||
const rarest = r.calc.rows.reduce((m, x) => x.wBase < m.wBase ? x : m, r.calc.rows[0]);
|
||
const hits = ROLLS * rarest.wBase / r.calc.sumWBase;
|
||
const p0 = Math.pow(1 - rarest.wBase / r.calc.sumWBase, ROLLS);
|
||
const flag = p0 > SAFE_P0 ? `⚠ P(промаха)=${(p0 * 100).toFixed(2)}% — тест может плавать, подними вес` : 'ок';
|
||
console.log(`${(locName(r.loc) + ' · ' + r.phase).padEnd(18)} мин ${f1(hits).padStart(6)} попаданий (${rarest.s.id}) — ${flag}`);
|
||
}
|
||
|
||
/* Прочие проверки целостности (ловят ошибки баланса до плейтеста) */
|
||
const nLoc = TABLES.length / 2; // число локаций (день — чётные индексы TABLES)
|
||
const idsDay = Array.from({ length: nLoc }, (_, i) => new Set(TABLES[i * 2].tbl.map(s => s.id)));
|
||
const inter = [];
|
||
for (let i = 0; i < nLoc; i++)
|
||
for (const id of idsDay[i])
|
||
for (let j = i + 1; j < nLoc; j++)
|
||
if (idsDay[j].has(id)) inter.push(`${id} (${locName(i)}+${locName(j)})`);
|
||
const badWeights = TABLES.reduce((n, t) => n + t.tbl.filter(s => typeof s.weight !== 'number' || !(s.weight >= 0)).length, 0);
|
||
const unreachable = D.SPECIES.filter(s => !knownIds.has(s.id)).map(s => s.id);
|
||
|
||
console.log('');
|
||
console.log(inter.length ? `⚠ пересечения таблиц локаций: ${inter.join(', ')}` : '✓ таблицы локаций без пересечений');
|
||
console.log(badWeights ? `⚠ нечисловых/отрицательных весов: ${badWeights}` : `✓ веса всех ${knownIds.size * 2} записей (${TABLES.length} таблиц) — числа`);
|
||
console.log(unreachable.length ? `⚠ виды вне роллов локаций: ${unreachable.join(', ')}` : `✓ все ${D.SPECIES.length} видов достигаемы в роллах`);
|
||
console.log('');
|
||
console.log('боссы локаций (Река/Озёра/Море/Океан/Болото) клюют вне обычного ролла (награда ×5) — в доходность не входят.');
|
||
console.log('туман (клёв ×1.5) на цену улова не влияет; дождь учтён строкой «при дожде».');
|