#!/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 редкость приманки: вес ×(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 · Болото 133.9/162.5 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь, 🪙 за улов; этапы 38/39/40 — лестница весов + урезанные цены + ночные веса; болото — этап 45, пересмотрено этапом 53) — скрипт обязан воспроизводить их до и после любых правок баланса. Этап 53: Болото СОЗНАТЕЛЬНО ниже Озёр по паре «per-catch» (доля дешёвой лёгкой moonfish поднята) — инвариант «дороже вода доходнее» для Болота не выполняется; по 🪙/мин (tools/progression.mjs) Болото ≈ Озёра за счёт более лёгкой/быстрой рыбы. Пары отношений в сводке строятся ПО ЦЕНЕ локации (Река→Озёра→Болото→Море→Океан), а не по порядку массива 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 редкость приманки: вес ×(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) на цену улова не влияет; дождь учтён строкой «при дожде».');