diff --git a/BOTBRAIN.md b/BOTBRAIN.md
new file mode 100644
index 0000000..5714f19
--- /dev/null
+++ b/BOTBRAIN.md
@@ -0,0 +1,88 @@
+# Что взять у чужих ботов (разбор исходников)
+
+Скачано и просмотрено: **L2jRoboto** (серверные фантомы, GPL-3), **SmartBot** (наш прошлый референс, GPL-3), **L2Bot2.0** (клиентская фармилка, C#, инъекция в клиент), **L2NET** (клиентский бот со своим скриптовым движком). Ничего не копируем построчно — берём модель поведения (см. «Лицензии» внизу).
+
+## Кто чем полезен
+
+**L2jRoboto** — ближе всего к нашей задаче (он тоже поднимает Player на сервере). Полезное:
+
+- на каждый класс свой AI-класс со своим набором заклинаний: `DuelistAI`, `CardinalAI`, `SaggitariusAI`, `SoultakerAI`, ~20 классов;
+- заклинания описаны как данные с приоритетом: `OffensiveSpell(345, 1)`, `HealingSpell(1218, TARGET_ONE, 70, 3)` — «лечить, когда цель ниже 70% HP», приоритет 3;
+- отдельные списки: атакующие / лечащие / self-support / бафы; выбирается первое доступное по приоритету;
+- шоты и стрелы подбираются по уровню (таблица уровень → грейд) и авто-подкачиваются;
+- смерть: N тиков лежит, потом «в деревню»;
+- ограничение: цель выбирается случайно в радиусе, никакой оценки опасности; фактически это красивая массовка.
+
+**L2Bot2.0** — настоящая фармилка, и вот тут «понимание игры». Полезное:
+
+- **конечный автомат состояний**: `Dead → Idle → FindTarget → MoveToTarget → Attack → Rest → MoveToSpot` с явными условиями переходов;
+- **выбор моба по правилам**: списки включённых/исключённых id, ограничение по разнице уровней (`MobLevelLowerLimit/UpperLimit`), проверка перепада высоты (`MobsMaxDeltaZ`, чтобы не бить недосягаемых), проверка «внутри зоны фарма»;
+- **скиллы по условиям**: `SkillCondition { id, MaxTargetPercentHp, MinPlayerPercentMp, MaxPlayerPercentHp }` — то есть «добивание только когда цель ниже X%», «не кастовать, если мало МП»;
+- **отдых**: сесть при HP/MP ниже порога, встать при достижении верхнего порога — сидя реген в разы быстрее;
+- **дистанция боя** зависит от оружия (лук/ближний) и от дальности выбранного скилла;
+- **приоритет спойла** и свипа, счётчики попыток;
+- **подбор лута** с фильтрами и радиусом;
+- **возврат на спот**, если ушёл далеко.
+
+**L2NET** — интересен своим скриптовым движком (условия/циклы поверх пакетов), но для нас это оверкилл.
+
+## Чего не хватает нашим ботам (итог)
+
+| Механика | У нас | Откуда берём |
+|---|---|---|
+| Скиллы по условиям (HP цели, МП, приоритет) | нет, только автоатака | L2Bot2 SkillCondition + Roboto Spell-списки |
+| Наборы скиллов по классам | нет | Roboto (AI на класс) |
+| Отдых сидя при низком HP/MP | нет | L2Bot2 RestState |
+| Выбор моба по уровню/высоте/зоне | берём ближайшего | L2Bot2 Helper.GetMobsToAttackByConfig |
+| Дистанция боя по оружию и скиллу | нет | L2Bot2 GetAttackDistanceByConfig |
+| Самобаф и поддержание бафов | нет | Roboto applyDefaultBuffs |
+| Шоты по грейду + автопополнение | грейд есть, автопополнения нет | Roboto handleShots |
+| Бегство при низком HP, страх ПК | нет | своё |
+| Роли в пати (хил/баф/танк/спойл) | нет | Roboto (IHealer) + L2Bot2 (spoil/sweep) |
+
+## План: BotBrain
+
+Дерево приоритетов, каждый тик сверху вниз, первое сработавшее выигрывает:
+
+1. **Мёртв** → лежать, через N сек в деревню, вернуться на спот.
+2. **Выживание** → HP < 30%: хилка/скилл лечения; HP < 15% и рядом враг: бежать в город/к стражам.
+3. **Обслуживание** → нет бафов: бафнуть; нет шотов: докупить; HP/MP < порога и никого рядом: сесть отдыхать.
+4. **Роль в пати** → хилер лечит самого раненого, бафер обновляет бафы, танк держит агро.
+5. **Бой** → скилл по условиям (дебафф → бурст → добивание), иначе автоатака; держать дистанцию по оружию.
+6. **Лут** → подобрать свой дроп рядом.
+7. **Фарм** → выбрать моба (уровень ±, высота, зона), подойти, атаковать.
+8. **Безделье** → маршрут по зоне, болтовня, торговля.
+
+Наборы скиллов держим **данными** (XML), а не кодом: `` — можно править без пересборки, как швы.
+
+## Что уже сделано (первая итерация)
+
+**`FakePlayerBrain`** — дерево приоритетов, работает раньше «бытовой» логики (торговли, маршрутов), поэтому бот не пойдёт торговать, пока его бьют:
+
+1. **Выживание**: HP < 55% — лечащий скилл; < 40% — хилка (раз в 15 сек); < 25% и рядом враг — бегство в противоположную сторону на 12 секунд.
+2. **Обслуживание**: HP < 45% или MP < 35% и никого рядом — сесть отдыхать (встаёт на 85% HP / 75% MP); раз в 3 минуты обновить самобафы.
+3. **Роль в пати**: хилер лечит самого раненого соклановца в радиусе 900, если у того ниже 75% HP.
+4. **Бой**: скилл выбирается по условиям из XML (приоритет, `maxTargetHp` для добивания, `minMp`), проверяется кулдаун, МП и дальность каста; лучники и маги **отходят**, если враг ближе 250, бойцы наоборот сближаются.
+5. **Фарм**: цель выбирается с умом — разница уровней не больше 8, перепад высоты меньше 200 (чтобы не долбиться в недосягаемого), не рейд, **не чужая цель**.
+
+**`data/FakePlayerCombat.xml`** — наборы скиллов по классам (13 профилей + дефолт), правится без пересборки:
+
+```xml
+
+
+
+
+
+```
+
+`role` (fighter / archer / mage / healer / support) задаёт дистанцию боя и приоритеты.
+
+**Проверка**: `do=brain&name=Ник` — покажет роль, HP/MP и какие скиллы из профиля бот реально выучил (`+` есть, `-` нет). Плюс `do=gear` — что надето и штраф экспертизы.
+
+**Не проверено вживую**: песочница в этот раз убивала процессы, поэтому сборка чистая (BUILD SUCCESSFUL), но запуск с ботами не прогнан. Первым делом посмотри `logs -f game` на строку `FakePlayerCombatData: Loaded combat profiles for 13 classes.`
+
+**Следующий заход**: роли танка и спойлера, покупка расходников у НПС, бегство к стражам от ПК, смена профессии на 20/40.
+
+## Лицензии
+
+L2jRoboto и SmartBot под GPL-3, L2Bot2.0 — тоже открытый. Мы **не копируем их код**, а реализуем поведение заново поверх Mobius. Это важно, если когда-нибудь захочешь выложить пак: копирование GPL-кода потребовало бы открыть исходники всей сборки.
diff --git a/BOTS.md b/BOTS.md
new file mode 100644
index 0000000..7b95cb0
--- /dev/null
+++ b/BOTS.md
@@ -0,0 +1,155 @@
+# Боты: что сделано и как этим пользоваться
+
+Сборка от 2026-08-25 (пак `server_interlude.zip`). Население 1–76 с настоящей прокачкой, всё включено по умолчанию.
+
+## Что в паке
+
+**Геодата** — 172 региона `.l2j` уже лежат в `game\data\geodata`, `PathFinding = 2` включён. Боты и мобы обходят стены и ходят по реальному рельефу.
+
+**5000 персонажей, уровни 1–76, 34 зоны** (замер: ~22% одного слабого ядра на все 5000, масштаб почти бесплатный) — от стартовых полей всех рас (Talking Island, Elven Forest, Shadow Land, Orc Lands, Dwarven Valley) через Wasteland, Cruma, Execution Grounds, Sea of Spores, Plains of Glory до Blazing Swamp, Wall of Argos, Ketra и Varka. У каждого — ник, пол, внешность, класс из 11 архетипов (гладиатор, паладин, разрушитель, кинжальщики, лучники, три школы магов, бишоп) и шмот по грейду. Стартовые уровни раскиданы по всей кривой, так что мир населён сразу.
+
+**Настоящая прокачка.** Боты получают опыт с реально убитых мобов (реальные exp-значения мобов из датапака, рейт `FakePlayerXpRate`, по умолчанию x3). Уровень и опыт хранятся в таблице `fake_player_progress` и переживают рестарт. 8 тиров (1–14, 15–24, 25–34, 35–44, 45–54, 55–63, 64–71, 72–76): при переходе тира бот пишет в чат «апнулся, ушёл за шмотом», исчезает и через 1–3 минуты появляется в зоне своего нового уровня — с новой профой (варриор → глад → дуэлист) и новым грейдом шмота (NG → D → C → B → A → S). Кап 76. То есть встреченный на Talking Island новичок через пару дней реально будет фармить в Cruma, а потом в Blazing Swamp.
+
+**Четыре характера** (параметр `fpcPersonality`):
+
+- `ganker` (~25%) — фармит и нападает на игроков в радиусе 1000, кидает дерзкую реплику. Ганкают на любом уровне, включая стартовые поля — но только там, куда бот такого уровня и расы мог реально прийти.
+- `pkk` (~10%) — охотник на ПК: белых не трогает, но нападает на фиолетовых (PvP-флаг) и красных (карма). Убил ганкера у новичков — жди комментарий про правосудие.
+- `helper` (~15%) — видит твой бой — прибегает и помогает, комментирует.
+- `neutral` (~50%) — мирно фармит и болтает. Все, кроме ганкеров, тоже сами бьют мобов (иначе бы не качались).
+
+**Правдоподобность появления**: стартовые локации расовые — на Talking Island качаются только люди, в Elven Forest эльфы, у орков орки и т.д. (бот другой расы «не мог туда прийти»). С 15-го уровня все смешиваются на континенте. Уровень бота всегда соответствует зоне.
+
+**Торговля.** Периодически бот «уходит в город» (по уровню и расе: новички — в свою деревню, 40+ — Гиран/Орен/Аден...), садится и открывает лавку: скупку — если ему «нужно что-то», или продажу — если перед этим успешно нафармил. Раз в 3 минуты кричит зазывалку в торговый канал (текст генерирует LLM). Через час лавка закрывается, бот возвращается на спот, следующая торговля через ~2–2.5 часа. Настройки в `FakePlayers.ini`: `FakePlayerTrading`, `FakePlayerTradeDuration`, `FakePlayerTradeCooldown`, `FakePlayerTradeShoutInterval`, `FakePlayerTradeChance`. Лавки декоративные: купить/продать им ничего нельзя (это NPC без настоящего инвентаря) — но выглядят и кричат как живые.
+
+**Мир живёт без тебя**: `GridsAlwaysOn = True` — AI работает во всех регионах, боты фармят и качаются, даже когда тебя нет рядом. Это дороже по CPU; если сервер начнёт тормозить — выключи в `General.ini`, тогда качаться будут только зоны рядом с тобой.
+
+**LLM-чат через Ollama.** Боты пишут по-русски: тонт при ганге, комментарий при помощи, бытовой трёп когда ты рядом, ответ если ты что-то сказал в общий чат возле них (шанс 40%), ответ в личку (шёпот `"ник текст`). Если Ollama не запущена — молча откатываются на шаблонные ответы из `FakePlayerChatData.xml`, сервер работает как обычно.
+
+## Рынок, титулы, гербы
+
+**Лавки торгуют настоящим лутом.** Боты подбирают дроп со своих убийств (свой лут, чужой не трогают) и идут в город, только когда действительно есть что продать; в лавку выставляются реальные вещи из инвентаря по цене от справочной ±. Никаких выданных из воздуха товаров и никакого лимита — торгуют те, кто нафармил.
+
+**Название лавки собирается из частей** (пулы `store_goods` / `store_price` / `store_mood`): «соски дёшево налетай», «C грейд по рынку», «дроп оптом не торгуюсь» — тон зависит от характера. Подпись теперь реально видна: раньше не хватало broadcast'а `PrivateStoreMsgSell`.
+
+**Титулы вместо PK/PKK.** Над ником — то, что пишут живые игроки: «ищу клан», «куплю адену», «afk», «Гиран», «рб хантер», у большинства пусто. Характер больше нигде не светится.
+
+**Гербы кланов и альянсов** генерируются процедурно (DXT1 DDS 16×12 и 8×12, как требует клиент — 224 и 176 байт при лимитах 256/192): шесть геральдических стилей (полосы, четверти, шеврон, рамка, диагональ, шахматка) и восемь палитр, детерминированно от названия клана. 150 кланов сведены в ~19 альянсов, у каждого свой герб. Сид `db_installer\sql\spp_crest_seed.sql` применяется автоматически (entrypoint теперь накатывает все `spp_*.sql`).
+
+## Мир только из headless-игроков (текущая схема)
+
+NPC-боты выключены (`FakePlayerProgression = False`), население — 500 headless-игроков (`FakePlayerHeadlessCount`), выбранных случайно из тех же 5000 сид-персонажей, то есть с разбросом по всем уровням 1–76. Каждый расселяется в зону по своему уровню и расе (та же таблица зон, что была у NPC — она грузится всегда, даже когда NPC-население отключено). Стоковый бот Evi из поставки Mobius отключён.
+
+Хочешь другое число — правь `FakePlayerHeadlessCount` и пересобирай образ. Замер: 500 headless на слабом ядре песочницы — 3.2% CPU, +0.5 ГБ RAM; на нормальной машине запас большой, но помни, что Player в бою дороже NPC.
+
+Вернуть гибрид (NPC-статисты + элитные headless) — поставить `FakePlayerProgression = True` и уменьшить `FakePlayerHeadlessCount`: тогда headless берут только «хвост» сид-персонажей, а остальные живут как NPC.
+
+## Headless-игроки (настоящая пати) — прототип
+
+`FakePlayerHeadlessCount` в `FakePlayers.ini` (по умолчанию 30) поднимает N ботов не как NPC, а как настоящие `Player`-инстансы без клиента — поднятые из тех же сид-персонажей (берутся старшие charId, прогрессия их не трогает). Они фармят у Гирана. Ключевое: **их можно позвать в настоящую пати** — жмёшь invite, бот через 1–2 сек сам принимает, появляется окно группы, идёт опыт, боты держатся рядом с лидером и ассистят его цель; после смерти встают через 15 сек.
+
+Это цена «быть настоящим игроком»: для движка они игроки, потому что это класс `Player`, а не NPC с флагом.
+
+**Весь функционал NPC-ботов перенесён на headless.** Они берут свою личность из того же сида (`FakePlayerProgression.xml`): характер (ganker/pkk/helper/neutral), раса, клан, домашняя зона. И дальше умеют всё то же самое:
+
+- **речь**: те же швы + LLM с той же персоной; тонты при ганге, реплики ПКК, ассист, бытовой трёп, левел-ап, прощание при переезде;
+- **понимание**: шёпот и общий чат проходят через тот же разбор интентов — оскорбил ганкера, он нападёт; попросил помощи, побежит; спросил «что нового» — расскажет слух;
+- **память и слухи**: помнят, кто их убивал и кто помогал; смерть headless-бота видят соседние боты и разносят историю с искажением;
+- **прогрессия настоящая**: они получают реальный опыт с мобов (они же игроки), при переходе тира сами прощаются и телепортируются в зону своего уровня — по той же таблице зон, что у NPC;
+- **пати между собой**: соклановцы поблизости собираются в **настоящие** L2-пати (не имитация);
+- **дашборд**: видны на карте отдельным значком (★, обведены золотым), фильтруются как обычные боты.
+
+**Что они умеют как игроки** (добавлено после первого прототипа):
+
+- **клан**: при спавне восстанавливается членство из БД — крест над ником, участие в клановых войнах;
+- **торговля**: раз в 0.5–1.5 часа бот телепортируется на площадь Гирана, получает товар (соски, D-грейд), выставляет **настоящую приватную лавку** с ценником и садится на 20–60 минут. У такой лавки реально можно купить — это полноценный `PrivateStoreType.SELL`, а не декорация как у NPC-торговцев;
+- **PvP**: если рядом игрок или другой headless из клана-врага — атакует, со всеми штатными последствиями (флаг, карма, PvP-счётчик).
+
+**Замер (песочница, 2 слабых ядра, без геодаты, изолированно от NPC-ботов):**
+
+| headless | CPU | RAM |
+|---|---|---|
+| 0 | 1.3% | 1.81 ГБ |
+| 500 | 3.2% | 2.29 ГБ |
+
+То есть ~1 МБ и почти ноль CPU на одного простаивающего headless-игрока. Но честные оговорки: замер без геодаты (pathfinding выключен) и в основном на фарме — активный бой у Player-инстанса дороже NPC (пересчёт статов, тики эффектов, апдейты пати, броадкаст пакетов), так что под нагрузкой с геодатой цена вырастет. Вывод: **30–100 headless-игроков рядом с 5000 NPC — комфортно; тысячи headless — нет** (это подтверждает гибридную архитектуру). И: авто-принятие пати написано по образцу штатного accept, но вживую с клиентом я его не проверял — это прототип, первым делом протести окно группы у себя.
+
+## Речь, память, слухи
+
+**Композитные фразы.** Реплика собирается из кусочков, а характер решает, какие кусочки вообще войдут:
+
+- `game\data\FakePlayerPools.xml` — 26 пулов по 12 кусочков (приветствия, обращение по нику, слово-паразит, угроза, насмешка, похвальба, предложение помощи, зов в пати, бытовуха, жалоба, встречный вопрос, хвосты-смешки и т.п.). Пул с суффиксом характера (`hello.helper`, `threat.pkk`) перекрывает базовый.
+- `game\data\FakePlayerPhrases.xml` — шаблоны сборки: ``. То есть ганкер никогда не здоровается, а сразу насмехается; хелпер здоровается развёрнуто и добавляет встречный вопрос; ПКК отвечает сухо.
+
+Из тех же частей выходит «прив, {player} чё как» у дружелюбного и «слышь {player} ща будет больно кек» у пкшера. Если для ключа шаблона нет — используется плоский шов из `FakePlayerChatLines.xml`, так что старые ключи продолжают работать. Проверить руками: на дашборде блок «Генератор фраз» — вводишь ключ и характер, получаешь 8 разных сборок.
+
+**Плоские швы** (`game\data\FakePlayerChatLines.xml`): 194 реплики в 14 событийных швах со слотами `{target}/{player}/{boss}/{town}/{level}/{msg}` — редактируются без пересборки. Анти-повтор: каждый бот помнит свои последние 12 реплик и не повторяется.
+
+**Память** (`fake_player_memory`): бот помнит, кто его убивал (обида) и кто помогал добить его моба (благодарность). При встрече узнаёт («опа, а вот и обидчик»), LLM-разговоры тоже пишутся в память.
+
+**Слухи** — порт системы из «Округи»: смерть бота видят соседние боты (свидетели), истории гуляют между ботами при встречах с искажением (уверенность падает, пересказы копятся, масштаб растёт: «гангнул» → «вырезал всех на точке» → «пол сервера в страхе держит»). Рассказывая тебе, бот выдаёт источник хеджем: «сам видел как ты…» / «говорят ты…» / «брешут будто ты…» / «уже легенды ходят как ты…». Стоя рядом с двумя ботами, можно подслушать сплетню о себе. Всё в `fake_player_rumor`, переживает рестарт, и всё это скармливается LLM в персону (с пометкой «пересказ N раз, можешь ошибаться»).
+
+## Группы, рейды, олимпиада
+
+**Сквады.** Ганкеры и охотники на ПК ходят группами по 2–4 (свой клан, свой уровень, своя раса): лидер ведёт маршрут, остальные держатся рядом и вместе входят в бой (соседние фейки и так ассистят друг другу в радиусе 600). Итого ~500 бродячих групп: ганг встречается пати, и ответка от ПКК тоже приходит пати.
+
+**Рейды на боссов.** Каждые 30–90 минут собирается пати из 5 ботов 60+ и идёт убивать живого рейд-боссa (55–80 lvl): телепорт к боссу («со стороны» выглядит как приход группы), боевой клич в чат, бой до смерти босса или 25 минут, потом расходятся по спотам. В логе: `Raid event started/finished`. Гранд-боссы (Баюм и т.п.) не трогаются.
+
+**Олимпиада — иллюзия, честно.** Настоящие бои олимпиады требуют персонажей-игроков, NPC-боты в них участвовать не могут — это фундаментальное ограничение. Что сделано вместо: 60 ботов записаны в нобли с очками/статистикой боёв, 12 из них — действующие Герои (по классам). Списки ноблей и героев в игре настоящие. Чего нет: самих матчей и ротации героев по месяцам (могу добавить «виртуальную ротацию» очков позже).
+
+## Кланы, войны, холлы, замки (pre-seed)
+
+12 бот-кланов в базе (RedSyndicate и GopStop — пкшеры, KarmaPolice и NightWatch — охотники на ПК, Academka — новички, IronPact/OldSchool — хай-энд...). ~85% ботов состоят в кланах и носят клан-теги; все 200 существуют и как офлайн-персонажи, так что списки членов кланов в игре настоящие. Между кланами прописано 8 взаимных войн. 30 из 34 аукционных клан-холлов заняты бот-кланами (аренда оплачена), 4 свободны для тебя. Замки Глудио, Дион, Гиран и Аден принадлежат бот-кланам (кресты над городами).
+
+**Война с бот-кланом работает по-настоящему**: объяви войну (например, RedSyndicate) — и каждый бот этого клана при встрече будет нападать на членов твоего клана, с боевыми выкриками. Осады замков ботами пока не отыгрываются.
+
+Сид идемпотентный (`db_installer\sql\spp_clan_seed.sql`), применяется автоматически при старте login-контейнера; занятые игроком холлы не трогает.
+
+## Включение Ollama
+
+1. Поставь Ollama: https://ollama.com/download
+2. `ollama pull gemma3`
+3. Ollama должна слушать на 127.0.0.1:11434 (по умолчанию так и есть).
+
+Всё. Сервер сам начнёт ходить в неё. Модель, язык, тайминги и шансы — в `game\config\Custom\FakePlayers.ini`, секция «SPP additions».
+
+## Настройка
+
+`game\config\Custom\FakePlayers.ini`:
+
+- `FakePlayerXpRate = 3` — скорость прокачки ботов (реальный опыт мобов × рейт).
+- `FakePlayerProgression = True` — вся система прокачки; False = статичное население.
+- `FakePlayerAggroPlayers = True` — ганкеры нападают на игроков. False = мирные боты.
+- `FakePlayerAmbientChatChance / CombatChatChance / LocalChatReplyChance` — болтливость.
+- `FakePlayerLlmModel` — любая модель Ollama (gemma3, qwen3, llama3.1...).
+
+Сбросить прогресс всех ботов: `DROP TABLE fake_player_progress;` в базе — при следующем старте пересоздастся со стартовыми уровнями.
+
+Своих персонажей добавлять в `game\data\FakePlayerProgression.xml` (секция `character`: id, имя, характер, стартовый уровень) — но для нового id нужны и 8 тир-шаблонов в `game\data\stats\npcs\custom\fpc_spp_*.xml` (id = 82000 + (charId−1)×8 + тир). Проще попросить меня перегенерировать население с нужными параметрами.
+
+## Что изменено в исходниках (src_mobius_interlude.zip)
+
+Новые файлы:
+
+- `gameserver/managers/FakePlayerLlmService.java` — асинхронный клиент Ollama (без зависимостей, семафор на 2 запроса, санитизация ответов).
+- `gameserver/managers/FakePlayerAiTaskManager.java` — «мозг»: тик раз в 5 сек, тонты при ганге, ассист и фарм для не-агрессивных, бытовой чат.
+- `gameserver/managers/FakePlayerProgressionManager.java` — прокачка: опыт с киллов, тиры, миграция между зонами, движение по точкам зоны, персист в БД.
+
+Правки:
+
+- `FakePlayerChatManager.java` — LLM-путь с фолбэком на паттерны, `manageLocalChat`/`manageEventChat`/`broadcastToNearby`, персона бота из шаблона.
+- `FakePlayersConfig.java` — новые ключи конфига.
+- `Attackable.java` — хук начисления опыта боту при убийстве моба.
+- `GameServer.java` — старт менеджеров.
+- `data/scripts/handlers/chat/channels/ChatGeneral.java` — реакция ботов на общий чат.
+
+Данные генерируются скриптом (1600 шаблонов в `fpc_spp_00..07.xml`, зоны/опыт/персонажи в `FakePlayerProgression.xml`). Таблица опыта построена из реальных exp-значений мобов датапака: уровень ≈ 60 убитых мобов своего уровня.
+
+Пересборка: `ant` из корня `L2J_Mobius_CT_0_Interlude` (нужны JDK 25 и Ant).
+
+## Проверено в песочнице
+
+Полный цикл на тестовом рейте x1000: за 2,5 минуты 192 из 200 ботов получили опыт с реальных киллов, +2066 уровней суммарно, миграции между зонами и сохранение в БД отработали, 0 исключений в логе.
+
+## Ограничения текущей версии
+
+Уровень бота в бою определяется тиром (статы шаблона), а не точным уровнем из БД — внутри тира бот дерётся одинаково. Хилеры-helper-ы бьют моба, а не хилят тебя. Ганкеры бросают погоню, если ты убежал далеко от их зоны. Пати с ботами собрать нельзя — они NPC; управляемые пати-боты — следующий большой этап.
diff --git a/DEBUG.md b/DEBUG.md
new file mode 100644
index 0000000..b59ceab
--- /dev/null
+++ b/DEBUG.md
@@ -0,0 +1,58 @@
+# Дебаг-команды для headless-ботов
+
+Два способа: кнопки на дашборде **http://127.0.0.1:7780/** (панель «Дебаг-команды») или напрямую в браузере / PowerShell.
+
+Формат: `http://127.0.0.1:7780/api/cmd?do=КОМАНДА&name=НИК_БОТА&arg=АРГУМЕНТ`
+`name` пустой — берётся случайный живой бот. В дашборде клик по боту в списке подставляет его ник автоматически.
+
+## Уровень и переезд
+
+| Команда | Что делает |
+|---|---|
+| `do=levelup` | **+1 уровень** боту. На следующем тике (до 3 сек) бот сам порадуется в чат, а при переходе тира — попрощается и уедет в новую зону |
+| `do=migrate` | немедленный переезд в зону по своему уровню, с прощальной фразой |
+
+Пример: `http://127.0.0.1:7780/api/cmd?do=levelup&name=Eladrael`
+Нажать 8 раз подряд — увидишь пересечение границы тира и переезд.
+
+## Торговля
+
+| Команда | Что делает |
+|---|---|
+| `do=trade` | бот телепортируется в центр Гирана и **открывает настоящую лавку** (можно подойти и купить) |
+| `do=tradeall&arg=10` | открыть лавки сразу у 10 ботов (до 50) — рынок за 5 секунд |
+| `do=closeshop` | закрыть лавку |
+
+## Взаимодействие с тобой
+
+| Команда | Что делает |
+|---|---|
+| `do=come&arg=ТвойНик` | телепортировать бота к тебе |
+| `do=party&arg=ТвойНик` | бот телепортируется и вступает в **настоящую пати** с тобой |
+| `do=gank&arg=ТвойНик` | бот прыгает к тебе и атакует с дерзкой фразой (проверка PvP) |
+| `do=clanwar&arg=ТвойНик` | объявить войну между кланом бота и твоим кланом (нужны кланы у обоих) |
+
+## Речь, память, смерть
+
+| Команда | Что делает |
+|---|---|
+| `do=say` | бот говорит случайную реплику из шва `ambient` (или LLM, если Ollama запущена) |
+| `do=say&arg=текст` | бот говорит ровно этот текст |
+| `do=rumor&arg=ТвойНик` | вложить боту слух про тебя — потом при встрече он его выдаст («говорят ты…») |
+| `do=kill` | убить бота: он ложится, через ~15 сек **встаёт в ближайшем городе** и через минуту возвращается на спот |
+| `do=list` | первые 25 ботов: ник, уровень, характер, зона, статус |
+
+## Проверка разговоров без игры
+
+На дашборде секция «Тестер диалога»: пишешь фразу (можно транслитом — `IIoMoruTe`, `privet`), видишь нормализацию → интент → шов → ответ, справа подсвечивается выбранная строка в списке вариантов шва.
+
+В игре: шёпот боту (`"Ник текст`) — теперь работает, «Offline mode» исправлено.
+
+## Быстрый сценарий проверки всего
+
+1. `do=tradeall&arg=15` → идёшь в Гиран, там 15 лавок, заходишь в любую и покупаешь.
+2. `do=party&arg=ТвойНик` → окно пати, бот рядом, бьёт твои цели.
+3. `do=gank&arg=ТвойНик` → PvP, флаг, бот кричит.
+4. `do=kill` → смотришь, что бот встал в городе.
+5. `do=levelup` несколько раз → реакция в чат и переезд.
+6. Шёпот боту «помоги» → он бежит; «ты бот?» → отнекивается.
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000..6b814c1
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,89 @@
+# L2 Interlude SPP — установка
+
+Сервер: L2J Mobius CT_0 Interlude + SPP-моды (200 ботов с прокачкой, LLM-чат, геодата — см. BOTS.md).
+
+Два способа запуска: **Docker** (проще, всё в одной команде) или **нативно** (JDK + MariaDB в систему).
+
+## Файлы
+
+- `server_interlude.zip` — готовый сервер с геодатой. Распакуй в `I:\coding\l2sp\server\`.
+- `src_mobius_interlude.zip` — исходники (для доработок).
+- `docker\` — Dockerfile, docker-compose.yml, entrypoint.sh.
+
+---
+
+## Вариант А: Docker
+
+Нужен Docker Desktop (https://docker.com, с WSL2). Дальше:
+
+1. Распакуй `server_interlude.zip` → папка с `game`, `login`, `libs`, `db_installer`.
+2. Скопируй папку `docker` из `I:\coding\l2sp\docker` внутрь распакованной папки сервера (рядом с `game`).
+3. Из папки сервера:
+
+```
+docker compose -f docker/docker-compose.yml up --build
+```
+
+Первый запуск: соберётся образ, поднимется MariaDB, login сам зальёт базу, game дождётся схемы и стартует. Готово, когда в логах `Registered on login as Server`. Дальше — шаги «Клиент» и «Аккаунт» ниже (порты 2106 и 7777 проброшены на localhost, для клиента разницы с нативным запуском нет).
+
+Нюансы: пароль БД зашит `l2j` (менять не нужно, наружу не торчит); Ollama контейнер видит на хосте через `host.docker.internal` — просто поставь её на Windows как обычно; правки конфигов в папке сервера применяются после `docker compose ... up --build`; база живёт в volume `l2db` (`docker volume rm` — полный сброс). Файлы docker/ не тестировались в бою (в моей песочнице нет Docker) — если что-то упадёт при сборке, кидай вывод.
+
+---
+
+## Вариант Б: нативно (Windows)
+
+### Шаг 1. JDK 25
+
+Temurin JDK 25 (Windows x64 MSI): https://adoptium.net/temurin/releases/?version=25
+При установке включи «Add to PATH» и «Set JAVA_HOME» — лаунчеры .vbs берут Java из JAVA_HOME.
+
+### Шаг 2. MariaDB
+
+MariaDB (Windows x64 MSI): https://mariadb.org/download/ — задай пароль root, порт 3306.
+
+### Шаг 3. База данных
+
+```
+mysql -uroot -p -e "CREATE DATABASE l2jmobiusinterlude"
+cd I:\coding\l2sp\server\db_installer
+for %f in (sql\login\*.sql) do mysql -uroot -p"ПАРОЛЬ" l2jmobiusinterlude < "%f"
+for %f in (sql\game\*.sql) do mysql -uroot -p"ПАРОЛЬ" l2jmobiusinterlude < "%f"
+```
+
+(`mysql.exe` — в `C:\Program Files\MariaDB *\bin`. Либо GUI: `db_installer\DatabaseInstaller.vbs`.)
+
+### Шаг 4. Конфиги
+
+Пароль root в строку `Password =` в двух файлах:
+
+- `server\login\config\Database.ini`
+- `server\game\config\Database.ini`
+
+Остальное уже настроено под localhost.
+
+### Шаг 5. Запуск (порядок важен)
+
+1. `server\login\LoginServer.vbs` — ждать `Login Server ready`.
+2. `server\game\GameServer.vbs` — ждать `Registered on login`. Первый старт с геодатой — около минуты, нужно ~4 ГБ RAM (прописано в `game\java.cfg`).
+
+---
+
+## Клиент
+
+Клиент Lineage 2 Interlude (protocol 746) с патченным под L2J `system` (сборки для приватов обычно такие; чистый официальный клиент не подключится — нужен «Interlude L2J patch»). В `system\l2.ini`: `ServerAddr=127.0.0.1` (если зашифрован — L2FileEdit). Запуск: `system\l2.exe`.
+
+## Аккаунт и админка
+
+Логин/пароль вводишь любые — аккаунт создастся сам. Админка после первого входа:
+
+```
+mysql -uroot -p l2jmobiusinterlude -e "UPDATE accounts SET accessLevel=100 WHERE login='ТВОЙ_ЛОГИН'"
+```
+
+(в Docker: `docker compose -f docker/docker-compose.yml exec db mariadb -uroot -pl2j l2jmobiusinterlude -e "UPDATE accounts SET accessLevel=100 WHERE login='ТВОЙ_ЛОГИН'"`)
+
+Перезайти. `//admin` — панель.
+
+## Боты
+
+Всё включено из коробки: 200 персонажей 1–76 с настоящей прокачкой, LLM-чат (нужна Ollama + `ollama pull gemma3`, иначе шаблонные фразы). Настройки — `game\config\Custom\FakePlayers.ini`, детали — `BOTS.md`.
diff --git a/cleanup-docker.ps1 b/cleanup-docker.ps1
new file mode 100644
index 0000000..3d70384
--- /dev/null
+++ b/cleanup-docker.ps1
@@ -0,0 +1,29 @@
+# Освобождает место, которое занял Docker, НЕ трогая базу сервера (том docker_l2db).
+# Запуск: powershell -ExecutionPolicy Bypass -File cleanup-docker.ps1
+
+Write-Host "Было занято:" -ForegroundColor Cyan
+docker system df
+
+Write-Host "`nЧищу кэш сборок..." -ForegroundColor Yellow
+docker builder prune -af
+
+Write-Host "`nЧищу неиспользуемые образы (том с базой не трогаю)..." -ForegroundColor Yellow
+docker image prune -af
+
+Write-Host "`nЧищу остановленные контейнеры..." -ForegroundColor Yellow
+docker container prune -f
+
+Write-Host "`nСтало:" -ForegroundColor Cyan
+docker system df
+
+Write-Host "`nТома (базa должна остаться):" -ForegroundColor Cyan
+docker volume ls
+
+Write-Host @"
+
+Место освобождено внутри виртуального диска Docker, но сам файл vhdx на C: не
+сжимается автоматически. Чтобы вернуть место Windows:
+ 1) wsl --shutdown
+ 2) Optimize-VHD -Path "$env:LOCALAPPDATA\Docker\wsl\disk\docker_data.vhdx" -Mode Full
+ (нужен Hyper-V; если команды нет - Docker Desktop -> Settings -> Resources -> Disk -> Clean up)
+"@ -ForegroundColor DarkGray
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..a663308
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,13 @@
+# Лёгкий образ: только среда запуска. Файлы сервера НЕ копируются внутрь —
+# папка server/ монтируется при старте (см. docker-compose.yml).
+# Благодаря этому пересборка образа почти никогда не нужна, а обновление
+# сервера = замена файлов на диске + docker compose restart.
+FROM eclipse-temurin:25-jdk-noble
+
+RUN apt-get update && apt-get install -y --no-install-recommends mariadb-client && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /opt/l2
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh && sed -i 's/\r$//' /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000..ab18d31
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,57 @@
+# Папка server/ монтируется в контейнеры, а не копируется в образ:
+# образ ~200 МБ вместо 900 МБ, сборка нужна только при правке entrypoint.sh,
+# обновление сервера = положить новые файлы и сделать restart.
+services:
+ db:
+ image: mariadb:11.4
+ environment:
+ MARIADB_ROOT_PASSWORD: l2j
+ MARIADB_DATABASE: l2jmobiusinterlude
+ volumes:
+ - l2db:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ login:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: l2-spp
+ command: ["login"]
+ environment:
+ DB_HOST: db
+ DB_PASS: l2j
+ volumes:
+ - ..:/opt/l2
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "2106:2106"
+ restart: unless-stopped
+
+ game:
+ image: l2-spp
+ command: ["game"]
+ environment:
+ DB_HOST: db
+ DB_PASS: l2j
+ # Ollama на хосте (Windows). Если не запущена - боты говорят шаблонами.
+ FAKE_LLM_URL: http://host.docker.internal:11434/api/generate
+ volumes:
+ - ..:/opt/l2
+ depends_on:
+ db:
+ condition: service_healthy
+ login:
+ condition: service_started
+ ports:
+ - "7777:7777"
+ - "7780:7780"
+ restart: unless-stopped
+
+volumes:
+ l2db:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..84d96c3
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+set -e
+
+ROLE="$1" # login | game
+DB_HOST="${DB_HOST:-db}"
+DB_PASS="${DB_PASS:-l2j}"
+DB_NAME="l2jmobiusinterlude"
+FAKE_LLM_URL="${FAKE_LLM_URL:-http://host.docker.internal:11434/api/generate}"
+
+M() { mariadb -h "$DB_HOST" -uroot -p"$DB_PASS" "$@"; }
+
+# MOUNTED: файлы сервера примонтированы с диска, а не вшиты в образ.
+echo "[entrypoint] waiting for database at $DB_HOST..."
+until M -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done
+
+# Patch configs for the container network.
+for f in /opt/l2/login/config/Database.ini /opt/l2/game/config/Database.ini; do
+ sed -i "s|jdbc:mysql://localhost|jdbc:mysql://$DB_HOST|" "$f"
+ sed -i "s|^Password *=.*|Password = $DB_PASS|" "$f"
+done
+sed -i "s|^LoginHostname *=.*|LoginHostname = 0.0.0.0|" /opt/l2/login/config/Server.ini
+sed -i "s|^LoginHost *=.*|LoginHost = login|" /opt/l2/game/config/Server.ini
+sed -i "s|^FakePlayerLlmUrl *=.*|FakePlayerLlmUrl = $FAKE_LLM_URL|" /opt/l2/game/config/Custom/FakePlayers.ini
+# Client-facing IP: everything is published on localhost of the host machine.
+cp -f /opt/l2/game/config/default-ipconfig.xml /opt/l2/game/config/ipconfig.xml
+
+if [ "$ROLE" = "login" ]; then
+ TABLES=$(M -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'")
+ if [ "$TABLES" -eq 0 ]; then
+ echo "[entrypoint] empty database, importing sql..."
+ for f in /opt/l2/db_installer/sql/login/*.sql /opt/l2/db_installer/sql/game/*.sql; do
+ M "$DB_NAME" < "$f"
+ done
+ echo "[entrypoint] import done."
+ fi
+ for seed in /opt/l2/db_installer/sql/spp_*.sql; do
+ [ -f "$seed" ] || continue
+ M "$DB_NAME" < "$seed" && echo "[entrypoint] applied $(basename "$seed")"
+ done
+ cd /opt/l2/login
+ exec java -Xmx256m -cp "../libs/*" org.l2jmobius.loginserver.LoginServer
+fi
+
+if [ "$ROLE" = "game" ]; then
+ # Wait until login finished the import.
+ until [ "$(M -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'")" -gt 50 ]; do
+ echo "[entrypoint] waiting for database schema..."
+ sleep 3
+ done
+ cd /opt/l2/game
+ exec java $(cat java.cfg) -cp "../libs/*" org.l2jmobius.gameserver.GameServer
+fi
+
+echo "Usage: entrypoint.sh login|game" >&2
+exit 1
diff --git a/import-crests.py b/import-crests.py
new file mode 100644
index 0000000..2f4942c
--- /dev/null
+++ b/import-crests.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+"""
+Импорт своего пака гербов в бот-кланы.
+
+Использование:
+ python import-crests.py C:\\путь\\к\\папке_с_гербами
+ python import-crests.py C:\\путь --ally (только альянсовые, 8x16)
+
+Принимает PNG / BMP / DDS. Всё, что не 16x16 (клан) или 8x16 (альянс),
+масштабируется автоматически. Результат: server/db_installer/sql/spp_crest_seed.sql
+Дальше: docker compose ... up -d --build и reset-bots.ps1
+
+Требует: pip install pillow
+"""
+import sys, os, glob, struct, re, hashlib, random
+
+try:
+ from PIL import Image
+except ImportError:
+ print("Нужен Pillow: pip install pillow"); sys.exit(1)
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SEED_CLAN = os.path.join(HERE, "server", "db_installer", "sql", "spp_clan_seed.sql")
+SEED_OUT = os.path.join(HERE, "server", "db_installer", "sql", "spp_crest_seed.sql")
+
+def dds_header(w, h, linear):
+ hdr = bytearray(128)
+ hdr[0:4] = b'DDS '
+ struct.pack_into('> 3) << 11) | ((c[1] >> 2) << 5) | (c[2] >> 3)
+
+def encode_dxt1(img):
+ """Простое DXT1-сжатие: в каждом блоке 4x4 берём две крайние по яркости краски."""
+ w, h = img.size
+ px = img.convert("RGB").load()
+ body = b''
+ for by in range(h // 4):
+ for bx in range(w // 4):
+ cells = [px[bx * 4 + c, by * 4 + r] for r in range(4) for c in range(4)]
+ lum = [0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] for c in cells]
+ cmax = cells[lum.index(max(lum))]
+ cmin = cells[lum.index(min(lum))]
+ c0, c1 = to565(cmax), to565(cmin)
+ if c0 <= c1:
+ c0 = min(0xFFFF, c1 + 1)
+ idx = 0
+ for i, cell in enumerate(cells):
+ l = 0.299 * cell[0] + 0.587 * cell[1] + 0.114 * cell[2]
+ lo, hi = min(lum), max(lum)
+ t = 0 if hi == lo else (l - lo) / (hi - lo)
+ sel = 0 if t > 0.83 else (2 if t > 0.5 else (3 if t > 0.17 else 1))
+ idx |= (sel & 3) << (i * 2)
+ body += struct.pack(' пересобираю")
+ return None
+ img = Image.open(path)
+ if img.size != (w, h):
+ img = img.resize((w, h), Image.LANCZOS)
+ return encode_dxt1(img)
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__); sys.exit(1)
+ folder = sys.argv[1]
+ files = []
+ for ext in ("png", "bmp", "dds", "jpg", "gif"):
+ files += sorted(glob.glob(os.path.join(folder, "**", "*." + ext), recursive=True))
+ if not files:
+ print("В папке нет картинок"); sys.exit(1)
+ print(f"Найдено файлов: {len(files)}")
+
+ clans = re.findall(r"INSERT IGNORE INTO clan_data \(clan_id, clan_name.*?VALUES \((\d+), '([^']+)'",
+ open(SEED_CLAN, encoding="utf-8").read())
+ print(f"Кланов в сиде: {len(clans)}")
+
+ out = ["-- SPP crests: imported from " + folder.replace("\\", "/"),
+ "DELETE FROM crests WHERE crest_id BETWEEN 9000 AND 9999;"]
+ crest_id = 9000
+ rnd = random.Random(1)
+ ally_size = 8
+ allies = [clans[i:i + ally_size] for i in range(0, len(clans), ally_size)]
+ A = ["Free","Iron","Silver","Northern","Old","Wild","Royal","United","Dark","Golden","Storm","Silent"]
+ B = ["Union","League","Pact","Alliance","Circle","Banner","Order","Coalition"]
+ ok = 0
+ for ai, group in enumerate(allies):
+ if len(group) < 3:
+ continue
+ name = A[ai % len(A)] + B[(ai // len(A)) % len(B)]
+ ally_src = files[ai % len(files)]
+ ally_data = load_crest(ally_src, 8, 16) or encode_dxt1(Image.open(ally_src).resize((8, 16)))
+ ally_id = crest_id; crest_id += 1
+ out.append(f"INSERT INTO crests (crest_id, data, type) VALUES ({ally_id}, 0x{ally_data.hex()}, 3);")
+ leader = int(group[0][0])
+ for cid, cname in group:
+ src = files[hash(cname) % len(files)]
+ data = load_crest(src, 16, 16)
+ if data is None:
+ data = encode_dxt1(Image.open(src).convert("RGB").resize((16, 16), Image.LANCZOS))
+ cc = crest_id; crest_id += 1
+ out.append(f"INSERT INTO crests (crest_id, data, type) VALUES ({cc}, 0x{data.hex()}, 1);")
+ out.append(f"UPDATE clan_data SET crest_id={cc}, ally_id={leader}, ally_name='{name}', ally_crest_id={ally_id} WHERE clan_id={cid};")
+ ok += 1
+ open(SEED_OUT, "w", encoding="utf-8").write("\n".join(out) + "\n")
+ print(f"Готово: {ok} клановых гербов -> {SEED_OUT}")
+ print("Дальше: docker compose -f server\\docker\\docker-compose.yml up --build -d и reset-bots.ps1")
+
+if __name__ == "__main__":
+ main()
diff --git a/nickgen.html b/nickgen.html
new file mode 100644
index 0000000..797930c
--- /dev/null
+++ b/nickgen.html
@@ -0,0 +1,245 @@
+
+
+
+
+NickGen — генератор ников
+
+
+
+