Files
fishing-game/docs/PHYSICS-RESEARCH.md
andrey 126e402dee Экономика: ушедшие рыбы без наград — выплаты 10% цены убраны из обеих веток схода в updateReel() (escape>=1 и near-miss 1.45·d0), общий хвост вынесен в fishEscaped() (тост «Сбежала! <emoji> <имя> <вес> кг» → WAIT → biteTimer → sfx('escape')), устаревший near-miss-комментарий удалён; формат сейва не менялся. Тост обрыва: теперь с рыбой («💥 Линия лопнула! 🐟 Карась 0.5 кг» / «💥 Рыба крупнее предела удочки! …», разбор ov>1.15 сохранён); босс-обрыв фазы не тронут. Закрывает камень #12 из Этапа T4 (утешения за сходы = 43–47% раннего дохода, фармить сходы было выгоднее). Тесты: §9c переписан (пин save.quests/стрика — монеты не изменились; тост «Сбежала!» с весом без «🪙»), (2b) переиспользован (сход монет не даёт, HUD в согласии с сейвом), +1 чек тоста обрыва (чтение в следующем eval сразу после waitState('SNAP'): тост 1.6 с > SNAP 1.0 с); orca шаг 9 — «сход без выплат». 129/129, exit 0; Orca 61/61 live. Доки: DEVLOG Этап 23, PHYSICS-RESEARCH исходы, README/AGENTS 128→129.
2026-09-07 13:15:28 +03:00

657 lines
47 KiB
Markdown
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.
# Спринт 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` (4 приманки): `cast` (дальность), `bite` (частота клёва), `rare`
(шанс редких/крупных). Поле `nightOnly` удалено (M5): фонарик — не
приманка, а апгрейд 1 любого удилища (2+ уровень, `hasLight()`).
- `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` — ролл `forceBite || Math.random() <
biteChance()` → `startBite()`, иначе новый таймер `rand(2.5,5)/lure().bite`
(пустые клёвы ЕСТЬ: «молчание» ~10–35% забросов, ночью тише — `×0.85`).
Ночью без фонарика (M5: апгрейд 1 удилища) таймер тикает вхолостую —
клёв не стартует.
- `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: «Сбежала!» (без выплат, Этап 23)
escape >= 1 → сбежала: «Сбежала!» (без выплат, Этап 23)
```
### 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% высоты шкалы): «выше черты — леска скользит,
сдавайся или готовься к обрыву». Иконка над шкалой: 💪 (держишь, F<drag),
⚡ (рыба тянет), 🌀 (скольжение, over>0). Рывок визуально: поплавок/линия
дёргаются, леска «прямая» при 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 с |
| босс/Титан lv4 + Блёстка lv4 (M5 max) | **43.5%** / 0 / 56.5% — 55.2 с | 77.3% / **SNAP 22.8%** — 25.8 с | 100% сход |
| босс/Карбон | 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<drag» не формализована (кандидат P1-3).
---
## 5. Источники
- Wikipedia, «Fishing reel»: разделы *Drag mechanisms* (принцип: drag —
потолок натяжения; шпуля отдаёт леску, пока нагрузка не упадёт ниже drag),
*Setting the drag* (drag ≈ ⅓…½ разрывной нагрузки лески; пример 20 lb →
7–10 lb), *Anti-reverse reel*, *Braking mechanisms* (feathering шпули).
- DEVLOG.md, этап 14 (спринт 3): вердикт T5 too_easy (0 сходов/329, босс
18/18), «вываживание и ×5 босса требуют правки логики».
- Паттерны симуляторов (Fishing Planet, Fishing Sim World, Stardew Valley
tug-of-war, мобильные Angler/Lure) — по описанию механик: drag-настройка,
stamina рыбы, «walk the fish», зона-перетягивание. Точные формулы
сторонних игр не цитируются — числа P0 подобраны под наши тесты и
целевые метрики 4.4, и перепроверяются живым плейтестом.