Files
andrey 1ebaf6e2ae Этап 53: ребаланс прогрессии и экономики + масштабируемые квесты. (1) Цены снастей: RODS 0/875/4500/15000 → 0/150/500/30000; LURES 0/625/3000/11250 → 0/100/2000/22000 (Воблер 300→2000 по правке владельца). Апгрейды id 0: ROD_UPG_COST 70/210/500 → 20/50/100, LURE_UPG_COST 60/160/400 → 15/40/80; UPG_MULT 0.2/0.6/1.2 → 0.15/0.35/0.6. Гейт «предыдущая снасть на 4 ур.» оставлен, но апгрейды сильно удешевлены. (2) Цены локаций: Озёра 65536→500, Болото 90000→10000, Море 131072→100000, Океан 196608→300000; порядок по цене Река<Озёра<Болото<Море<Океан сохранён. (3) Веса Болота снижены до уровня Озёр и ниже (цель «тащить легче, доход меньше»): SWAMP_DAY_WEIGHTS={glowfin:4,moonfish:32,emberfin:3,violetlure:6,spiritfin:7}, SWAMP_NIGHT_WEIGHTS={glowfin:4,moonfish:28,emberfin:3,violetlure:10,spiritfin:3}; per-catch E 240.3/328.9 → 133.9/162.5 (доля дешёвой лёгкой moonfish поднята, дорогая violetlure/emberfin срезана); по 🪙/мин Болото ≈ Озёра. Инвариант balance.mjs «дороже вода доходнее» для Болота сознательно не выполняется. (4) Квесты дня масштабируются по save.earned: questScale()=clamp(round((earned/2000)^0.6),1,30); QUEST_TPLS масштабирует ВСЕ шаблоны (earn_coins goal×scale, casts ×(1+0.15·scale), catch_weight порог от localMedianW() — медиана середин весов рыб ТЕКУЩЕЙ локации ×scale^0.35, catch_rarity r=clamp(1+floor(1.5·log10(scale+1)),1,4), catch_species вид из SPECIES_BY_LOCATION[save.location] с rarity≤2), qReward(base)=round(base·scale^0.6); questText RU/EN терпим к неречным id (fallback trName). (5) Новый tools/progression.mjs (read-only, данные из index.html): моделирует улов (pickSpecies+rollWeight+варианты+обрыв по maxW) и цикл ловли (biteChance/таймеры/fishForce+updateReel ботом), жадный маршрут покупок → 🪙/мин по водам и ETA целей; what-if --rod/--lure/--loc/--rod-upg/--lure-upg/--upg-mult/--set/--set-day/--set-night. Калибровочные цели: Стекло 4.4 мин · Озёра 10.3 (5.9 после Стекло) · Карбон 6.9 · Болото 22.9 (13.3 после Карбона) · Титан ~50 мин · Море 2 ч 17 · Океан 4 ч 04. (6) Тесты 233→234: обновлены пины M5 (150/100, апгрейды 20/50/100 и 15/40/80, upgCost-строки), lc1 (costs 0,500,100000,300000,10000), lc8 (покупки локаций), ранний магазин; новый §9e2 — questScale/localMedianW/рост целей. Прогон: 234/234 PASS, exit 0; check.mjs OK; balance.mjs — Болото 133.9/162.5, остальные пины (Река/Озёра/Море/Океан) не сдвинуты. (7) Доки: AGENTS.md (Баланс/Локации/Баланс Озёр/Баланс-скрипт + пункт «Прогрессия»/Награды + тест-глобалы), README (локации/цены/прогрессия), docs/PLAN-PROGRESSION.md (новый), DEVLOG (Этап 53).
2026-10-05 15:42:49 +03:00

251 lines
17 KiB
JavaScript
Raw Permalink 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.
#!/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 · Болото 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<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) на цену улова не влияет; дождь учтён строкой «при дожде».');