squad-proto/docs/17-extraction.md
z.kirill ece906cbb3 Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.

NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.

Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:

  отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
         одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2

Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.

Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.

Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.

--accept PASS без изменений: без профиля отряд по-прежнему впятером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:29:19 +03:00

365 lines
27 KiB
Markdown
Raw 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.

# 17 — Экстракшен: спуск как ставка
## Что изменилось в игре
До этой вехи глубина была прогрессом: чем дальше, тем лучше. Теперь она
**ставка**. Отряд уходит вниз с тем, что нажил, и каждый этаж — заново заданный
вопрос: ещё ниже или назад, пока есть что выносить.
Три правила, из которых следует всё остальное:
1. **Унести можно только то, что на выживших.** Павший забирает своё с собой.
2. **Полный вайп не возвращает ничего.** Снаряжение сбрасывается к стартовому.
3. **Достигнутая глубина остаётся навсегда.** Она открывает чекпоинты — и это
единственное, чего смерть не отнимает.
Третье правило — ответ на «а если этажей десять тысяч». Проходить их заново
каждую вылазку не нужно; терять при этом можно только снаряжение.
## Два выхода с этажа
| | Где | Клавиша | Что делает |
|---|---|---|---|
| **спуск** | самая дальняя достижимая точка | `F` | следующий этаж, отряд как есть |
| **эвакуация** | там, где отряд вошёл на этаж | `E` | забег окончен, добыча в схроне |
Эвакуация совпадает с точкой входа не из экономии: это лестница наверх, и в этом
вся суть напряжения. Спуск лежит на дальнем конце этажа — решив уйти, отряд идёт
обратно через всё, что успел растревожить, раненый и с полными руками.
Кольца на полу разного цвета: зелёное — вниз, голубое — наверх. Оба видны даже
в полной темноте. Спутать их означает уйти не туда, и цена ошибки здесь — весь
забег, поэтому подсказки в HUD подкрашены теми же цветами.
Ни то ни другое не срабатывает от захода на клетку. Спуск и эвакуация — решения
игрока: этаж не зачищен, лут не собран, уходить рано — это нормальный выбор, и
отряд может стоять на выходе сколько угодно.
## Профиль
[src/sim/profile.h](../src/sim/profile.h) — всё, что переживает забег:
```
maxdepth 13
runs 6
extractions 3
agent 0 cleaver plate charm bandage stimpack - - - -
stash saber plate vest smg bow charm lantern spurs
```
- **снаряжение пяти бойцов** лежит здесь, а не собирается заново при старте.
То, в чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки;
- **схрон** — общий запас сверх надетого, потолок `STASH_CAP`. Без потолка
экстракшен превращается в склад, и решение «что взять вниз» исчезает;
- **максимальная глубина** открывает чекпоинты.
Профиль — состояние ИГРОКА, а не мира: он не в `ecs::World` и не сбрасывается ни
`Reset`, ни `Descend`. Записывается он только в конце забега — авария посреди
вылазки означает потерю, и это часть контракта, а не недоделка.
Формат текстовый по той же причине, что у комнат: файл лежит рядом с игрой, его
правят руками при отладке, и он обязан читаться глазами.
## Чекпоинты
Каждые `CHECKPOINT_STEP` (5) этажей. Достигнутая глубина открывает все чекпоинты
не глубже неё: дошёл до 13-го — доступны D1, D6, D11.
![выбор глубины](ui-depth.png)
Рекорд глубины засчитывается **любым исходом**. Дошёл — значит дошёл; требовать
за чекпоинт ещё и удачную эвакуацию значило бы наказывать дважды за одну смерть.
Пять — это «достаточно редко, чтобы дойти до следующего было событием, и
достаточно часто, чтобы потеря забега не откатывала в самое начало».
## Сумка одна на отряд
Раньше каждый боец таскал свои шесть ячеек, и это порождало проблему, которую
нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась только
ему и лежала мёртвым грузом до конца забега. Перекладывать между бойцами игра не
умела.
Теперь сумка одна — шестнадцать ячеек на пятерых. Личным остаётся **надетое**:
руки, броня, карман. Их не разделить, они и есть «кто этот боец».
**Вес делится на живых.** Потерять бойца — значит взвалить его долю на
остальных: обратно наверх отряд пойдёт медленнее, чем шёл сюда. Это не побочный
эффект, а то, ради чего сумка общая.
Правило «унести можно только то, что на выживших» этим не ослаблено, оно стало
точнее: надетое павшего теряется по-прежнему, а общая ноша на нём не висела —
её тащил отряд. Вайп по-прежнему не выносит ничего: тогда выживших нет вовсе, и
сумку некому донести.
## Схрон и сборы
![экран отряда со схроном](ui-squad-stash.png)
Экран отряда (`I`) стал местом сборов: снаряжение бойца слева, общая сумка
справа, схрон двумя рядами внизу. Предметы перетаскиваются мышью, `Q`/`E`
листают схрон, правая кнопка по вещи выбрасывает её под ноги.
**Наведи — и увидишь, что это.** Подсказка под курсором показывает вес и все
эффекты предмета, а на строках характеристик объясняет, откуда берётся число.
До неё вещь была именем и цветным квадратиком: чем VEST отличается от JACKET,
знал только каталог в коде. С сумкой на шестнадцать мелких ячеек это перестало
быть терпимым — в ячейку не влезает даже имя.
Схрон полон — обмен не состоится целиком, и вещь останется в слоте. Молча терять
её нельзя: «некуда положить» обязано быть видно.
## Вес: что унести
Шесть ячеек в сумке отвечали на вопрос «сколько вещей», но не на вопрос «чего
они стоят»: шесть бинтов и шесть плит обходились одинаково. Теперь у каждой
вещи есть вес, а у бойца — два порога.
| | |
|---|---|
| до `CARRY_LIMIT` (7.5) | отряд идёт как шёл |
| дальше | тем медленнее, чем больше нагрёб, до 55% на пределе |
| выше `CARRY_MAX` (14) | не поднимет: вещь остаётся лежать |
Порогов ДВА намеренно. Один жёсткий предел означал бы «сумка кончилась» — то
же самое, что счёт ячеек, только считать труднее. Настоящий выбор возникает
там, где лишнее взять МОЖНО, но за это платят: а идти с этим — обратно через
весь этаж, который уже растревожен.
**Надетое не считается.** За броню уже заплачено множителем скорости (плита —
0.82), и считать её вес вторично значило бы наказывать за один выбор дважды.
Хуже того: плита съедала бы почти весь запас, и боец в тяжёлой броне не мог бы
поднять вообще ничего — вместо решения вышел бы запрет. А вот плита, НАЙДЕННАЯ
и лежащая в сумке, весит полной мерой: тащить её наверх — это и есть выбор.
Выбросить лишнее можно правой кнопкой по ячейке на экране отряда: вещь ложится
под ноги и подбирается обратно. Из рук бросать нельзя — безоружный боец не
бежит быстрее, он просто перестаёт быть бойцом.
## То, что кончается
Наверх гонит не таймер. Часы заставляли бы торопиться механически, а это
хоррор: давить должно **исчерпание**, а задача — уйти как можно глубже.
**Патроны конечны.** Запас (`startAmmo`, 120 на бойца) выдаётся на вылазку и
едет вниз вместе с отрядом. Перезарядка тянет из него; кончился — стрелок
молчит, и перезарядка даже не начинается: боец с пустым стволом не изображает
работу. Восполнить можно только находкой — пачкой на 40 патронов.
**Расходники применяются сами.** Ручных действий в игре нет (спек 5), поэтому
бинт и пачка — не кнопка, а поведение: боец перевязывается, когда ранен ниже
`healAtFrac` и рядом никого нет, и набивает подсумок, когда стрелять нечем.
Лечиться в схватке нельзя — перевязка посреди боя обесценила бы урон, который
тварь только что нанесла.
До этой вехи `heal` у бинта был мёртвым числом: расходники не применялись
нигде, и бинт был просто грузом. С весом (выше) он стал грузом буквально.
Патроны нужны тому, у кого **вообще** есть ствол, а не только тому, кто держит
его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы
найденную пачку до конца забега. Это нашла проверка, а не глаз.
### Свет: второй ресурс, и тратится он за ПУТЬ
Фонарь садится. Полного заряда (`LIGHT_FULL`, 420) хватает примерно на
**четыре прямых пути до спуска** — то есть на этаж с осмотром, но не на два
этажа подряд. Кончился — обзор падает до `DARK_SIGHT_MUL` (35%), а твари
начинают замечать отряд в `DARK_AGGRO_MUL` раз дальше. Отряд не слепнет: он
меняется с тварями местами, и первым теперь видит не он.
**Главное решение — заряд тратится за пройденное расстояние, а не за время.**
Расход по секундам был бы таймером, только без циферблата: садящийся сам собой
фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого.
Здесь думать бесплатно. Стоять и разглядывать — бесплатно. Платить приходится
за то, что **обошёл этаж целиком**, а не за то, что задержался.
Получается тот же размен, что и с весом, только с другой стороны:
| ресурс | что он делает дорогим |
|---|---|
| вес | унести всё |
| свет | обшарить всё |
Прямая дорога к спуску дёшева обоими. Полная зачистка — дорога обоими сразу.
Чем глубже, тем дороже шаг (`LightDrain`): на сотом этаже заряда хватает вдвое
меньше, чем на первом, а сами этажи там длиннее. Заряд едет вниз вместе с
отрядом и **не обновляется на новом этаже** — иначе он был бы ресурсом этажа, а
не вылазки, и никуда бы не гнал.
Батарея (0.3 веса) — расходник по тем же правилам, что бинт и пачка: встаёт в
фонарь сама, в тот же шаг, когда свет погас. Держать её в сумке при погасшем
фонаре отряду незачем, а темнота ради темноты — не решение игрока.
Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом.
Иначе «сел» означало бы «стал слабее», а не «кончился».
## Отряд из одного
![экран улучшений](ui-upgrades.png)
Игра начинается **одним оперативником-исследователем**: тактический нож, слабый
фонарь, куртка. Не отрядом — человеком. Остальные четверо нанимаются за опыт,
и это самая дорогая покупка в игре: 600 / 2400 / 9600 / 38400, всего 51000
против дохода в 726 за этаж на десятом (`--tool economy`).
Дорого намеренно. Одиночка с ножом — это первое впечатление от игры, и продать
второго бойца за одну вылазку значило бы выбросить весь хоррор начала: вдвоём
уже не страшно. По кривой второй приходит через две-три ранние вылазки, третий
— когда отряд уверенно берёт десятый этаж, пятый — ближе к двадцать пятому.
Замерено в `--descend`: одинокий оперативник с ножом на первом этаже **выживает
и убивает двух тварей, теряя 59 из 100 здоровья**. Это ровно та цена, ради
которой всё и затевалось: он справляется, но после второй схватки решение
«ещё этаж или наверх» принимается всерьёз.
Внутри кода размер отряда — это ДВА разных понятия, и путать их нельзя:
| | что значит | кто читает |
|---|---|---|
| `NUM_AGENTS` | сколько слотов БЫВАЕТ (5, время сборки) | все циклы, массивы, метрики |
| `Squad::roster` | сколько слотов ЗАНЯТО сейчас | сборка этажа |
| `Agent::present` | этот слот занят человеком | рендер, HUD, экраны, профиль |
| `Agent::alive` | этот человек ещё жив | правила боя, движение, твари |
Правила боя читают только `alive`: для них пустой слот и павший неразличимы, и
это верно. Разница нужна там, где показывают ЧЕЛОВЕКА — у павшего есть тело на
полу и перечёркнутая карточка, у пустого слота нет ни того, ни другого. Иначе
игрок с одним оперативником видел бы вокруг него четыре чужих трупа.
Делать `NUM_AGENTS` динамическим не стали: это сто с лишним мест ради числа,
которое всё равно упирается в пять.
## Во что вкладывается опыт
![экран улучшений](ui-upgrades.png)
Опыт копился с вехи M12 и до сих пор был честной заглушкой: число, которое
ни на что не влияет. Теперь он тратится — на четыре улучшения отряда.
| | что даёт | почему это |
|---|---|---|
| AMMO RESERVE | +20 патронов на вылазку | |
| CARRY LIMIT | +1.5 до перегруза | |
| SIGHT RANGE | +6% обзора | |
| VIGOR | +12 здоровья | |
Все четыре — **ресурсные**, и ни одного силового. Игра про исчерпание, и
вкладываться в ней надо в то, что кончается: отряд, который бьёт вдвое сильнее,
перестаёт бояться, а страх тут единственная механика, которую нельзя
восстановить балансом.
Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре остаётся
снаряжение, и второе личное измерение превратило бы сборы в бухгалтерию на
пятерых.
Опыт зачисляется только за **успешную эвакуацию** (`FinishRun`): вайп не
приносит ничего. Это та же ставка, что и с добычей.
Приёмки это не касается вообще: улучшения живут в профиле, а `--accept` его не
подключает — там нет ни снаряжения, ни улучшений, и критерии меряются на том
же, на чём мерялись всегда.
## Итог забега
![итог вылазки](ui-summary.png)
Две цифры, ради которых экран и существует: **сколько вынес** и **сколько
осталось внизу**. Плюс глубина, выжившие и открытый чекпоинт.
Забег закрывается ДО показа экрана — в момент вайпа или нажатия `E`. Симуляция
при этом ничего не решает: она только отмечает `atExit`/`atExtract`, а конец
забега объявляет UI. Смена мира посреди шага, между движением и стрельбой,
оставила бы половину систем со ссылками на снесённые сущности.
## Как играть
```bash
squad_proto.exe --rooms
```
`NEW RAID` → выбрать чекпоинт → вылазка. `I` перед выходом — разобрать схрон.
| Ключ | Что делает |
|---|---|
| `--profile <файл>` | другой профиль (полезно для проверок) |
| `--no-profile` | не читать и не писать профиль вообще |
Без `--rooms` игра работает как раньше: одна карта, никакого забега. Приёмка и
`--headless` профиль не подключают — критерии меряются на прежнем снаряжении.
## Проверки
```bash
squad_proto.exe --descend 7 --no-profile
```
Прогон проверяет всю цепочку: спуск по этажам, перенос сумок, эвакуацию,
**круговой прогон профиля через запись** — и правило переноски.
Переноска проверяется на ВЫДУМАННОМ инвентаре, а не по грузу в прогоне: в сумки
кладутся бинты, чтобы проверить перенос между этажами, и по ним вес выглядит
втрое меньше настоящего. Первые две попытки выставить пороги делались как раз
по этой цифре — и обе дали числа, которые не ограничивали ничего.
```
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
перезарядка: запас 40 -> 16 (магазин 24)
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге (прошли 12.7, продвинулись 1.6)
глубина 8: заряда на 383 тайлов, это 4.0 прямых пути до спуска (средний 95, здешний 155)
обзор 1.00 -> 0.35 в темноте, батарея встала сама
переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1
```
Расход патронов проверяется тут же и отдельно: спуск ни с кем не воюет, столбец
«патронов» в таблице стоит на месте — и без прямой проверки бесконечный
боезапас выглядел бы ровно так же.
У света первая строка проверки — **не про баланс, а про жанр**: заряд на
стоянке обязан стоять. Написать таймер случайно очень легко (одна строка со
`dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково.
Расход сравнивается с числом из каталога, и первая версия проверки показала
1.55 за тайл при 1.10 в правилах. Расходился не свет, а измеритель: он делил
потраченное на **смещение** отряда, а отряд шёл не по прямой. Считать надо путь
пошагово — тогда сходится до сотой.
Хватает ли заряда на этаж — считается в **этажах**, а не в тайлах, и по средней
длине этажей забега: путь до спуска скачет вдвое от сида, и порог, выставленный
по одному этажу, ловил бы удачу, а не баланс.
```
7 RUST 13 19 3 149 живых 5, в сумках 30
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6
профиль: запись и чтение сходятся
```
Круговой прогон здесь не для полноты: запись профиля — единственный путь,
которым игра может стереть всё нажитое, и ошибка в нём выглядит как «схрон
куда-то делся», а не как падение.
### Как круговой прогон три вехи подтверждал то, чего не было
Он сверял три поля из десяти — глубину, схрон и снаряжение — и всё это время
`Profile::Save` **не писала ни версию формата, ни улучшения**. То есть купленный
запас патронов действовал до конца запуска и молча пропадал при следующем
чтении, а профиль без строки `version` читался как формат 1 и на каждом выходе
прогонялся через все миграции. Пока миграции были пустыми, это ничего не портило
и потому не всплывало; с приходом размера отряда такая запись начала бы
возвращать игроку пятерых бойцов после каждой вылазки.
Отсюда правило: **круговой прогон сверяет поля списком, и список растёт вместе с
форматом**. Выборочная сверка проверяет не формат, а память того, кто её писал.
И второе: прогон делается на НАБИТОМ профиле. Пустое поле сходится с пустым при
любой поломке записи — «0 == 0» подтверждало работу того, чего в файле не было.
## Чего ещё нет
- **Схватки за добычу.** В настоящем экстракшене выход можно не отдать —
здесь на этаже нет никого, кто охотится именно за вынесенным.
- **Страховки и торговли.** Схрон только копится; девать его некуда.
- **Траты опыта** — она по-прежнему ждёт своей вехи
([16-descent.md](16-descent.md)).