# Спринт 4 «Симулятор»: исследование физики рыбалки Цель — довести механику боя с рыбой до уровня рыбалки-симулятора: у рыбы появляется выносливость и характер (рывки с паузами, усталость), у катушки — тормоз (drag), у снастей — честный предел, у воды — пустые клёвы, у босса — реальный риск. Документ: что есть сейчас, как это устроено в реальной рыбалке и в симуляторах, приоритеты P0/P1/P2 и план спринта, реализуемый **одной** разработкой (1 файл, ~150–300 строк изменений). Исходная точка — вердикт T5 (спринт 3, DEVLOG, этап 14): **too_easy** — 0 сходов на 329 уловов, босс 18/18 без шанса проиграть, клёв 100% на каждый заброс. Числовой баланс (этап 14) подкрутил тайминги клёва и редкости, но «вываживание и ×5 босса требуют правки логики» — это и есть спринт 4. Ограничения жёсткие (AGENTS.md): один `index.html`, без зависимостей/ассетов/ бэкенда; имена глобалов, которые пользуют тесты, не переименовывать (`state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, состояния `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`); ввод — Pointer Events; сейв `fishing-save-v1` — новые поля только через миграцию в `loadSave()`; 60 fps; один палец. --- ## 1. Состояние сейчас Кратко, с именами функций и формулами (всё в `index.html`). ### 1.1 Данные - `SPECIES` (8 рыб): `strength` (0.25–0.9), `weight` (вес спавна в рандоме видов), `wMin/wMax` (диапазон веса улова), `minDist` (нужна дальняя дистанция), `price` (🪙/кг). `SPECIES_NIGHT` — клон с ночными весами. - `RODS` (4 удочки): `maxW` (предел веса), `line` (прочность шнура 100/140/190/260), `reel` (скорость втягивания 1.0–2.3), `size` (бонус на крупную). - `LURES` (5 приманок): `cast` (дальность), `bite` (частота клёва), `rare` (шанс редких/крупных), `nightOnly` (фонарик). - `SHINY_CHANCE` (3%), `forceBoss` (тест-форс босса). ### 1.2 Бросок - `CHARGE`: `charge += dt`, `power = 1 - |1 - ((charge/0.9) % 2)|` — треугольник с периодом 1.8 с (нажал-отпустил в любой момент). - `doCast()`: `dist = min(0.97, (0.10 + 0.80*power) * lure().cast)`; состояние `CAST` — фиксированная парабола 0.65 с (`castT`, дуга `-150*sin(π*t)`), потом `WAIT`, `biteTimer = rand(4,10)/lure().bite`. - Нет ветра, течения, баллистики: расстояние — детерминированная функция мощности и приманки. ### 1.3 Клёв и подсечка - `WAIT`: `biteTimer` тикает; при `≤ 0` — всегда `startBite()` (кроме ночи без фонарика). **Пустых клёв нет: 100% забросов приводят к рыбе.** - `startBite()`: `pickSpecies(dist)` (вес вида × редкость-бонус приманки, штраф `×0.06` за близкую дистанцию); осётр → босс с шансом 30% (`forceBoss` форсирует). Рыба плывёт к поплавку за 0.45 с (`APPROACH`, `updateBiteFishIn()`). - `BITE`: окно `biteT = biteWindow(sp)` — `0.8 - rarity*0.08 + rod*0.04 + achBonus().windowS` (не уже 0.35 с), у босса `rand(0.25,0.5)`. Тап (`hook()`) в окне — подсечка; мимо — «Ушла… жми быстрее!», новый таймер `rand(5,11)`. - Подсечка **мгновенно успешна** в любом месте окна: качество момента не влияет на бой. ### 1.4 Вываживание (`hook()` + `updateReel()`) `hook()` создаёт `reel`: ``` w = rollWeight(sp) // смещение к крупной: B = min(0.95, rod.size + lure.size) ov = w / rod().maxW // 1.0 = на пределе удочки aggr = min(1, 0.35*str + 0.75*min(1.25, ov)/1.25) // «злость» d0 = 40 + 3.5*w // стартовая дистанция (px) line = rod().line + achBonus().line reel = { d: d0, d0, tension: 0, escape: 0, holding: false, phase: 'calm', phaseT: rand(0.8,1.5), sp, w, ov, aggr, tick: 0, line, boss } ``` `updateReel(dt)` — машина фаз + накопители: ``` фазы: pull (рыба тянет) phaseT = 0.3 + ag*rand(0.4, 0.9) [0.7–1.2 с] calm (отдых) phaseT = max(0.35, rand(0.7,1.7) - 0.3*ag) держа: d -= 30 * rod().reel * dt [26–69 px/с втягивание] tension += (pull ? (14+30*ag)*(0.6+0.9*ov) : 9) * dt отпустил: tension -= 38*dt pull: d += (3+4*ag)*(0.5+0.4*ov) * dt [до ~7.7 px/с отдача] escape: держа -= 0.5*dt; отпустил += (0.2+0.4*ag)*dt (1 = сбежала) исходы: tension >= line → SNAP (обрыв, save.snaps++) d <= 0 → улов (у босса фаз 1–2: «рвёт леску», без штрафа, дальше d = 0.6·d0 / 1.0·d0) d >= 1.4*d0 → near-miss: +10% цены, «Сбежала!» escape >= 1 → сбежала: +10% цены ``` ### 1.5 Улов и босс - `catchFish()`: `val = w * price * (shiny?3:1) * (boss?5:1) * (0.95…1.05) * (1 + achBonus().coinPct)`; рекорд, shiny, босс-флаги, квесты, достижения. - Босс: `stateBoss`/`bossPhase`; фазы 1–2 завершаются авто-`SNAP` без штрафа (сброс `tension=0`, следующая фаза глубже), фаза 3 — обычный улов ×5. Настоящий обрыв по натяжению и near-miss бой завершают (`stateBoss=false`). ### 1.6 Почему «too_easy» (причины по числам) 1. **Асимметрия сил.** Втягивание — `30*reel` px/с (26–69 по удилищам), отдача рыбы — максимум `(3+4*1)*(0.5+0.4*ov)` ≈ 7.7 px/с. Рыба **никогда** не выигрывает дистанцию: при правильном ритме «держал-отпускал» сход математически невозможен (0/329 у T5). 2. **Дешёвое натяжение.** Карась (ag≈0.17): в pull при удержании `(14+5.1)*(0.6+0.12)≈15.8/с` → до обрыва 6+ с; в calm — `9/с` → 11 с. Бот с реакцией 140 мс обрывает только в наихудшем стечении фаз. 3. **Нет выносливости.** «Злость» `aggr` постоянна весь бой: нет дуги «первый рывок — паузы — усталость — слабый финиш», нет «усталой» рыбы, которую втягивают быстрее. Бой бесконечен, но статичен. 4. **Нет тормоза (drag).** У удилища только `line` (предел прочности) и `reel` (скорость). Механики «уступить рыбе» нет: единственная свобода — держать/отпустить. Нет и «бесплатного» скольжения лески, которое в реальности гасит пик нагрузки. 5. **Клёв 100%.** `biteTimer` всегда приводит к рыбе: нет удачи, «чтения воды», пустых забросов (0 пустых у T5). 6. **Босс = гарантия победы.** Три фазы с бесплатными ретраями (обрыв фазы без штрафа + сброс `tension`), после фазы 3 дёргать до улова. 18/18 у T5. --- ## 2. Как это устроено в реальной рыбалке и в симуляторах ### 2.1 Тормоз катушки (drag) У любого современного барабанного/безынерционного катушки есть **тормоз** — трения, которое лимитирует натяжение лески (Wikipedia, «Fishing reel», разделы *Drag mechanisms* / *Setting the drag*): - Принцип: когда натяжение лески превышает усилие тормоза, шпуля «проскальзывает», отдавая леску, **пока натяжение не упадёт обратно ниже уровня drag** — drag это *потолок* нагрузки на шнур. Именно это позволяет вываживать рыбу сильнее, чем «на бумаге» держит шнур: рыбак «разговаривает» с рыбой (walk the fish) — гасит рывки тормозом, медленно утомляет её. - Рекомендуемая настройка: **drag ≈ 1/3…1/2 от разрывной нагрузки лески** (пример из статьи: шнур 20 lb → drag 7–10 lb). - Связанные механики: **anti-reverse** (леска идёт, ручка стоит — «рыба тянет, катушка крутится сама»), **freespool** (полное освобождение шпули на стартовом рывке), **strike position** на рычажном тормозе (слабый drag на момент подсечки, чтобы не вырвать крюк, полный drag — после). В нашей игре `tension`/`line` — это натяжение и предел прочности, но самого тормоза нет: нагрузка идёт либо «в ноль», либо «в обрыв». Добавление `drag` на удилище превращает палочную механику «держать/отпустить» в симуляционную: **рывок сильнее drag → леска скользит (рыба отходит, нагрузка держится у drag); рывок слабее drag → спокойно втягиваешь**. ### 2.2 Рывки, паузы и выносливость рыбы - Реальная рыба борется **рывками**: после подсечки — «первый рывок» (first run) с приманкой во рту, затем чередование рывков (секунды-полторы) и затишья. С утомлением рывки слабеют и короче, в конце — вялое «плывёт». - Длительность боя зависит от размера и вида: мелочь — десятки секунд, крупный сом/осетёр — минуты. Выносливость — ключевая переменная: именно её исчерпание, а не «вытянул дистанцию», и есть конец боя. - Игрок управляет темпом: подтягивает в паузах, «сдаёт» тормоз в рывках. Ошибка «держать в рывок» → пик нагрузки выше прочности → обрыв. Ошибка «не держать вовсе» → рыба уходит, сходит с крюка / уводит в заросли. ### 2.3 Момент подсечки Подсечка — короткий энергичный подъём удилища в момент, когда рыба взяла приманку. Раньше — рыба «пробует» насадку (пустой удар), позже — заглотила и уже уходит (слабое зацепление, вырвется в первые секунды боя). Качество подсечки в симуляторах влияет на начало боя: глубина зацепа → сила первого рывка и вероятность схода в первые секунды. ### 2.4 Баллистика заброса, ветер, течение - Заброс — баллистическая траектория: дальность = ф(мощности, веса приманки, угла, **ветра**). Ветер против/по — сдвигает точку приземления на десятки процентов; рыбак «щупает» линию пальцем (feathering), гася бегущую шпулю. - **Течение** дрейфует поплавок после приводнения; рыба чаще подходит со стороны течения. Даже слабый дрейф делает каждый заброс уникальным. ### 2.5 Что делают симуляторы (паттерны) - **Fishing Planet / Fishing Sim World**: настройка drag, у рыбы stamina, «разговор с рыбой» — подтяжка в паузах, сдача в рывках; усталая рыба тянет слабее и её втягивают заметно быстрее. - **Stardew Valley (tug-of-war)**: зона «рыбы» по полосе; втягиваешь, когда она стоит, отпускаешь, когда идёт; удержание «в движение» сжимает зону и ведёт к сходу. Наш цикл «держи в calm / отпускай в pull» — уже аналог, но без последствий по силам: сжиматься «зона» (escape) умеет, а вот **силы сторон не соревнуются** (см. 1.6). - Мобильные симуляторы (Angler, Lure): та же пара «натяжение + выносливость», управление одним пальцем — подтверждение, что механика укладывается в наш ввод. **Вывод:** ядро (фазы pull/calm, метры натяжения/дистанции, escape) у нас уже есть. Не хватает слоя симуляции: (а) drag как потолок нагрузки, (б) выносливость как движок дуги боя, (в) честный баланс сил, (г) пустые клёвы и качество подсечки, (д) риск у босса. --- ## 3. Приоритеты P0 — берём в спринт 4 (раздел 4). P1 — следующий шаг после P0. P2 — «симуляционная мишени» на потом. По каждому: что меняем, что видит игрок, риск для тестов, объём. ### P0-1. Выносливость рыбы (stamina) и структура рывков **Что меняем.** - `hook()`: новые поля `reel.st` (выносливость 1→0) и стартовое `st0 = 0.65 + 0.35*min(1, w/sp.wMax)` (крупная = выносливее); у босса `st0 = 1`. - Структура фаз в `updateReel()`: длительность рывка растёт с `st`, пауза растёт при усталости: ``` pull: phaseT = (0.8 + 2.2*st) * (0.6 + 0.7*ag) * rand(0.8, 1.25) calm: phaseT = max(0.35, (1.3 - 0.8*st) * rand(0.8, 1.3)) ``` - Расход (каждый кадр): ``` drain = (pull ? 0.05 + 0.16*ag : 0.008) + (holding ? 0.035 : 0.004) + (pull && !holding ? 0.05 : 0) // свободный бег тоже утомляет st = max(0, st - drain*dt) ``` При `st ≤ 0.12` — флаг `reel.tired`: сила рывка ×0.45, рывки ×0.6, втягивание ×1.4 (усталую втягивают быстрее — как в реальности). - Оценка боя: карась ~10–15 с, карп/щука ~15–25 с, сом/осетёр ~25–40 с, босс ~30–50 с (3 фазы). Дуга боя появляется: свежая рыба рвёт, уставшая — «плывёт». **Что видит игрок.** Тонкая горизонтальная полоска «выносливость» под дистанцией (затухает к нулю, при `tired` — иконка 💤 и статус «Рыба устала — втягивай!»). Силуэт рыбы в `drawHookedFish()` «вялый» при усталости (амплитуда качества хвоста ниже, движение медленнее). **Риск для тестов.** Средний. Тесты пишут `reel.phase`/`phaseT` явно (6, 9c, 9f, M4) — структура фаз не мешает; `M4 p2/p3` проверяют `d` в ±5% от `0.6·d0`/`1.0·d0` сразу после авто-REEL — при `st=1` (босс) скорость втягивания в calm остаётся 30 px/с (см. P0-2), смещение за ~80 мс ≈ 2.4 px, в допуске (у осётра `d0 ≥ 57.5 px` → допуск ±2.9 px, впритык — см. план спринта: скорость втягивания при `st=1` не менять). `reel.st` — новое поле, тесты его не трогают; миграция сейва не нужна (бой не сохраняется). **Объём.** ~35 строк `index.html` (hook + updateReel + drawMeters + drawHookedFish). ### P0-2. Тормоз катушки (drag) и скольжение лески **Что меняем.** - `RODS`: новое поле `drag` ≈ ⅓ прочности: `[34, 47, 63, 87]` (против `line` 100/140/190/260) — консервативный низ вилки «⅓…½ разрывной» (раздел 2.1). Описание удилищ в лавке: «… · Drag 34». - Сила рыбы (новая функция, глобальная — тесты смогут вызывать): ``` fishForce(reel, ag, pull): pull: (16 + 34*st) * (0.5 + 0.9*ag) * (0.7 + 0.5*min(1.5, ov)) // 16…101.5 calm: 5 + 7*ag // «сопротивление» ``` Диапазон: мелочь ~15–25, карп/щука ~45–70, сом/осетёр/босс ~85–101.5. - Ядро `updateReel()` — «перетягивание» через drag: ``` F = fishForce(reel, ag, pull); R = holding ? 22 + 8*rod().reel : 0 держа: over = F - reel.drag tension += (8 + max(0, over)*1.1 + 14*ag*(pull ? 1 : 0.35)) * dt if over > 0: d += over * 0.22 * dt // леска скользит: рыба отходит else: d -= R * (1 + 0.35*(1-st)) * dt // втягивание (усталая — быстрее) отпустил: tension -= 38*dt if pull: d += F * 0.4 * dt // свободный бег (быстрее, чем сейчас) ``` - Смысл: **рывок сильнее drag → скольжение + рост нагрузки** (держись или лопнет); **рывок слабее drag → просто втягиваешь** (мелочь можно «держать через всё»). Свободный бег без удержания теперь заметен: у сома ~40 px/с отдача (было ≤7.7) — пассивный игрок реально теряет дистанцию. - Натяжение при мелкой рыбе остаётся мягким (over < 0 → базовые 8–15/с), при крупной — жёстким (у сома на 1-й удочке: ~96/с → обрыв ~1 с, если «держать в рывок»). **Что видит игрок.** Правая вертикальная полоса натяжения получает **метку drag** (белая черта на 34% высоты шкалы): «выше черты — леска скользит, сдавайся или готовься к обрыву». Иконка над шкалой: 💪 (держишь, F0). Рывок визуально: поплавок/линия дёргаются, леска «прямая» при F>drag (меньше провис — см. P2-4). **Риск для тестов.** Высокий (менят формулу, которую тесты обходят конструктивно) — разобран по кейсам, все 61 проходят при указанных числах: - 4b (12 кг на 3-кг удочке → SNAP за 4 с): при ag=1, ov→1.5, st≈0.8: F≈88, over≈54 → tension ≈ 8+59+14 ≈ 81/с → обрыв ~1.3 с (+≤1.7 с calm) ✓ - 5 (`tension = line-0.3` + держать → SNAP за 3 с): рост ≥13/с в calm ✓ - 4c/9a/9d/9h (`d=0.4`, calm, держать → CARD): F_calm≈12 < drag → втягивание 30 px/с → CARD за ~15 мс, tension не успевает дойти до line ✓ - 6/9c (`pull`, `phaseT=9999`, не держать, `d=1.38·d0` → WAIT за 15 с): свободный бег 6–40 px/с → 1.4·d0 за <1 с (или escape за ~2–5 с) ✓ - 9f (escape растёт/падает/сход): формула escape **не меняется** ✓ - M4 (p2: d в [0.55,0.65]·d0; p3: [0.95,1.05]·d0): см. P0-1 — при st=1 скорость в calm 30 px/с, как сейчас ✓ - 9l(3) (босс: tension 0.9·line, calm, держать → SNAP, затем tension≈0): рост 13/с в calm → SNAP ~0.8 с; сброс tension в авто-REEL не трогается ✓ **Объём.** ~35 строк `index.html` (RODS + fishForce + ядро updateReel + метка drag в drawMeters + описания лавки). ### P0-3. Ребаланс рисков (числа в рамках P0-1/P0-2) **Что меняем.** Не отдельная функция, а выбор чисел так, чтобы появились три «честных» исхода: - **SNAP** — при «держишь в рывок» у крупной рыбы (1–2 с до обрыва на слабой удочке, 3–5 с на титане); - **сход (escape)** — при пассивной игре: escape-скорость не меняется `(0.2+0.4*ag)/с`, но теперь к ней добавляется реальная потеря дистанции (свободный бег 20–40 px/с) → «не держать» = почти гарантированный сход за 3–6 с; - **улов** — при правильной игре (держишь в calm, «сдаёшь» в pull): мелочь ~10–15 с, крупная ~20–40 с; `tired`-фаза ускоряет финиш. - Целевые метрики (проверяются живым плейтестом, см. раздел 4.4): «умный» бот (реакция 140 мс) — 80–95% улова мелочи, 50–80% крупной, 30–70% босса; «тупой» бот (всегда держать) — 30–50% мелочи, 0–20% крупной (SNAP); пассив (никогда не держать) — 100% сходов. **Риск для тестов.** Тот же, что P0-1/P0-2 (числа уже согласованы с кейсами выше). Отдельного риска нет. **Объём.** ~10 строк (константы) — включены в P0-1/P0-2. ### P0-4. Пустые клёвы (не каждый заброс — рыба) **Что меняем.** - Глобал `let forceBite=false` (тест-форс, паттерн `forceBoss`). - `WAIT`: при `biteTimer ≤ 0` — ролл: ``` if (forceBite || Math.random() < biteChance()) startBite() else biteTimer = rand(2.5, 5) / lure().bite // «пусто»: тише, попробуем ещё function biteChance(){ let p = 0.65 + 0.25*dist; // дальше = чаще (дальше = жирнее) if (dayPhase==='night') p *= 0.85; // ночь тише return p; // ~0.65–0.9, ночь ~0.55–0.77 } ``` `dist` — нормированная дистанция заброса (та же, что в `startBite()`). Пустой клёв молчалив: поплавок просто лежит (игрок сам решает ждать или `recall()`). Меню: строка «Не каждый заброс — с клёвом: подожди, если молчит». **Что видит игрок.** Честные «пустые» 10–35% забросов — появляется элемент терпения и «чтения» (дальше = лучше). Никаких новых метров. **Риск для тестов.** Высокий, но механически чинится: ~15 мест в `tests/test.js` и ~5 в `tests/orca-playtest.js` ставят `biteTimer=0.05` и ждут `BITE` — добавить рядом `forceBite=true` (и сброс `false` после секции). Имена глобалов не переименовываются (`biteTimer` на месте, тесты обновляются в том же коммите — разрешено AGENTS.md). Миграция сейва не нужна. **Объём.** ~18 строк `index.html` + ~20 строк правки тестов. ### P1-1. Качество подсечки **Что.** В `hook()`: `quality = 1 - 0.6*|biteT/biteWindow - 0.5|` (центр окна — лучшее). Плохая подсечка → `reel.loose = 1-quality`: escape-скорость ×(1+loose), старт с `tension=0`, но `st0` выше (рыба «не дозацепилась» — дольше дёргается). Тост при loose>0.3: «Слабая подсечка! Рыба может сойти». Видео-сигнал: в `BITE` кольцо-таймер вокруг поплавка (сжимается с `biteT`). **Риск тестов.** Низкий (только множитель escape и старт; выплаты near-miss не меняются). **Объём.** ~30 строк + 1–2 теста (loose-флаг, ускоренный escape). ### P1-2. Босс: реальный риск **Что.** Босс на P0-механике уже опасен (F≈101, drag 34 → «держать в рывок» = обрыв за ~1 с), но фазы дают бесплатные ретраи. Добавляем: (а) старт `st=1`, drain ×0.8 (держится дольше), F ×1.15, длительность рывков ×1.3; (б) «погружение»: в фазах 2–3 раз в ~8 с босс ныряет на 1.2–1.8 с — сила ×1.5 и отдача ×1.5 **даже при отпускании** (сдаваться бесполезно — только терпеть пик: tension растёт медленнее при holding=false? Нет: `over`-ветка работает только при holding; при отпускании tension падает `-38/с`, но d уходит — риск near-miss). Авто-REEL после фазы сбрасывает `dive=0` (важно для M4-тестов). Статус «⚠️ Босс ныряет!» + затемнение воды. **Риск тестов.** Средний: M4-кейсы проходят (dive выключен на старте фазы, числа p2/p3 проверены), но нужен новый тест «босс можно потерять»: forceBoss, `tension=line-1`, `phase='pull'`, держать → SNAP, `stateBoss=false`, `bossPhase=0` (боевой обрыв, не фазовый). **Объём.** ~45 строк + 1 тест. ### P1-3. Стартовый рывок (first run) **Что.** В `hook()`: `reel.firstRun = rand(1.2, 2.5)*(0.5+0.5*ag)` — первая фаза после подсечки обязательно `pull` с этой длительностью (рыба уходит с приманкой во рту). Статус 2–3 с: «Первый рывок — сдай тормоз!». **Риск тестов.** Низкий: все тесты, ожидающие конкретную фазу, выставляют `phase`/`phaseT` явно; M4-старт проверяет только флаги, затем сам ставит `d=0.4, calm`. **Объём.** ~12 строк. ### P2-1. Ветер **Что.** Детерминированный от дня (тесты повторяют): `wind = (seededRand(daySeed(todayStr())+'wind')()*2-1)*1.5` (м/с, −1.5…1.5). В `doCast()`: `dist *= 1 + wind*0.08`; в `WAIT`/`REEL`: дрейф поплавка `bobber.x += wind*3*dt` (clamp до бровки). Визуал: существующий флажок на лодке (drawBoat) наклоняется/трепещет сильнее, в HUD — «🌬 +1.2» у монет. Соль `'wind'` отделяет поток `seededRand` от квестов (daySeed без соли). **Риск тестов.** Низкий: тесты не читают `castTo`/`dist` (M3 (b) зовёт `pickSpecies(1)` напрямую). **Объём.** ~30 строк. ### P2-2. Течение **Что.** Второе число от той же соли дня: `current = (seededRand(daySeed( todayStr())+'flow')()*2-1)`. Дрейф поплавка `+ current*2*dt` (суммируется с ветром в P2-1), `startBite()`: рыба приходит преимущественно со стороны течения (`fromLeft` не 50/50). Чисто атмосфера + лёгкая вариативность. **Риск тестов.** Низкий (M3 не проверяет сторону подхода). **Объём.** ~20 строк. ### P2-3. Баллистика заброса **Что.** `doCast()`: считать `vx = distPx/0.65`, `vy0` под гравитацию g, чтобы траектория приземлялась в `castTo`; `CAST`-ветка — честная интеграция (`vx,vy,g`), время полёта остаётся ~0.65 с. Всплеск/рассыпание — от скорости при ударе. После заброса — короткая метка «12 м» у бровки (дистанция в «метрах» = dist×30 м — игрок видит результат мощности+ветра). **Риск тестов.** Низкий: состояние `WAIT` приходит по `castT>=1` — время не меняется, меняется только формула отрисовки. **Объём.** ~35 строк. ### P2-4. Визуальное натяжение **Что.** Провис лески `drawLineTo(sag)` от `tension` (натяжение — прямая линия, покой — провис 18 px); в `REEL` удилище в `drawBoat()` гнётся к рыбе (control point), силуэт в рывке дергается сильнее (`wob` ∝ F). Полностью canvas, механика не трогается. **Риск тестов.** Нулевой. **Объём.** ~30 строк. --- ## 4. План спринта (P0 одной разработкой) ### 4.1 Состав `P0-1 выносливость + P0-2 drag + P0-3 ребаланс + P0-4 пустые клёвы`. Один файл `index.html`, ~130 строк изменений + ~45 строк тестов. Сейв не меняется (stamina — поле боя, drag — константа удилища, forceBite — тест-форс, как `forceBoss`): **миграция в loadSave() не нужна**. Затронутые зоны: `RODS` (+drag), `hook()` (+st, +firstRun не берём — P1), `updateReel()` (ядро), новая `fishForce()`, `WAIT`-ветка `updateGame()` (+biteChance/forceBite), `drawMeters()` (полоска st, черта drag, иконка 🌀), `drawHookedFish()` (усталость), текст меню, описания лавки. ### 4.2 Формулы (ИТОГ после ребаланса T2b) В разделах P0-1/P0-2 ниже — начальные числа; финальные (подогнанные Monte-Carlo, см. 4.7) — здесь: ``` hook(): st0 = boss ? 1 : 0.65 + 0.35*min(1, w/sp.wMax) d0 = 45 + 7*w // T2b: чем крупнее, тем больше буфер дистанции reel.st = st0; reel.tired = false updateReel(dt): // 1) фазы (структура рывков) — без изменений if (phaseT <= 0) { pull: phaseT = (0.8 + 2.2*st) * (0.6 + 0.7*ag) * rand(0.8,1.25) calm: phaseT = max(0.35, (1.3 - 0.8*st) * rand(0.8,1.3)) } // 2) выносливость (T2b: медленная дуга — свежая тянет сильно, уставшая слабо) drain = (pull ? 0.016+0.02*ag : 0.002) + (holding ? 0.006 : 0.001) + (pull && !holding ? 0.01 : 0) st = max(0, st - drain*dt); if (st <= 0.12) tired = true F = tired ? 0.45*fishForceBase : fishForceBase fishForceBase(pull) = (16+34*st) * (0.5+0.9*ag) * (0.7+0.5*min(1.5, ov)) fishForceBase(calm) = 5 + 7*ag // 3) перетягивание через drag if (holding) { over = F - rod().drag tension += (10 + max(0,over)*1.1 + 22*ag*(pull?1:0.35)) * dt // T2b: чувствительнее if (over > 0) d += over * 0.22 * dt // скольжение else d -= (11+3*rod().reel)*(1+0.6*(1-st)) * dt // втягивание, уст. до ×1.6 } else { tension -= 38*dt if (pull) d += F*0.32*dt // свободный бег (T2b: 0.4 → 0.32) } tension = max(0, tension) // 4) escape и исходы (T2b: отпускать в рывок — безопасно) // escape: holding ? -0.5 : (!pull ? +0.2+0.4*ag : 0) [1/с] // SNAP: tension >= line | улов: d<=0 (босс: фазы) | // near-miss: d>=1.45*d0 (T2b: 1.4 → 1.45) | сбежала: escape>=1 WAIT: if (biteTimer <= 0 && !biteFish) forceBite || Math.random() < biteChance() ? startBite() : biteTimer = rand(2.5,5)/lure().bite biteChance = (0.65 + 0.25*dist) * (night ? 0.85 : 1) ``` Ключевые инварианты (T2b): - втягивание в calm при `st=1` = `(11+3*rod().reel)` px/с (14 / 14.9 / 16.1 / 17.9 по удилищам) и растёт с усталостью до ×1.6 (`st=0`). Старое «ровно `30*rod().reel`» снято: скорость — часть дуги усталости; фактические значения для старт-уловов 17–41% медленнее старых (учтено в балансе); - `escape` НЕ растёт при `pull && !holding` — отпускание в рывке безопасное (выигрышное) действие; его стоимость — только дистанция (near-miss); пассив («засиделся» без удержания в затишье) и «всегда держать» — те, кто платит сходами и обрывами; - `tension=0` в авто-REEL босса не трогается; - `reel` сохраняет все текущие поля (d, d0, tension, escape, holding, phase, phaseT, sp, w, ov, aggr, tick, line, boss) — новые только `st`, `tired`; - имена: новые глобалы `forceBite` (let) и `fishForce` (function) — в списке защищённых имён AGENTS.md. ### 4.3 План тестов **Правка существующих (тот же коммит):** - `tests/test.js`: во всех местах `biteTimer = 0.05` (≈15 шт: §§3, 4b, 4c, 5, 6, 9a, 9c, 9d, 9h, 9j(c)(d), 9k, 9l(1,2b,3), 9f) добавить `forceBite=true`; после секций, где форс включён, сброс `forceBite=false`. Удобнее — хелпер `forceBiteNow(page)`. - `tests/orca-playtest.js`: то же в ~5 местах (тот же приём с `biteT=5`). **Новые проверки (+5 → 66/66):** 1. `stamina: reel.st есть и убывает в бою` — старт REEL, `st0 = reel.st`, держать 2 с → `reel.st < st0`. 2. `stamina: усталая рыба слабее` — `fishForce` глобальна: `st=1 → F1`, `st=0.05 → F2`; проверить `F2 < F1*0.6` и `reel.tired===true` после установки `st=0.1`. 3. `drag: рывок сильнее drag скользит леску` — форс крупной рыбы (`w=12, ov, aggr=1, st=1, phase='pull', phaseT=9999, holding=true`), замер `d` через 1 с: `d > d0` (отдача) и `tension > rod().drag` (нагрузка ушла за черту). 4. `drag: рывок слабее drag — втягивание` — мелочь (`w=0.3, aggr≈0.2, st=0.9, phase='pull', phaseT=9999, holding=true`): через 1 с `d < d0` (втягиваем, скольжения нет) и `tension < rod().drag`. 5. `пустой клёв: без forceBite и с Math.random=0.99+ клёва нет` — `state='WAIT'`, `biteTimer=0.05`, подменить `Math.random` (как в M3 (b)), 700 мс → `state==='WAIT' && !biteFish`; вернуть `Math.random`. **Регресс-гарант:** все 61 существующих без правок формул исходов — прогон 61 + 5 = 66. M4-блок (5 проверок) не правится вовсе — он и есть проверка, что босс-фазы не сломаны новой физикой. ### 4.4 Приёмка живым плейтестом (orca-playtest, боты) Добавить в `tests/orca-playtest.js` (или отдельным прогоном T5-стиля) сравнение трёх ботов × N=100+ рыб: | Бот | Поведение | Ожидание | |---|---|---| | «умный» | держать в calm, отпускать в pull (реакция 140 мс) | мелочь 80–95% улов, крупная 50–80%, босс 30–70% | | «тупой» | всегда держать | мелочь 30–50% (SNAP у крупных), крупная 0–20% | | пассив | никогда не держать | 100% сходов за 3–8 с | Плюс: доля пустых забросов 10–35%; медиана времени боя: мелочь 8–15 с, крупная 20–40 с. Критерий «не too_easy»: хотя бы один исход ≠ улов в каждом типе бота, и босс не 100%. ### 4.5 Оценка объёма | Файл | Строк | |---|---| | `index.html` (логика + UI + текст) | ~130 | | `tests/test.js` (правки + 5 новых) | ~45 | | `tests/orca-playtest.js` (правки + боты — опционально во 2-й итерации) | ~15–60 | | **Итого** | **~190–235** | Влезает в ориентир 150–300. Порядок работ внутри задачи: (1) P0-2 drag + fishForce, (2) P0-1 stamina на новый каркас, (3) P0-3 подгонка чисел по прогонам ботов, (4) P0-4 пустые клёвы + forceBite и правка тестов, (5) `node tools/check.mjs` + `npm test` в `tests/` (66/66) + живой плейтест. ### 4.6 Риски и откаты - **Чувствительность M4 p2/p3 (±5% d):** единственная «впритык» точка. Страхуем тем, что в кейсах p2/p3 палец отпущен (holding=false) и фаза calm — `d` статичен, втягивание `(11+3·reel)·(1+0.6·(1-st))` в проверках M4 не участвует. Если тест всё же дрогнет на медленной CI — первый кандидат на локальное увеличение допуска в M4 (согласовать в коммите). - **Флик пустых клёв в тестах:** исключён `forceBite` (детерминированный путь, как у `forceBoss`). - **Сложность для новичка:** если плейтест покажет <60% улова мелочи у «умного» бота — ослабляем drag (34→40 на 1-й удочке) или `drain` (×0.9); числа вынесены в формулы, правка = одна строка. - **Откат:** P0-4 (forceBite) и P0-1/P0-2 независимы логически; при проблеме с drag достаточно вернуть старое ядро updateReel (git-патч), выносливость и пустые клёвы останутся. ### 4.7 Т2b: ребаланс Monte-Carlo (приёмка до мержа) Первый P0-коммит (70ff964) провалил приёмку 4.4: «умная» стратегия (держать в calm, отпускать в pull) теряла 98–100% босса (свежий рывок 2.5–3.9 с длиннее escape-окна 1.7–2.3 с), «всегда держать» давало 100% улова у босса на Титане/Карбоне, бои 1.3–7.2 с вместо 8–50 с, stamina-дуга не видна. Т2b подогнал числа (только константы 4.2): | Параметр | Было (P0) | Стало (T2b) | Зачем | |---|---|---|---| | `d0` | `40+3.5·w` | `45+7·w` | буфер дистанции под «сдавать в рывок» | | escape при `pull&&!holding` | `+(0.2+0.4·ag)` | `0` | отпускание в рывке безопасно | | free-run | `F·0.4` | `F·0.32` | стоимость отпускания — дистанция | | втягивание | `(22+8·reel)·(1+0.35·(1-st))` | `(11+3·reel)·(1+0.6·(1-st))` | бои длиннее; усталая — до ×1.6 | | drain | `0.05+0.16·ag / 0.008 / …` | `0.016+0.02·ag / 0.002 / …` | дуга усталости за бой, а не за фазу | | натяжение | `8+over·1.1+14·ag·…` | `10+over·1.1+22·ag·…` | «всегда держать» обрывает крупных | | near-miss | `1.4·d0` | `1.45·d0` | окно на «сдачу» в сильном рывке | Приёмка (точные формулы index.html, N=400/сценарий, dt=1/60 с, три бота 4.4; «умный» = держать calm/отпускать pull, реакция 140 мс): | Сценарий | умный (улов/SNAP/сход, t_med) | держать | пассив | |---|---|---|---| | карась/Дедова (мелочь) | 98% / 0 / 2% — 13.0 с | 100% улов, 2.9 с | 100% сход, 5.9 с | | окунь/Дедова (мелочь) | 85% / 0 / 15% — 11.7 с | 100% улов | 100% сход | | лещ/Стекло | 92% / 0 / 8% — 14.1 с | 100% улов | 100% сход | | сом/Карбон (крупная) | 66% / 0 / 34% — 20.4 с (уловы) | 79% / SNAP 21% | 100% сход, 5.7 с | | сом/Стекло (крупная, слабая) | 39% / 0 / 62% | 39% / **SNAP 61%** | 100% сход | | босс/Титан | **59.5%** / 0 / 40% — 42.8 с | 62% / **SNAP 38%** | 100% сход, 6.7 с | | босс/Карбон | 0% / 0 / 100% (недогрейд) | 40% / SNAP 60% | 100% сход | Смысл метрик: умный >80% мелочи ✓, крупная 50–80% ✓ (66% на Карбоне; blended t_med по всем исходам 15.9 с — низ из-за 34% ранних сходов, медиана уловов 20.4 с в окне 20–40 с), босс <100% на топ-удочке ✓ (59.5%); «всегда держать» на крупной у слабого удилища — SNAP ✓ (61%), у босса — SNAP ✓ (38%); пассив — 100% сходов ✓. Stamina-дуга видна: st при улове 0.2–0.5 (крупная/босс 0.0) против старта 0.7–1.0. Сознательно оставлено в P1: - **P1-2 (обязательный follow-up)** — детализация риска босса: drain×0.8, F×1.15, рывки×1.3, «погружение», тест «босс можно потерять» (боевой обрыв, не фазовый). Базовый риск T2b уже даёт (умный: 40% сходов на Титане, «всегда держать»: 38% обрывов); босс на недогрейде (Карбон/Стекло, maxW 8–18 против 5–40 кг) — 0% улова у любого бота: это про «грейдись, прежде чем лезть на осетра», а не баланс; - «всегда держать» на мелочи = 100% уловов (2.9 с) — мелочь на своей удочке честна и без риска (цель 4.4 «30–50%» недостижима при умных 8–15 с: dumb-время = d0/скорость ≤ умного-времени); - «тупой» бот не читает черту drag (держит всегда) — продвинутая стратегия «держать, пока F