Files
andrey 7f545347b4 Этап 45: локация «Волшебное болото» (id 4, 90 000 🪙) — светящиеся рыбы, лягушки, кувшинки/лилии. (1) Данные: LOCATIONS[4] (тёмно-моховая вода 6 hex, слабая рябь; в массиве ПОСЛЕДНИЙ, по цене — между Озёрами 65536 и Морем 131072); 5 новых видов строго в КОНЕЦ SPECIES (32→37, по одному на редкость r0–r4, у каждого glow:'#hex' — 5 разных цветов): glowfin Светлячок #5ff2ff 8🪙, moonfish Лунная рыбка #b9c8ff 13, emberfin Изумрудный светляк #57ffa8 20, violetlure Фиалковый огонёк #d17bff 38, spiritfin Дух болота #ffe07a 70; SWAMP_IDS/SWAMP_DAY_WEIGHTS/SWAMP_NIGHT_WEIGHTS/SWAMP_SPECIES(_NIGHT) — паттерн LAKE/SEA/OCEAN без пересечений; NIGHT_WEIGHTS — ключи всем 37 id. (2) pickSpecies(): вложенный тернарник → табличный доступ SPECIES_BY_LOCATION(_NIGHT) по save.location + guard (вне 0..4 → Река); роллы 0..3 бит-в-идентичны (lc3/lc4/lc4b/lc4c + речные §9j/M4/M6 не тронуты). (3) Свечение (только ночь): nightMix()>0.35 — тот же порог, что у фонаря; drawGlowAura() (радиальный градиент + 'lighter', save/restore, кэш GLOW_RGBA) — фоновые рыбы болота (AMBIENT_FISH[4] — 7 рыб + palette из 4 цветов, цвет стабильно по индексу), зацепившаяся/вываживаемая (fishGlow — вид с glow), боссовая золотая аура сохранена; карточка — класс glow-card + --glow (только normal-вариант, drop-shadow, не конфликтует с фильтрами вариантов), дневник — drop-shadow на SVG. (4) Декорации: initScenery() ветка i===4 (bank+reed+snag+pine в болотной гамме), ГЛОБАЛЬНЫЕ pads[] (8–14 кувшинок с вырезом, 45% с лилиями — жёлтые/белые/розовые) и frogs[] (3–5, sit→jump по параболе на соседнюю кувшинку, 3–7 с) — очищаются/пересобираются в initScenery() (resize + обе ветки shopClick); updateDecor(dt) — покачивание/прыжки; drawSwampDecor() — ОТДЕЛЬНЫЙ слой после drawWater/drawAmbientFish и до drawBoat (дusk берега не гасит; локальное притухание ночью). (5) Босс: BOSS_BY_LOCATION[4]='spiritfin' (Дух болота, 20–50 кг, ×5 — паттерн Этапа 37). (6) Магазин/i18n/ачивки: тост welcome_swamp (ru/en, тернарник → if/else), ачивки 34→36: swamp_first (bonus:{coinPct:0.02}, locations-based) + swamp_boss (bonus:{}, bosses.spiritfin) → пин achBonus().coinPct 0.16→0.18; species_8 = все 37. (7) Баланс: SWAMP_DAY_WEIGHTS {glowfin:3,moonfish:4,emberfin:28,violetlure:13,spiritfin:4.5}, SWAMP_NIGHT_WEIGHTS {glowfin:2.5,moonfish:2.5,emberfin:20,violetlure:30,spiritfin:4} — Болото 240.3/328.9 🪙 (ночь/день ×1.37), порядок по цене Озёра 194.1/249.0 < Болото < Море 297.3/476.7 в ОБЕ фазы; пины остальных вод не изменились (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7). (8) tools/balance.mjs: 8→10 таблиц, +SWAMP_SPECIES(_NIGHT) в return, сводка/пары отношений — сортировка ПО ЦЕНЕ (болото встает между Озёрами и Морем), пины в шапке. (9) Портреты: 5 flat-SVG (каждый — отдельным художником) assets/fish/fish-{glowfin,moonfish,emberfin,violetlure,spiritfin}.svg (viewBox 0 0 160 100, var(--f1..f4,hsl-fallback)); embed-fish.mjs IDS 32→37 (в конец), 37 <symbol> в index.html; fish-preview.html 32→37 карточек. (10) Тесты (тот же коммит): lc1 (37/5 локаций/90000/names/sprites 37/oceanOrder slice(23,32)/swampOrder/swampGlow/swampRar), lc2 (+SWAMP-таблицы, пересечения, nightW 37), lc4d (новый: 3000+3000 роллов болота — все 5 ≥1, чужих нет), lc5 (+location:4 не сбрасывается, location:5→0), lc8w (новый: покупка 90000 + welcome_swamp), lc9 (37), lc10 (mid29), lc11 (новый: glow #rrggbb, 5 цветов различны, веса покрывают id, NIGHT_WEIGHTS — все 37), a22d (36), a36loc (+swamp_first), пин 0.18, §9k2 (+{loc:4, spiritfin, swamp_boss}). 198→204 PASS, exit 0. (11) Скриншоты: gen-screenshots.js +кадры swamp/swamp-night (12→14 × 3 вьюпорта, dayTime 0.3/0.85) — 42 RU + 42 EN PNG (screenshots-wiki/, screenshots-wiki-en/). (12) Доки: AGENTS.md (локации/босс/баланс/баланс-скрипт/награды/портреты/скриншоты), README (5 локаций, 37 видов, 36 ачивок, 204 теста, 14 кадров, mobp-swamp*), KODA.md (SWAMP_*, SPECIES_BY_LOCATION, drawGlowAura/fishGlow/GLOW_RGBA, drawSwampDecor, pads/frogs), DEVLOG (Этап 45), PLAN-SWAMP.md (статус «реализован»). Проверено: 204/204 PASS exit 0, check.mjs OK, balance.mjs — пины без изменений + Болото в цели, emoji-font --check OK (52 глифа, новых emoji нет), ревью @oracle — критичных замечаний нет (.gitignore + .opencode/, fish-preview, осколок комментария species_8 — закрыты в этом же коммите). Миграция сейва не тронута (граница из LOCATIONS.length; location=4 покрыт lc5(f)).
2026-09-21 22:59:15 +03:00

248 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 · Болото 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) на цену улова не влияет; дождь учтён строкой «при дожде».');