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

47 KiB
Raw Permalink Blame History

Спринт 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, и перепроверяются живым плейтестом.