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

420 lines
22 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
/* progression.mjs — модель прогрессии/экономики игры (вспомогательный инструмент балансировки).
Отвечает на вопрос «за сколько минут игрок дойдёт до цели»: берёт цены и статы снастей
ПРЯМО из index.html (ничего не дублируется), моделирует улов и цикл ловли теми же
формулами, что игра, и прогоняет жадный маршрут покупок, печатая ETA каждой цели.
Что моделируется (ссылки — на index.html):
- pickSpecies() (:2946): выбор таблицы по локации и фазе (evening+night → ночная),
веса ×(1+lure.rare·rarity), штраф дальних видов ×0.06;
- rollWeight() (:2982): s=u^(2/(1+2B)), B=rod.size+lure.size — статы снасти двигают вес;
- обрыв по maxW (:3011/:3087): если вес рыбы > предела удочки, шанс улова резко падает
(это учитывает симулятор боя);
- варианты рыб (E[mult], variantBoost :2185/:2188) — буст редкой приманки;
- цикл: cast 0.65с + ожидание клёва biteChance()/таймеры (:2974/:3178) + approach 0.45с
+ бой (fishForce/updateReel :3021/:3043, бот «держать в затишье / отпускать в рывке»)
+ карточка 1.2с; пустые клёвы учтены геометрически;
- сутки: 4 равные фазы (утро/день — дневная таблица, вечер — ночная, ночь — ночная
и клёв ×0.85), день/ночь 50/50.
Запуск: node tools/progression.mjs [опции]
Опции:
--start-coins N стартовые монеты (по умолчанию 0)
--per-day MIN минут игры в день — печатает ETA ещё и в днях (по умолчанию 15)
--route "..." маршрут покупок (см. ниже); по умолчанию — целевой из плана
--samples N выборка MC на вид для оценки боя (по умолчанию 80)
--rod i:cost what-if: цена удочки i (можно несколько раз)
--lure i:cost what-if: цена приманки i
--loc i:cost what-if: цена локации i
--rod-upg a,b,c what-if: ROD_UPG_COST
--lure-upg a,b,c what-if: LURE_UPG_COST
--upg-mult a,b,c what-if: UPG_MULT
--set id:вес what-if: вес вида во ВСЕХ таблицах (день и ночь)
--set-day id:вес what-if: только дневная таблица (SPECIES_BY_LOCATION)
--set-night id:вес what-if: только ночная таблица (SPECIES_BY_LOCATION_NIGHT)
--help
Синтаксис маршрута (через запятую):
rodN — купить удочку N (нужна удочка N−1 на 4 ур.)
rodN:lvl — поднять удочку N до уровня lvl (1..4)
lureN / lureN:lvl, locN — то же для приманок и локаций.
Пример: "rod0:4,lure0:4,rod1,lure1,loc1,rod1:4,lure1:4,rod2,lure2,loc4,rod2:4,lure2:4,rod3,lure3,loc2,loc3"
Данные НЕ дублируются: два фрагмента index.html вырезаются по якорям и исполняются
в изоляции (правки цен/статов видны сразу). Переименование якоря → понятная ошибка. */
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('progression: ОШИБКА — ' + msg); process.exit(1); }
/* ---------- 1. Данные из index.html (single source of truth) ---------- */
let html;
try { html = readFileSync(htmlPath, 'utf8'); }
catch (e) { fail(`не удалось прочитать ${htmlPath}: ${e.message}`); }
// блок 1: SPECIES … VARIANTS (тот же фрагмент, что в balance.mjs)
const a1 = html.indexOf('const SPECIES=[');
if (a1 < 0) fail('в index.html не найден якорь «const SPECIES=[»');
const vStart = html.indexOf('const VARIANTS=[', a1);
if (vStart < 0) fail('в index.html не найден якорь «const VARIANTS=[»');
const b1 = html.indexOf('\n];', vStart);
if (b1 < 0) fail('не найдено закрытие «];» таблицы VARIANTS');
const D = new Function(html.slice(a1, b1 + 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,' +
'SPECIES_BY_LOCATION, SPECIES_BY_LOCATION_NIGHT, LOCATIONS, VARIANTS, SHINY_CHANCE};')();
// блок 2: RODS … UPG_MULT
const r1 = html.indexOf('const RODS=[');
if (r1 < 0) fail('в index.html не найден якорь «const RODS=[»');
const rEnd = html.indexOf('const ROD_UPG_NEXT=', r1);
if (rEnd < 0) fail('в index.html не найден якорь «const ROD_UPG_NEXT=»');
const R = new Function(html.slice(r1, rEnd) +
'\n;return {RODS, LURES, ROD_UPG_COST, LURE_UPG_COST, UPG_MULT};')();
const SPECIES = D.SPECIES;
/* ---------- 2. Опции CLI ---------- */
const argv = process.argv.slice(2);
let startCoins = 0, perDay = 15, samples = 80;
const rodCost = {}, lureCost = {}, locCost = {}, sets = {}, setsDay = {}, setsNight = {};
let rodUpg = null, lureUpg = null, upgMult = null;
let route = null;
const DEFAULT_ROUTE =
'rod0:4,lure0:4,rod1,lure1,rod1:4,lure1:4,rod2,lure2,loc1,loc4,rod2:4,lure2:4,rod3,lure3,loc2,loc3';
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); }
else if (arg === '--start-coins') { startCoins = Number(argv[++i]); if (!(startCoins >= 0)) fail('--start-coins ожидает число ≥ 0'); }
else if (arg === '--per-day') { perDay = Number(argv[++i]); if (!(perDay > 0)) fail('--per-day ожидает число > 0'); }
else if (arg === '--samples') { samples = Number(argv[++i]); if (!(samples > 0)) fail('--samples ожидает число > 0'); }
else if (arg === '--route') { route = String(argv[++i] || ''); }
else if (arg === '--rod') parseCost(rodCost, argv[++i], '--rod');
else if (arg === '--lure') parseCost(lureCost, argv[++i], '--lure');
else if (arg === '--loc') parseCost(locCost, argv[++i], '--loc');
else if (arg === '--rod-upg') rodUpg = parseList(argv[++i], '--rod-upg');
else if (arg === '--lure-upg') lureUpg = parseList(argv[++i], '--lure-upg');
else if (arg === '--upg-mult') upgMult = parseList(argv[++i], '--upg-mult');
else if (arg === '--set' || arg === '--set-day' || arg === '--set-night') {
const m = String(argv[++i] || '').split(':');
if (m.length !== 2 || !m[0].trim() || !(Number(m[1]) >= 0)) fail(`${arg} ожидает «id:вес»`);
(arg === '--set' ? sets : arg === '--set-day' ? setsDay : setsNight)[m[0].trim()] = Number(m[1]);
}
else fail(`неизвестный аргумент «${arg}» (см. node tools/progression.mjs --help)`);
}
if (!route) route = DEFAULT_ROUTE;
// what-if весов: применяем поверх таблиц локаций ДО расчётов
function applySets(map, tables, opt) {
for (const id of Object.keys(map)) {
let found = false;
for (const tbl of tables) for (const arr of tbl) for (const s of arr)
if (s.id === id) { s.weight = map[id]; found = true; }
if (!found) fail(`${opt}: вид «${id}» не найден в таблицах локаций`);
}
}
applySets(sets, [D.SPECIES_BY_LOCATION, D.SPECIES_BY_LOCATION_NIGHT], '--set');
applySets(setsDay, [D.SPECIES_BY_LOCATION], '--set-day');
applySets(setsNight, [D.SPECIES_BY_LOCATION_NIGHT], '--set-night');
function parseCost(obj, s, opt) {
const m = String(s || '').split(':');
if (m.length !== 2 || !m[0].trim() || !(Number(m[1]) >= 0)) fail(`${opt} ожидает «i:цена»`);
obj[Number(m[0])] = Number(m[1]);
}
function parseList(s, opt) {
const arr = String(s || '').split(',').map(Number);
if (arr.length !== 3 || arr.some(x => !(x >= 0))) fail(`${opt} ожидает три числа через запятую`);
return arr;
}
function printHelp() {
console.log(`progression.mjs — модель прогрессии: ETA целей по ценам снастей и локаций.
Данные (SPECIES/VARIANTS/RODS/LURES/LOCATIONS/UPG) берутся из index.html.
Опции:
--start-coins N стартовые монеты (по умолчанию 0)
--per-day MIN минут в день для перевода ETA в дни (по умолчанию 15)
--route "..." маршрут покупок (rodN, rodN:lvl, lureN, lureN:lvl, locN)
--samples N выборка Monte-Carlo на вид (по умолчанию 80)
--rod i:cost what-if цена удочки i (не сохраняется)
--lure i:cost what-if цена приманки i
--loc i:cost what-if цена локации i
--rod-upg a,b,c what-if ROD_UPG_COST
--lure-upg a,b,c what-if LURE_UPG_COST
--upg-mult a,b,c what-if UPG_MULT
--help
Маршрут по умолчанию:
${DEFAULT_ROUTE}`);
}
/* ---------- 3. Статы и цены (порт из index.html) ---------- */
const RODS = R.RODS.map((r, i) => Object.assign({}, r, rodCost[i] !== undefined ? { cost: rodCost[i] } : {}));
const LURES = R.LURES.map((l, i) => Object.assign({}, l, lureCost[i] !== undefined ? { cost: lureCost[i] } : {}));
const LOCATIONS = D.LOCATIONS.map((l, i) => Object.assign({}, l, locCost[i] !== undefined ? { cost: locCost[i] } : {}));
const ROD_UPG_COST = rodUpg || R.ROD_UPG_COST.slice();
const LURE_UPG_COST = lureUpg || R.LURE_UPG_COST.slice();
const UPG_MULT = upgMult || R.UPG_MULT.slice();
function effRod(i, lv) {
const e = Object.assign({}, RODS[i]);
if (lv >= 2) e.line *= 1.15;
if (lv >= 3) { e.drag *= 1.15; e.reel *= 1.10; }
if (lv >= 4) { e.line *= 1.15; e.size += 0.10; }
return e;
}
function effLure(i, lv) {
const e = Object.assign({}, LURES[i]);
if (lv >= 2) e.bite *= 1.15;
if (lv >= 3) e.rare += 1.0;
if (lv >= 4) e.size += 0.15;
return e;
}
function upgCost(type, i, lv) {
if (i === 0) return (type === 'rod' ? ROD_UPG_COST : LURE_UPG_COST)[lv - 1];
const base = (type === 'rod' ? RODS : LURES)[i].cost;
return Math.round(base * UPG_MULT[lv - 1] / 10) * 10;
}
function variantE(boost) {
let survive = 1, e = 0;
for (const v of D.VARIANTS) {
if (v.id === 'normal') continue;
let c = v.id === 'gold' ? D.SHINY_CHANCE : v.chance;
c *= 1 + boost * D.VARIANTS.indexOf(v);
c = Math.min(1, c);
e += survive * c * v.mult;
survive *= 1 - c;
}
return e + survive;
}
/* ---------- 4. Симулятор боя (порт updateReel/fishForce) ---------- */
const DT = 1 / 60;
function fight(w, str, rod, maxT) {
const d0 = 45 + 7 * w;
const ov = w / rod.maxW;
const aggr = Math.min(1, 0.35 * str + 0.75 * Math.min(1.25, ov) / 1.25);
let d = d0, tension = 0, escape = 0;
let st = 0.65 + 0.35 * Math.min(1, w / (rod.maxW * 1.5)); // приближение st0 (без wMax вида)
let phase = 'calm', phaseT = 0.8 + Math.random() * 0.7, t = 0;
while (t < maxT) {
t += DT; phaseT -= DT;
if (phaseT <= 0) {
if (phase === 'calm') { phase = 'pull'; phaseT = (0.8 + 2.2 * st) * (0.6 + 0.7 * aggr) * (0.8 + Math.random() * 0.45); }
else { phase = 'calm'; phaseT = Math.max(0.35, (1.3 - 0.8 * st) * (0.8 + Math.random() * 0.5)); }
}
const pulling = phase === 'pull';
// бот: максимум держать, но отпускать, когда натяжение подходит к прочности лески
// (держать в затишье неоптимально только при критичном натяжении — тогда сбрасываем)
const holding = tension < 0.75 * rod.line;
const drain = (pulling ? 0.016 + 0.02 * aggr : 0.002) + (holding ? 0.006 : 0.001) + (pulling && !holding ? 0.01 : 0);
st = Math.max(0, st - drain * DT);
const tired = st <= 0.12;
const base = pulling ? (16 + 34 * st) * (0.5 + 0.9 * aggr) * (0.7 + 0.5 * Math.min(1.5, ov)) : (5 + 7 * aggr);
const F = tired ? 0.45 * base : base;
if (holding) {
const over = F - rod.drag;
tension += (10 + Math.max(0, over) * 1.1 + 22 * aggr * (pulling ? 1 : 0.35)) * DT;
if (over > 0) d += over * 0.22 * DT;
else d -= (11 + 3 * rod.reel) * (1 + 0.6 * (1 - st)) * DT;
} else {
tension -= 38 * DT;
if (pulling) d += F * 0.32 * DT;
}
tension = Math.max(0, tension);
if (holding) escape = Math.max(0, escape - 0.5 * DT);
else if (!pulling) escape = Math.min(1, escape + (0.2 + aggr * 0.4) * DT);
if (escape >= 1) return { caught: false, t };
if (tension >= rod.line) return { caught: false, t };
if (d <= 0) return { caught: true, t };
if (d >= d0 * 1.45) return { caught: false, t };
}
return { caught: false, t };
}
/* ---------- 5. Оценка вида при снасти (кэш) ---------- */
const SPEC_CACHE = new Map();
function speciesOutcome(ri, rlv, li, llv, sp) {
const key = ri + '|' + rlv + '|' + li + '|' + llv;
let map = SPEC_CACHE.get(key);
if (!map) { map = new Map(); SPEC_CACHE.set(key, map); }
if (map.has(sp.id)) return map.get(sp.id);
const rod = effRod(ri, rlv), lure = effLure(li, llv);
const B = Math.min(0.95, rod.size + lure.size);
const a = 2 / (1 + 2 * B);
let caught = 0, tSum = 0, wCaught = 0;
for (let i = 0; i < samples; i++) {
const w = sp.wMin + Math.pow(Math.random(), a) * (sp.wMax - sp.wMin);
const r = fight(w, sp.strength, rod, 150);
tSum += r.t;
if (r.caught) { caught++; wCaught += w; }
}
const res = { p: caught / samples, t: tSum / samples, valW: wCaught / samples };
map.set(sp.id, res);
return res;
}
/* ---------- 6. Доходность локации при снасти, 🪙/мин ---------- */
const PHASES = [
{ night: false, bite: 1 },
{ night: false, bite: 1 },
{ night: true, bite: 1 },
{ night: true, bite: 0.85 }, // ночь: biteChance ×0.85
];
const DIST = 0.9; // дальность заброса (полная мощность, cast≥1)
const BASE_P = 0.65 + 0.25 * DIST;
const RATE_CACHE = new Map();
function rateFor(loc, ri, rlv, li, llv) {
const key = loc + '|' + ri + '|' + rlv + '|' + li + '|' + llv;
if (RATE_CACHE.has(key)) return RATE_CACHE.get(key);
const rod = effRod(ri, rlv), lure = effLure(li, llv);
const boost = lure.rare * 0.15 + (llv - 1) * 0.05; // variantBoost
const E = variantE(boost);
const B = Math.min(0.95, rod.size + lure.size);
const Es = (1 + 2 * B) / (3 + 2 * B); // E[s] для rollWeight
let sum = 0;
for (const ph of PHASES) {
const tbl = ph.night ? D.SPECIES_BY_LOCATION_NIGHT[loc] : D.SPECIES_BY_LOCATION[loc];
const p = BASE_P * ph.bite;
const Ewait = (7 + (1 - p) / p * 3.75) / lure.bite;
const fixed = 0.65 + Ewait + 0.45 + 1.2; // cast + ожидание клёва + approach + карточка
let expCoins = 0, expT = 0, totW = 0;
for (const sp of tbl) totW += sp.weight;
for (const sp of tbl) {
const P = sp.weight / totW;
const o = speciesOutcome(ri, rlv, li, llv, sp);
const Ew = sp.wMin + Es * (sp.wMax - sp.wMin);
expCoins += P * sp.price * (o.valW || Ew * o.p) * E;
expT += P * o.t;
}
const cycle = fixed + expT;
sum += expCoins / (cycle / 60);
}
const res = sum / PHASES.length;
RATE_CACHE.set(key, res);
return res;
}
/* ---------- 7. Жадный маршрут покупок ---------- */
const state = {
coins: startCoins,
rods: [0], rodLv: { 0: 1 }, rod: 0,
lures: [0], lureLv: { 0: 1 }, lure: 0,
locs: [0], loc: 0,
time: 0,
};
function bestRate() {
let best = 0, info = null;
for (const ri of state.rods) {
const rlv = state.rodLv[ri] || 1;
for (const li of state.lures) {
const llv = state.lureLv[li] || 1;
for (const loc of state.locs) {
const r = rateFor(loc, ri, rlv, li, llv);
if (r > best) { best = r; info = { ri, rlv, li, llv, loc }; }
}
}
}
return { rate: best, info };
}
function stepCost(tok) {
const m = /^(rod|lure|loc)(\d+)(?::(\d+))?$/.exec(tok);
if (!m) fail(`не разобрать шаг маршрута «${tok}»`);
const kind = m[1], i = Number(m[2]), lvl = m[3] ? Number(m[3]) : null;
if (kind === 'loc') return { kind, i, cost: LOCATIONS[i] ? LOCATIONS[i].cost : NaN, label: `локация «${(LOCATIONS[i] || {}).name || i}»` };
const type = kind === 'rod' ? 'rod' : 'lure';
const list = type === 'rod' ? RODS : LURES;
const lvMap = type === 'rod' ? state.rodLv : state.lureLv;
const owned = type === 'rod' ? state.rods : state.lures;
if (!list[i]) fail(`шаг «${tok}»: снасти с индексом ${i} нет`);
if (owned.indexOf(i) < 0) { // покупка
if (i > 0 && (lvMap[list[i - 1].id] || 1) < 4) fail(`шаг «${tok}»: сначала предыдущая снасть на 4 ур.`);
return { kind, type, i, cost: list[i].cost, label: `купить «${list[i].name}»` };
}
// апгрейд
const cur = lvMap[i] || 1;
if (!lvl || lvl <= cur) fail(`шаг «${tok}»: нечего улучшать (текущий ур. ${cur}, цель ${lvl})`);
let cost = 0;
for (let lv = cur; lv < lvl; lv++) cost += upgCost(type, i, lv);
return { kind, type, i, lvl, cost, label: `«${list[i].name}» ${cur}→${lvl} ур.` };
}
function applyStep(s) {
if (s.kind === 'loc') { state.locs.push(s.i); state.loc = s.i; return; }
if (s.kind === 'rod' || s.kind === 'lure') {
const type = s.kind;
const owned = type === 'rod' ? state.rods : state.lures;
const lvMap = type === 'rod' ? state.rodLv : state.lureLv;
if (owned.indexOf(s.i) < 0) { owned.push(s.i); lvMap[s.i] = 1; if (type === 'rod') state.rod = s.i; else state.lure = s.i; }
else lvMap[s.i] = s.lvl;
}
}
function fmtTime(min) {
if (min < 60) return min.toFixed(1) + ' мин';
const h = Math.floor(min / 60), m = min - h * 60;
return h + ' ч ' + m.toFixed(0) + ' мин';
}
/* ---------- 8. Печать ---------- */
const overridesUsed = [];
if (Object.keys(rodCost).length || Object.keys(lureCost).length || Object.keys(locCost).length ||
Object.keys(sets).length || Object.keys(setsDay).length || Object.keys(setsNight).length ||
rodUpg || lureUpg || upgMult) {
overridesUsed.push([...Object.entries(rodCost).map(([k, v]) => `rod${k}=${v}`),
...Object.entries(lureCost).map(([k, v]) => `lure${k}=${v}`),
...Object.entries(locCost).map(([k, v]) => `loc${k}=${v}`),
...(rodUpg ? ['rod-upg=' + rodUpg.join(',')] : []),
...(lureUpg ? ['lure-upg=' + lureUpg.join(',')] : []),
...(upgMult ? ['upg-mult=' + upgMult.join(',')] : []),
...Object.entries(sets).map(([k, v]) => `set ${k}=${v}`),
...Object.entries(setsDay).map(([k, v]) => `set-day ${k}=${v}`),
...Object.entries(setsNight).map(([k, v]) => `set-night ${k}=${v}`)].join(', '));
}
const dash = s => '─'.repeat(Math.max(2, 68 - s.length));
console.log('Прогрессия — node tools/progression.mjs');
console.log(`формула: улов/бой как в игре; день/ночь 50/50; питание вариантов E[mult] нейтрально (снасть учитывается)`);
console.log(`параметры: start-coins=${startCoins} · per-day=${perDay} мин · samples=${samples}`);
if (overridesUsed.length) console.log('WHAT-IF (НЕ сохранено): ' + overridesUsed);
console.log('');
// Сводка доходности по локациям с эталонной снастью (Титан lv1 + Блёстка lv1)
console.log('── ДОХОДНОСТЬ ПО ВОДАМ (Титан lv1 + Блёстка lv1, 🪙/мин) ' + dash('──'));
for (let i = 0; i < LOCATIONS.length; i++) {
const r = rateFor(i, 3, 1, 3, 1);
console.log((' ' + LOCATIONS[i].name + ' (' + LOCATIONS[i].cost + ' 🪙)').padEnd(34) + r.toFixed(0).padStart(6) + ' 🪙/мин');
}
console.log('');
console.log('── МАРШРУТ ' + dash('── МАРШРУТ '));
console.log(' цель'.padEnd(34) + 'цена'.padStart(9) + 'шаг'.padStart(10) + 'всего'.padStart(11) + 'доход'.padStart(10));
const tokens = route.split(',').map(s => s.trim()).filter(Boolean);
for (const tok of tokens) {
const s = stepCost(tok);
const { rate } = bestRate();
let dt = 0;
if (state.coins < s.cost) {
dt = (s.cost - state.coins) / rate; // минуты
state.time += dt;
state.coins += rate * dt;
}
state.coins -= s.cost;
const before = rate;
applyStep(s);
console.log((' ' + s.label).padEnd(34) + String(s.cost).padStart(9) + fmtTime(dt).padStart(10) +
fmtTime(state.time).padStart(11) + (before.toFixed(0) + '/м').padStart(10));
}
console.log('');
const total = state.time;
console.log(`Итого: ${fmtTime(total)} (${(total / 60).toFixed(2)} ч) до последней цели маршрута · ` +
`при ${perDay} мин/день ≈ ${(total / perDay).toFixed(1)} дней`);
console.log(`остаток монет: ${state.coins.toFixed(0)} 🪙 · время задним числом не включает ручные паузы/рекламу`);
console.log('');
console.log('Замечание: маршрут — модель «идеального» игрока (лучшая доступная вода, лучшая снасть,');
console.log('бот без ошибок). Реальные времена больше на промахи подсечки и меню; для проверки — плейтест.');