#!/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('бот без ошибок). Реальные времена больше на промахи подсечки и меню; для проверки — плейтест.');