SPP bot world as of 2026-08-26: merged state of all Cowork sessions (brain, combat data, chat seams 2.0, idiolect, heard/context, headless players, name generator, crests, seeds)

This commit is contained in:
2026-09-03 19:05:51 +02:00
parent f520c66e85
commit dcbecd2843
55 changed files with 58741 additions and 566 deletions
+88
View File
@@ -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), а не кодом: `<class id="88" role="fighter"><offensive id="261" priority="1" maxTargetHp="100"/><self id="139"/></class>` — можно править без пересборки, как швы.
## Что уже сделано (первая итерация)
**`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
<class id="2" role="fighter" name="Gladiator">
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="1" priority="3" maxTargetHp="100" minMp="10" />
<self id="8" />
</class>
```
`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-кода потребовало бы открыть исходники всей сборки.
+155
View File
@@ -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 тиров (114, 1524, 2534, 3544, 4554, 5563, 6471, 7276): при переходе тира бот пишет в чат «апнулся, ушёл за шмотом», исчезает и через 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 (пересчёт статов, тики эффектов, апдейты пати, броадкаст пакетов), так что под нагрузкой с геодатой цена вырастет. Вывод: **30100 headless-игроков рядом с 5000 NPC — комфортно; тысячи headless — нет** (это подтверждает гибридную архитектуру). И: авто-принятие пати написано по образцу штатного accept, но вживую с клиентом я его не проверял — это прототип, первым делом протести окно группы у себя.
## Речь, память, слухи
**Композитные фразы.** Реплика собирается из кусочков, а характер решает, какие кусочки вообще войдут:
- `game\data\FakePlayerPools.xml` — 26 пулов по 12 кусочков (приветствия, обращение по нику, слово-паразит, угроза, насмешка, похвальба, предложение помощи, зов в пати, бытовуха, жалоба, встречный вопрос, хвосты-смешки и т.п.). Пул с суффиксом характера (`hello.helper`, `threat.pkk`) перекрывает базовый.
- `game\data\FakePlayerPhrases.xml` — шаблоны сборки: `<part pool="hello" chance="100" skip="ganker"/>`. То есть ганкер никогда не здоровается, а сразу насмехается; хелпер здоровается развёрнуто и добавляет встречный вопрос; ПКК отвечает сухо.
Из тех же частей выходит «прив, {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 + (charId1)×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; управляемые пати-боты — следующий большой этап.
+58
View File
@@ -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. Шёпот боту «помоги» → он бежит; «ты бот?» → отнекивается.
+89
View File
@@ -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`.
+29
View File
@@ -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
+13
View File
@@ -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"]
+57
View File
@@ -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:
+55
View File
@@ -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
+127
View File
@@ -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('<I', hdr, 4, 124)
struct.pack_into('<I', hdr, 8, 0x0081007)
struct.pack_into('<I', hdr, 12, h)
struct.pack_into('<I', hdr, 16, w)
struct.pack_into('<I', hdr, 20, linear)
struct.pack_into('<I', hdr, 76, 32)
struct.pack_into('<I', hdr, 80, 0x4)
hdr[84:88] = b'DXT1'
struct.pack_into('<I', hdr, 108, 0x1000)
return bytes(hdr)
def to565(c):
return ((c[0] >> 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('<HH', c0, c1) + struct.pack('<I', idx)
return dds_header(w, h, len(body)) + body
def load_crest(path, w, h):
if path.lower().endswith(".dds"):
data = open(path, "rb").read()
dw = int.from_bytes(data[16:20], 'little'); dh = int.from_bytes(data[12:16], 'little')
if (dw, dh) == (w, h) and len(data) <= (256 if w == 16 else 192):
return data # уже готовый файл нужного размера
print(f" ! {os.path.basename(path)}: {dw}x{dh} -> пересобираю")
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()
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>NickGen — генератор ников</title>
<style>
body { background:#16181d; color:#d6d8de; font:14px/1.5 Consolas, monospace; margin:0; padding:24px; }
h1 { font-size:18px; margin:0 0 16px; color:#e8c65a; }
.controls { display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:16px;
background:#1e2128; border:1px solid #2c3038; border-radius:6px; padding:12px 16px; }
.controls label { display:flex; align-items:center; gap:6px; cursor:pointer; white-space:nowrap; }
.controls input[type=number] { width:56px; background:#111318; color:#d6d8de; border:1px solid #3a3f48; border-radius:4px; padding:3px 6px; font:inherit; }
button { background:#e8c65a; color:#16181d; border:0; border-radius:4px; padding:6px 18px; font:bold 14px Consolas, monospace; cursor:pointer; }
button:hover { background:#f5d878; }
button.sec { background:#2c3038; color:#d6d8de; }
button.sec:hover { background:#3a3f48; }
#grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px,1fr)); gap:6px; }
.nick { background:#1e2128; border:1px solid #2c3038; border-radius:4px; padding:6px 10px; cursor:pointer; }
.nick:hover { border-color:#e8c65a; }
.nick small { color:#6b7280; float:right; }
#status { color:#6b7280; margin:10px 0; min-height:1.5em; }
.weights { display:flex; gap:8px; align-items:center; }
</style>
</head>
<body>
<h1>NickGen — генератор ников для ботов</h1>
<div class="controls">
<label>Кол-во <input type="number" id="count" value="60" min="1" max="5000"></label>
<div class="weights">Части 1/2/3:
<input type="number" id="w1" value="25" min="0"><input type="number" id="w2" value="55" min="0"><input type="number" id="w3" value="20" min="0">
</div>
<label><input type="checkbox" id="optTranslit" checked> транслит</label>
<label><input type="checkbox" id="optFakeCyr" checked> псевдокириллица</label>
<label><input type="checkbox" id="optLeet" checked> l33t-цифры</label>
<label><input type="checkbox" id="optDigits" checked> цифровой суффикс</label>
<label><input type="checkbox" id="optWrap" checked> xX…Xx</label>
<button id="go">Генерировать</button>
<button id="copyAll" class="sec">Скопировать все</button>
</div>
<div id="status"></div>
<div id="grid"></div>
<script>
'use strict';
// ============================================================================
// Вся логика ниже — самодостаточная и переносимая в Java как есть:
// пулы (массивы строк), карты (switch/Map), генерация (Random).
// ============================================================================
// --- Пулы слов (русские — в кириллице, рендер решает транслит/псевдокириллицу) ---
const RU_ADJ = ['злой','добрый','старый','дикий','мелкий','жирный','хитрый','бешеный','мамкин','просто','лысый','грозный','тихий','сонный','голодный','кривой','ржавый','бодрый','унылый','модный','четкий','дерзкий','борзый','лютый','шустрый','реальный','угарный','наглый'];
const RU_NOUN = ['гном','пес','кот','волк','сухарь','травка','пельмень','колбаса','мармелад','чума','ведьмак','сквозняк','холодок','бомж','топор','кабан','хомяк','огурец','батон','чеснок','валенок','тапок','кефир','компот','барсук','ежик','мухомор','лопата','кирпич','шашлык','беляш','танкист','дед','сосед','медоед','каратель','монах','гопник','чебурек','шаурма','семки','пацан','браток','кент','батя','гараж','подъезд','чушпан','задрот','нуб','читер','красавчик','нагибатор','убиватор','терминатор'];
const RU_NAME = ['олег','вася','дашка','ленка','федор','санек','толик','серега','витек','петруха','колян','димон','настя','машка','гриша','степан','юрец','макс','жека','вован','толян','саня','диман','леха','андрюха','кирюха','паша','стас','руслан','тимур','натаха','светка','танюха','ирка','вовчик','петрович','палыч'];
const RU_SURNAME = ['пупкин','сидоров','петров','иванов','смирнов','кузнецов','козлов','волков','зайцев','медведев'];
const PREFIXES = ['MC','Dj','Mr'];
const EN_ADJ = ['Dark','Shadow','Storm','Night','Blood','Ghost','Crazy','Old','Swift','Silent','Grim','Mad','Evil','Frost','Iron','Black','Wild','Lone','Sweet','True'];
const EN_NOUN = ['Slayer','Reaper','Hunter','Killer','Knight','Rogue','Monk','Rider','Arrow','Blade','Master','Lord','King','Sniper','Mage','Angel','Demon','Raven','Wolf','Dragon','Fire','Ice','Death','Star','Dream','Soul','Fang','Claw','Bishop','Titan'];
// --- Транслит: кириллица -> латиница ---
const TRANSLIT = { 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'j','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'c','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya' };
// --- Псевдокириллица: латинские символы, похожие на кириллицу (KpucuK, TpaBka, IIeJIbMeHb) ---
// null = нет похожего, берём транслит этой буквы.
const FAKE_CYR = { 'а':'a','б':'6','в':'B','г':'r','д':null,'е':'e','ё':'e','ж':null,'з':'3','и':'u','й':'u','к':'K','л':'JI','м':'M','н':'H','о':'o','п':'II','р':'p','с':'c','т':'T','у':'y','ф':null,'х':'x','ц':null,'ч':'4','ш':'LLI','щ':null,'ъ':'','ы':'bI','ь':'b','э':'3','ю':'IO','я':'9I' };
// --- l33t для латинских слов (Ki4iga, OlegBI4) ---
const LEET = { 'e':'3','o':'0','a':'4','i':'1','s':'5' };
const rnd = (n) => Math.floor(Math.random() * n);
const pick = (arr) => arr[rnd(arr.length)];
const chance = (pct) => rnd(100) < pct;
// Транслит слова, с вариативностью окончаний (zloj/zloy)
function translitWord(w) {
let out = '';
for (const ch of w) {
let t = TRANSLIT[ch] ?? ch;
if (ch === 'й' && chance(50)) t = 'y';
if (ch === 'в' && chance(15)) t = 'w'; // Wowan-стиль
if (ch === 'х') t = chance(40) ? 'x' : (chance(30) ? 'kh' : 'h'); // Xyligan-стиль
out += t;
}
return out.charAt(0).toUpperCase() + out.slice(1);
}
// Псевдокириллица слова
function fakeCyrWord(w) {
let out = '';
for (const ch of w) {
const f = FAKE_CYR[ch];
out += (f !== null && f !== undefined) ? f : (TRANSLIT[ch] ?? ch);
}
return out;
}
// l33t: до 2 замен на слово, каждая с шансом 45%
function leetWord(w) {
let out = '', used = 0;
for (const ch of w) {
const l = LEET[ch.toLowerCase()];
if (l && used < 2 && chance(45)) { out += l; used++; }
else out += ch;
}
return out;
}
// --- Сборка частей: язык + число частей -> массив пар [слово, isSurname] ---
function buildParts(lang, n) {
const w = (x) => [x, false], s = (x) => [x, true];
if (lang === 'ru') {
if (n === 1) {
const v = rnd(100);
if (v < 45) return [w(pick(RU_NOUN))];
if (v < 80) return [w(pick(RU_NAME))];
return [s(pick(RU_SURNAME))];
}
if (n === 2) {
const v = rnd(4);
if (v === 0) return [w(pick(RU_ADJ)), w(pick(RU_NOUN))];
if (v === 1) return [w(pick(RU_ADJ)), w(pick(RU_NAME))];
if (v === 2) return [w(pick(RU_NOUN)), w(pick(RU_NOUN))];
return [w(pick(RU_NAME)), s(pick(RU_SURNAME))]; // VasyaPupkin
}
if (chance(50)) return [w(pick(RU_ADJ)), w(pick(RU_NOUN)), w(pick(RU_NOUN))];
return [w(pick(RU_ADJ)), w(pick(RU_NAME)), s(pick(RU_SURNAME))];
}
if (n === 1) return [w(pick(EN_NOUN))];
if (n === 2) return [w(pick(EN_ADJ)), w(pick(EN_NOUN))];
const first = pick(EN_ADJ);
const second = chance(50) ? pick(EN_ADJ.filter(x => x !== first)) : pick(EN_NOUN);
return [w(first), w(second), w(pick(EN_NOUN))];
}
const isRu = (w) => /[а-яё]/.test(w);
// --- Один ник. opts: {translit, fakeCyr, leet, digits, wrap, weights:[w1,w2,w3]} ---
function generateOne(opts) {
// число частей по весам
const total = opts.weights[0] + opts.weights[1] + opts.weights[2];
let roll = rnd(Math.max(total, 1)), n = 3;
if (roll < opts.weights[0]) n = 1;
else if (roll < opts.weights[0] + opts.weights[1]) n = 2;
const lang = chance(70) ? 'ru' : 'en';
const parts = buildParts(lang, n);
// редкая смесь языков в 2-3-частных никах
if (n > 1 && chance(15)) parts[parts.length - 1] = (lang === 'ru') ? [pick(EN_NOUN), false] : [translitWord(pick(RU_NOUN)), false];
// режим рендера русских слов — один на весь ник
const modes = [];
if (opts.translit) modes.push('translit');
if (opts.fakeCyr) modes.push('fakeCyr');
const ruMode = modes.length ? pick(modes) : 'translit';
let nick = parts.map(([w, isSurname]) => {
if (isRu(w)) {
if (ruMode === 'fakeCyr') return fakeCyrWord(w);
let t = translitWord(w);
// фамилии на -ov: SmirnOFF / Smirnoff
if (isSurname && t.toLowerCase().endsWith('ov') && chance(50)) t = t.slice(0, -2) + (chance(40) ? 'OFF' : 'off');
return t;
}
return (opts.leet && chance(20)) ? leetWord(w) : w;
}).join('');
// декорации (стиль сида: dashka, ZloyPES, HolodOK, OldRogue38, MCVovan95, xXDarkSlayerXx)
if (ruMode === 'translit') {
if (chance(12)) nick = nick.toLowerCase(); // dashka
else if (chance(12) && nick.length > 4) { // ZloyPES / HolodOK
const k = 2 + rnd(2);
nick = nick.slice(0, -k) + nick.slice(-k).toUpperCase();
}
}
if (opts.digits && chance(28) && nick.length <= 14) {
const v = rnd(100);
let suf;
if (v < 45) suf = chance(50) ? String(1 + rnd(9)) : String(10 + rnd(90)); // OlegBI4-стиль
else if (v < 80) suf = String((85 + rnd(23)) % 100).padStart(2, '0'); // год рождения 85..07
else suf = String(1985 + rnd(23)); // 1985..2007
if (nick.length + suf.length <= 16) nick += suf;
}
if (chance(4) && nick.length <= 13) nick += 'RUS';
let prefixed = false;
if (chance(6) && nick.length <= 14) { nick = pick(PREFIXES) + nick; prefixed = true; } // MCVovan
if (opts.wrap && !prefixed && chance(4) && nick.length <= 12) nick = 'xX' + nick + 'Xx';
return nick;
}
// Валидный для клиента L2: [A-Za-z0-9], 3-16 символов
const VALID = /^[A-Za-z0-9]{3,16}$/;
function generateBatch(count, opts, taken) {
const out = [];
let guard = count * 50;
while (out.length < count && guard-- > 0) {
const nick = generateOne(opts);
if (VALID.test(nick) && !taken.has(nick.toLowerCase())) {
taken.add(nick.toLowerCase());
out.push(nick);
}
}
return out;
}
// ============================================================================
// UI
// ============================================================================
const $ = (id) => document.getElementById(id);
let current = [];
function run() {
const opts = {
translit: $('optTranslit').checked,
fakeCyr: $('optFakeCyr').checked,
leet: $('optLeet').checked,
digits: $('optDigits').checked,
wrap: $('optWrap').checked,
weights: [+$('w1').value || 0, +$('w2').value || 0, +$('w3').value || 0],
};
const count = Math.min(+$('count').value || 60, 5000);
current = generateBatch(count, opts, new Set());
const grid = $('grid');
grid.innerHTML = '';
for (const nick of current) {
const d = document.createElement('div');
d.className = 'nick';
d.innerHTML = nick + ' <small>' + nick.length + '</small>';
d.title = 'Кликни — скопировать';
d.onclick = () => { navigator.clipboard.writeText(nick); $('status').textContent = 'Скопировано: ' + nick; };
grid.appendChild(d);
}
$('status').textContent = 'Сгенерировано: ' + current.length + ' (уникальные, ≤16 символов, [A-Za-z0-9])';
}
$('go').onclick = run;
$('copyAll').onclick = () => {
navigator.clipboard.writeText(current.join('\n'));
$('status').textContent = 'Скопировано в буфер: ' + current.length + ' ников';
};
run();
</script>
</body>
</html>
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Генератор ников для ботов — порт nickgen.html (логика 1:1).
Режимы:
python nickgen.py --print 30 [--seed 42] напечатать N ников
python nickgen.py [--seed 42] dry-run: показать, что изменится
python nickgen.py --apply [--seed 42] переписать имена в сидах:
server/game/data/FakePlayerProgression.xml (<character name="...">)
server/db_installer/sql/spp_clan_seed.sql (characters.char_name)
Соответствие: character id=N в XML <-> charId 270001000+N в SQL, имена ставятся
из одного списка, так что XML и БД остаются согласованы. После --apply ники в
работающей БД не изменятся (INSERT IGNORE) — нужен reset-bots.ps1 и пересборка
образа.
"""
import argparse
import random
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
XML_PATH = ROOT / 'server' / 'game' / 'data' / 'FakePlayerProgression.xml'
SQL_PATH = ROOT / 'server' / 'db_installer' / 'sql' / 'spp_clan_seed.sql'
CHAR_ID_BASE = 270001000
# --- Пулы слов (как в nickgen.html) ------------------------------------------
RU_ADJ = ['злой', 'добрый', 'старый', 'дикий', 'мелкий', 'жирный', 'хитрый', 'бешеный', 'мамкин', 'просто',
'лысый', 'грозный', 'тихий', 'сонный', 'голодный', 'кривой', 'ржавый', 'бодрый', 'унылый', 'модный',
'четкий', 'дерзкий', 'борзый', 'лютый', 'шустрый', 'реальный', 'угарный', 'наглый']
RU_NOUN = ['гном', 'пес', 'кот', 'волк', 'сухарь', 'травка', 'пельмень', 'колбаса', 'мармелад', 'чума',
'ведьмак', 'сквозняк', 'холодок', 'бомж', 'топор', 'кабан', 'хомяк', 'огурец', 'батон', 'чеснок',
'валенок', 'тапок', 'кефир', 'компот', 'барсук', 'ежик', 'мухомор', 'лопата', 'кирпич', 'шашлык',
'беляш', 'танкист', 'дед', 'сосед', 'медоед', 'каратель', 'монах',
'гопник', 'чебурек', 'шаурма', 'семки', 'пацан', 'браток', 'кент', 'батя', 'гараж', 'подъезд',
'чушпан', 'задрот', 'нуб', 'читер', 'красавчик', 'нагибатор', 'убиватор', 'терминатор']
RU_NAME = ['олег', 'вася', 'дашка', 'ленка', 'федор', 'санек', 'толик', 'серега', 'витек', 'петруха',
'колян', 'димон', 'настя', 'машка', 'гриша', 'степан', 'юрец', 'макс',
'жека', 'вован', 'толян', 'саня', 'диман', 'леха', 'андрюха', 'кирюха', 'паша', 'стас',
'руслан', 'тимур', 'натаха', 'светка', 'танюха', 'ирка', 'вовчик', 'петрович', 'палыч']
RU_SURNAME = ['пупкин', 'сидоров', 'петров', 'иванов', 'смирнов', 'кузнецов', 'козлов', 'волков', 'зайцев', 'медведев']
PREFIXES = ['MC', 'Dj', 'Mr']
EN_ADJ = ['Dark', 'Shadow', 'Storm', 'Night', 'Blood', 'Ghost', 'Crazy', 'Old', 'Swift', 'Silent',
'Grim', 'Mad', 'Evil', 'Frost', 'Iron', 'Black', 'Wild', 'Lone', 'Sweet', 'True']
EN_NOUN = ['Slayer', 'Reaper', 'Hunter', 'Killer', 'Knight', 'Rogue', 'Monk', 'Rider', 'Arrow', 'Blade',
'Master', 'Lord', 'King', 'Sniper', 'Mage', 'Angel', 'Demon', 'Raven', 'Wolf', 'Dragon',
'Fire', 'Ice', 'Death', 'Star', 'Dream', 'Soul', 'Fang', 'Claw', 'Bishop', 'Titan']
# --- Транслит: кириллица -> латиница -----------------------------------------
TRANSLIT = {'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e', 'ж': 'zh', 'з': 'z',
'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r',
'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'}
# --- Псевдокириллица: латинские двойники (KpucuK, TpaBka, IIeJIbMeHb) ---------
# None = нет похожего символа, берётся транслит буквы.
FAKE_CYR = {'а': 'a', 'б': '6', 'в': 'B', 'г': 'r', 'д': None, 'е': 'e', 'ё': 'e', 'ж': None, 'з': '3',
'и': 'u', 'й': 'u', 'к': 'K', 'л': 'JI', 'м': 'M', 'н': 'H', 'о': 'o', 'п': 'II', 'р': 'p',
'с': 'c', 'т': 'T', 'у': 'y', 'ф': None, 'х': 'x', 'ц': None, 'ч': '4', 'ш': 'LLI', 'щ': None,
'ъ': '', 'ы': 'bI', 'ь': 'b', 'э': '3', 'ю': 'IO', 'я': '9I'}
# --- l33t для латинских слов (Ki4iga, OlegBI4) --------------------------------
LEET = {'e': '3', 'o': '0', 'a': '4', 'i': '1', 's': '5'}
VALID = re.compile(r'^[A-Za-z0-9]{3,16}$')
RU_RE = re.compile(r'[а-яё]')
class NickGen:
def __init__(self, seed=None, translit=True, fake_cyr=True, leet=True, digits=True, wrap=True,
weights=(25, 55, 20)):
self.r = random.Random(seed)
self.translit = translit
self.fake_cyr = fake_cyr
self.leet = leet
self.digits = digits
self.wrap = wrap
self.weights = weights
def chance(self, pct):
return self.r.randrange(100) < pct
def pick(self, arr):
return arr[self.r.randrange(len(arr))]
def translit_word(self, w):
out = ''
for ch in w:
t = TRANSLIT.get(ch, ch)
if ch == 'й' and self.chance(50):
t = 'y'
if ch == 'в' and self.chance(15):
t = 'w' # Wowan-стиль
if ch == 'х':
t = 'x' if self.chance(40) else ('kh' if self.chance(30) else 'h') # Xyligan-стиль
out += t
return out[:1].upper() + out[1:]
def fake_cyr_word(self, w):
out = ''
for ch in w:
f = FAKE_CYR.get(ch, ch)
out += f if f is not None else TRANSLIT.get(ch, ch)
return out
def leet_word(self, w):
out, used = '', 0
for ch in w:
l = LEET.get(ch.lower())
if l and used < 2 and self.chance(45):
out += l
used += 1
else:
out += ch
return out
def build_parts(self, lang, n):
"""-> список пар (слово, is_surname)."""
w = lambda x: (x, False)
s = lambda x: (x, True)
if lang == 'ru':
if n == 1:
v = self.r.randrange(100)
if v < 45:
return [w(self.pick(RU_NOUN))]
if v < 80:
return [w(self.pick(RU_NAME))]
return [s(self.pick(RU_SURNAME))]
if n == 2:
v = self.r.randrange(4)
if v == 0:
return [w(self.pick(RU_ADJ)), w(self.pick(RU_NOUN))]
if v == 1:
return [w(self.pick(RU_ADJ)), w(self.pick(RU_NAME))]
if v == 2:
return [w(self.pick(RU_NOUN)), w(self.pick(RU_NOUN))]
return [w(self.pick(RU_NAME)), s(self.pick(RU_SURNAME))] # VasyaPupkin
if self.chance(50):
return [w(self.pick(RU_ADJ)), w(self.pick(RU_NOUN)), w(self.pick(RU_NOUN))]
return [w(self.pick(RU_ADJ)), w(self.pick(RU_NAME)), s(self.pick(RU_SURNAME))]
if n == 1:
return [w(self.pick(EN_NOUN))]
if n == 2:
return [w(self.pick(EN_ADJ)), w(self.pick(EN_NOUN))]
first = self.pick(EN_ADJ)
second = self.pick([x for x in EN_ADJ if x != first]) if self.chance(50) else self.pick(EN_NOUN)
return [w(first), w(second), w(self.pick(EN_NOUN))]
def generate_one(self):
total = max(sum(self.weights), 1)
roll = self.r.randrange(total)
n = 1 if roll < self.weights[0] else 2 if roll < self.weights[0] + self.weights[1] else 3
lang = 'ru' if self.chance(70) else 'en'
parts = self.build_parts(lang, n)
# редкая смесь языков в 2-3-частных никах
if n > 1 and self.chance(15):
parts[-1] = (self.pick(EN_NOUN), False) if lang == 'ru' else (self.translit_word(self.pick(RU_NOUN)), False)
modes = ([['translit'], []][not self.translit]) + ([['fakeCyr'], []][not self.fake_cyr])
ru_mode = self.pick(modes) if modes else 'translit'
rendered = []
for w, is_surname in parts:
if RU_RE.search(w):
if ru_mode == 'fakeCyr':
rendered.append(self.fake_cyr_word(w))
else:
t = self.translit_word(w)
# фамилии на -ov: SmirnOFF / Smirnoff
if is_surname and t.lower().endswith('ov') and self.chance(50):
t = t[:-2] + ('OFF' if self.chance(40) else 'off')
rendered.append(t)
else:
rendered.append(self.leet_word(w) if self.leet and self.chance(20) else w)
nick = ''.join(rendered)
# декорации (стиль сида: dashka, ZloyPES, HolodOK, OldRogue38, MCVovan95, xXDarkSlayerXx)
if ru_mode == 'translit':
if self.chance(12):
nick = nick.lower()
elif self.chance(12) and len(nick) > 4:
k = 2 + self.r.randrange(2)
nick = nick[:-k] + nick[-k:].upper()
if self.digits and self.chance(28) and len(nick) <= 14:
v = self.r.randrange(100)
if v < 45:
suf = str(1 + self.r.randrange(9)) if self.chance(50) else str(10 + self.r.randrange(90))
elif v < 80:
suf = '%02d' % ((85 + self.r.randrange(23)) % 100) # год рождения 85..07
else:
suf = str(1985 + self.r.randrange(23)) # 1985..2007
if len(nick) + len(suf) <= 16:
nick += suf
if self.chance(4) and len(nick) <= 13:
nick += 'RUS'
prefixed = False
if self.chance(6) and len(nick) <= 14:
nick = self.pick(PREFIXES) + nick
prefixed = True
if self.wrap and not prefixed and self.chance(4) and len(nick) <= 12:
nick = 'xX' + nick + 'Xx'
return nick
def batch(self, count, taken=None):
taken = taken if taken is not None else set()
out = []
guard = count * 200
while len(out) < count and guard > 0:
guard -= 1
nick = self.generate_one()
if VALID.match(nick) and nick.lower() not in taken:
taken.add(nick.lower())
out.append(nick)
if len(out) < count:
raise RuntimeError(f'Не хватило уникальных имён: {len(out)}/{count}. Расширь пулы слов.')
return out
# --- Перезапись сидов ---------------------------------------------------------
XML_RE = re.compile(r'(<character id="(\d+)" name=")([^"]*)(")')
SQL_RE = re.compile(r"(VALUES \((270\d{6}), 'sppbots', ')([^']*)(')")
def rewrite(xml_path, sql_path, names_by_id, apply_changes):
xml_text = xml_path.read_text(encoding='utf-8')
sql_text = sql_path.read_text(encoding='utf-8')
def xml_sub(m):
return m.group(1) + names_by_id[int(m.group(2))] + m.group(4)
def sql_sub(m):
return m.group(1) + names_by_id[int(m.group(2)) - CHAR_ID_BASE] + m.group(4)
new_xml, n_xml = XML_RE.subn(xml_sub, xml_text)
new_sql, n_sql = SQL_RE.subn(sql_sub, sql_text)
if n_xml != len(names_by_id) or n_sql != len(names_by_id):
raise RuntimeError(f'Несогласованность: XML={n_xml}, SQL={n_sql}, имён={len(names_by_id)}')
if apply_changes:
xml_path.write_text(new_xml, encoding='utf-8', newline='')
sql_path.write_text(new_sql, encoding='utf-8', newline='')
return n_xml, n_sql
def main():
p = argparse.ArgumentParser(description='Генератор ников для ботов')
p.add_argument('--print', dest='print_n', type=int, metavar='N', help='напечатать N ников и выйти')
p.add_argument('--apply', action='store_true', help='переписать имена в XML и SQL (по умолчанию dry-run)')
p.add_argument('--seed', type=int, help='сид для воспроизводимости (по умолчанию случайный)')
p.add_argument('--xml', type=Path, default=XML_PATH)
p.add_argument('--sql', type=Path, default=SQL_PATH)
a = p.parse_args()
seed = a.seed if a.seed is not None else random.randrange(1 << 31)
gen = NickGen(seed=seed)
print(f'seed = {seed}')
if a.print_n:
for nick in gen.batch(a.print_n):
print(nick)
return
ids = [int(m.group(2)) for m in XML_RE.finditer(a.xml.read_text(encoding='utf-8'))]
names = gen.batch(len(ids))
names_by_id = dict(zip(ids, names))
n_xml, n_sql = rewrite(a.xml, a.sql, names_by_id, a.apply)
sample = ', '.join(names[:10])
if a.apply:
print(f'Переписано имён: XML={n_xml}, SQL={n_sql}. Первые: {sample}')
print('В работающей БД имена не изменятся (INSERT IGNORE) — нужен reset-bots.ps1 и пересборка образа.')
else:
print(f'Dry-run: заменил бы XML={n_xml}, SQL={n_sql} имён. Первые: {sample}')
print('Запусти с --apply чтобы записать.')
if __name__ == '__main__':
sys.exit(main())
+34
View File
@@ -0,0 +1,34 @@
# Полный пересид ботов, кланов и всего бот-мира.
# НЕ трогает: твои аккаунты и персонажей, твои кланы (если создашь), купленные тобой холлы.
# Стирает и пересоздаёт: бот-персонажей, бот-кланы, войны, их холлы/замки,
# прогресс/память/слухи ботов, олимпиаду, Семь Печатей, манор.
# Использование: powershell -ExecutionPolicy Bypass -File reset-bots.ps1
$ErrorActionPreference = "Stop"
$Compose = Join-Path $PSScriptRoot "server\docker\docker-compose.yml"
$sql = @"
DELETE FROM characters WHERE account_name='sppbots';
DELETE FROM clan_data WHERE clan_id BETWEEN 300000001 AND 300000999;
DELETE FROM clan_wars WHERE CAST(clan1 AS UNSIGNED) BETWEEN 300000001 AND 300000999 OR CAST(clan2 AS UNSIGNED) BETWEEN 300000001 AND 300000999;
UPDATE clanhall SET ownerId=0, paid=0, paidUntil=0 WHERE ownerId BETWEEN 300000001 AND 300000999;
DELETE FROM items WHERE owner_id BETWEEN 300000001 AND 300000999;
DELETE FROM crests WHERE crest_id BETWEEN 9000 AND 9999;
DELETE FROM olympiad_nobles WHERE charId >= 270001000;
DELETE FROM heroes WHERE charId >= 270001000;
DELETE FROM seven_signs WHERE charId >= 270001000;
DROP TABLE IF EXISTS fake_player_progress;
DROP TABLE IF EXISTS fake_player_memory;
DROP TABLE IF EXISTS fake_player_rumor;
"@
Write-Host "Стираю бот-данные..."
$sql | docker compose -f $Compose exec -T db mariadb -uroot -pl2j l2jmobiusinterlude
if ($LASTEXITCODE -ne 0) { Write-Host "Ошибка SQL — сервер запущен? (docker compose up)" -ForegroundColor Red; exit 1 }
Write-Host "Перезапускаю сервер (логин зальёт свежий сид)..."
docker compose -f $Compose restart login
Start-Sleep -Seconds 8
docker compose -f $Compose restart game
Write-Host "Готово. Гейм-сервер поднимется ~1 мин, боты пересозданы со стартовых уровней." -ForegroundColor Green
+14
View File
@@ -0,0 +1,14 @@
# src_mobius — рабочие исходники сервера
Рабочая копия исходников L2J Mobius CT_0 Interlude. **Все правки кода и данных ботов делаются здесь** — это источник истины.
Структура (только то, что нужно для разработки):
- `mobius/L2J_Mobius_CT_0_Interlude/java/` — полные исходники сервера (1630 файлов). Всё по фейк-игрокам: `java/org/l2jmobius/gameserver/managers/FakePlayer*.java`
- `mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/` — данные чат-системы: `FakePlayerChatLines.xml` (швы+правила), `FakePlayerPools.xml` (рекурсивные пулы), `FakePlayerIntents.xml` (лексикон понимания), xsd
- `mobius/L2J_Mobius_CT_0_Interlude/dist/libs/` — jar-зависимости для сборки
- `build.xml` — ant-сборка (Java 25)
Деплой: собранный `GameServer.jar``../server/libs/`, XML-данные → `../server/game/data/`. Бэкапы jar лежат в `../server/backup/`.
`../src_mobius_interlude.zip` — замороженный архив (состояние на момент переезда сюда), больше не обновляется. Полный датапак (config, скрипты и т.д.) при необходимости достаётся из него.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
-- SPP crests: imported from the L2Topzone crest pack (DXT1, 16x16 clan / 8x16 ally).
DELETE FROM crests WHERE crest_id BETWEEN 9000 AND 9999;
-- id-диапазон сдвинут: клиент кэширует текстуру герба по id, старые id он считает битыми.
INSERT INTO crests (crest_id, data, type) VALUES (9500, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000005cbe0000555502a2198d00005555ac2f3b8da508283c9454d87400003f3f3635d55b00007c5c5e5e566c00003515959d36a50000574b555577ad0000fbe35555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9501, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000feff000055550000ffff000055550000feff000055550080ffffcc52c040e002feffa2c002837258dfffe8a0c0628d25fef7a94a83898982ffffa9520000e0609fff3420fc5c7283bfff122027258dcafdff0a5380828ba9ffff000080005555ffff000002005555ffff0000e08055551ad7000082805555, 1);
UPDATE clan_data SET crest_id=9501, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000001;
INSERT INTO crests (crest_id, data, type) VALUES (9502, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000410000005555a2421ce700005555752575b500005555555c0421000055554d6dffff2000552509f9ffff8d6b2a090301ffff20006ab820f0beff00005556505b7def000071e19555ffff00000d3f0033ffff00005cdf807379d6000052525555410800001d7b5555fff7000030aa5555def700004348555501000000f5f55555, 1);
UPDATE clan_data SET crest_id=9502, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000002;
INSERT INTO crests (crest_id, data, type) VALUES (9503, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000073ef0000555555f0ac100000555500008d1000005555020073ef00005555550f9fffe4181a6aafbbdfffa71800000104dfffa720000040109fffe418a4a9faee94df042982330f4019efa228d0a4fdff39efac18051a7fff54e7e32082c8f001eee70000d0f0555595200000000055559520000000005555e0ff000005055555, 1);
UPDATE clan_data SET crest_id=9503, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000003;
INSERT INTO crests (crest_id, data, type) VALUES (9504, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000c2d00000555500a0e2c800005555a0a0e2c8000055550080a79100005555008001b180a8f4accccc82d840980b0bff5725a18398aa70026a42e863a058585a5a61a9c0986ccc44cc83b960a8ac26f6be41f082a058587858879942903f3f1f1fc2d80000a8805555c1c80000a0205555e4c00000a000555562e00000a0005555, 1);
UPDATE clan_data SET crest_id=9504, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000004;
INSERT INTO crests (crest_id, data, type) VALUES (9505, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004d8b000055552a0a4d8b00005555a000c972000055550800076a00005555e8a04ec32982ad2d2d2d6ecbeba27fa8a0ea2cc3099a77eae0e008a261586a606060ecaa66490badadbd0bab697a00aaff554cb3e881a0f8fe57e791a0406068e8ea876100000aab5555076a000000aa5555286a0000008855552561000080e85555, 1);
UPDATE clan_data SET crest_id=9505, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000005;
INSERT INTO crests (crest_id, data, type) VALUES (9506, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000fdbd0000555528a457730000555580ff74520000555500af9808000055550a2f9dce0400d49494d4346303005574acac9c100900ad8b0bafb218040037b7acac9cce03009494a4e4ff5a0d00eceff4f192180600b7d70a00cf1807003cbc372bbed60000f4a05555ff6a000022005555ff490000aa0055553b3900002a005555, 1);
UPDATE clan_data SET crest_id=9506, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000006;
INSERT INTO crests (crest_id, data, type) VALUES (9507, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000100000055550000525a00005555a52515ed0000555583aa95dd000055555758f7280000555feae0b4ab03003525250d52cc00085a5c5c5cf5fc0018638d8d89f76206006262638974bba8080d8ba2acebba0018fc08fa5610dc0008e25855553a9c00002da555554d8200000c085555c26800005c5e55550008000000005555, 1);
UPDATE clan_data SET crest_id=9507, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000007;
INSERT INTO crests (crest_id, data, type) VALUES (9508, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dfff000055555515fff70000555500a0dff700005555003e9ff7000055555554dfff00000581a1a15dff4c393a3abe9f5ff71c0054545456ffff000053434f4fdfff0000a1e1c1c53fad38081f571595f8a42a10f4755456bfff010047474151bfff000015555555dfff00007c005555bfff000015005555dfff000054555555, 1);
UPDATE clan_data SET crest_id=9508, ally_id=300000001, ally_name='FreeUnion', ally_crest_id=10000 WHERE clan_id=300000008;
INSERT INTO crests (crest_id, data, type) VALUES (9509, 0x444453207c00000007100800100000000800000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000027f5000055552b2b24dc00005555a0a02af6806027260a09acfe60a1defc7c5c53cee248fdab03ad6eee2092f76aa2f84aad0000302355550dde0000a8a85555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9510, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000000800005555020b003000005555bf2fc5b800005555cd0b447900005555f22800300000d5d5df0bc3d00010f52d0d2b83e000b08900aa69e3c800b0cbfeb581c3d00040bd09092f82e000a8ede068aaa2d800a83b3e818a82e800680000aadae4c000000828555581e80000020a5555a2d0000000205555a691000030385555, 1);
UPDATE clan_data SET crest_id=9510, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000009;
INSERT INTO crests (crest_id, data, type) VALUES (9511, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000400800005555a323c241000055557517c02000005555f76c2000000055553505ebf60000d5bd09b5cdfe80491fa08022ccfe205a67a000a22cde0000557e5057601000003fe7f5d9d4ee20080dcbe1e9d8f60000585a7353610801003ab5ffff40000000c5dd555575ff000089ad5555d3f60000505655550100000000005555, 1);
UPDATE clan_data SET crest_id=9511, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000010;
INSERT INTO crests (crest_id, data, type) VALUES (9512, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000001bff00005555aa829bfe00005555aa02fbfe00005555aa009bfe00005555eac03cf7c77963f30303dff7857009b7dc50deff00580082ff559fff8659c0c2c3c1bfff6ea389a9a9a9dfff2048706060e05dff2549f589bfdfbeff2128c9cdcbc09eff000002bf55551cff000000aa555572c400003aa855559eff000080eb5555, 1);
UPDATE clan_data SET crest_id=9512, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000011;
INSERT INTO crests (crest_id, data, type) VALUES (9513, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000bed600005555c1f17dc6000055550e5ff9c6000055550050b8d60000555500559fc620005101d4f17fbe0c00147475d5dbd6200000540001fbce0000545540549ece000031500155fcd62b00540040549fc64b00df755540bdce03001441140021000000253d55551ac70000040055559fc600005c0f5555ddce000054405555, 1);
UPDATE clan_data SET crest_id=9513, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000012;
INSERT INTO crests (crest_id, data, type) VALUES (9514, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f183000055555515dfff0000555509e0bff700005555600b117c0000555555549fff0000152585c5bfff0b0054514515dff7070815455154dfff0100545852519fff0100c58525157fff0c0015551554dfff010055545594dfff0000515258545184000015555555dfff000020095555ffff000008605555307c000054555555, 1);
UPDATE clan_data SET crest_id=9514, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000013;
INSERT INTO crests (crest_id, data, type) VALUES (9515, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004208000055558033f08700005555f525810800005555b2a22000000055550f0fe0000000d79d1d1fef7f4000a5e92a2aeb550000575c5252000800007ae0a82aa0002100151d1f1ff07fe00355555515c836000052509292b9ce000055555852a00000003f3d5555f07700002aed5555b9d600001b97555578c60000585a5555, 1);
UPDATE clan_data SET crest_id=9515, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000014;
INSERT INTO crests (crest_id, data, type) VALUES (9516, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000feff000055550054feff000055550055fdff000055550055daff000055550015fdff621004a4a4a47bd60000371797b79dd6010000029e5e3af70008151e3a1afcff3608f4d454543373000837b7f7f7136b0308dcbeaaaa99ee01083a1f1f17d7ff0000d400555557f700005500555596ee0000ff0055552ecd000015005555, 1);
UPDATE clan_data SET crest_id=9516, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000015;
INSERT INTO crests (crest_id, data, type) VALUES (9517, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dff70000555500fc9fef0000555500bfffff00005555000affff00005555282e9ff7540074d4f42cffeffb31f5258228dfff8d3ae2e87edeffef6d3a2d2a2a2adfff1242040434d4ffffcc5288e08060ffcef052aba4adabffefdc8d3d3f3f2fdff70000bc005555ffff000002005555dfff0000a8205555fff7000002005555, 1);
UPDATE clan_data SET crest_id=9517, ally_id=300000009, ally_name='IronUnion', ally_crest_id=10009 WHERE clan_id=300000016;
INSERT INTO crests (crest_id, data, type) VALUES (9518, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f4a400005555f502674a00005555f87ed8c565298a436bfdc5bd01105d7f6787a4a400003d3d2d2d83ef00008bcdcdcda4a400002d2d5555e56a000009fd5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9519, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000020000000555508561cd70000555565051cd7000055555950400000005555b85ebf9c0000ff2df5f5ffce36080d02bdbdffce360870807e7ebf9c0000df785f5fbf9c0000f5f52dffffce1908bdbd020d1fd718087e7e80709e9c00005f5f78ff01000000005c5555bace000005455555b9c60000505155552100000000395555, 1);
UPDATE clan_data SET crest_id=9519, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000017;
INSERT INTO crests (crest_id, data, type) VALUES (9520, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dad6000055555554ffff00005555e041dfff000055550549dfff000055555455fff7020050420414feff0000c5850482dfff00004252114cffff000015011035ffff010014144252dfff000000584549dfff000060125241ffff00002d3e211599ce0000541555551cc60000896a555537c600000fa95555ffff000015575555, 1);
UPDATE clan_data SET crest_id=9520, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000018;
INSERT INTO crests (crest_id, data, type) VALUES (9521, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004a52000055555515f4ff000055550537f3ff000055555cf06e6b000055555554bfff000015d555d5ddff000014143404f6ffe0180000001c9cf70000545757579bf70000d5d5d515f2fface7ec9c3480fcff000814341414bdf70000575757542f8c00001555555516e700003c555555d3ff0000d45055550e63000054555555, 1);
UPDATE clan_data SET crest_id=9521, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000019;
INSERT INTO crests (crest_id, data, type) VALUES (9522, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055555500ffff000055555500ffff000055555500ffff000055555500def7000054044466ffde01005500556affde0100550055a9bef7000015101199388c05004466a657959403004055aa55959402000155aa55188c050011919ad5cf10000000aa55558f08000000aa5555af10000000aa5555cf10000000aa5555, 1);
UPDATE clan_data SET crest_id=9522, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000020;
INSERT INTO crests (crest_id, data, type) VALUES (9523, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f7b5000055550a027cff00005555020237ee00005555003eb7bd0000555500001ad7f083fd8f2f2fdfffa56042e0c0403dff417055571501dbfec4608aa109a8bbd60f7cbbcfbf0dffff490800495457dff709080d3519055eeff49cf878e475bad600002bab55551acf0000e00255553fe7000000005555b9ce000080a25555, 1);
UPDATE clan_data SET crest_id=9523, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000021;
INSERT INTO crests (crest_id, data, type) VALUES (9524, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000060100000555545ca457900005555f50d8689000055555ff8201000005555ba2836ff001849261415fcfe0028ed49555521a900306bad092914ff20086198145496ff001005515a162ffe0008fcf1254591ee00083f4f5851d6ff00085065a59440100000808e555592de000052555555b3e60000855555552010000002ea5555, 1);
UPDATE clan_data SET crest_id=9524, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000022;
INSERT INTO crests (crest_id, data, type) VALUES (9525, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff00005555020bffff000055550000ffff000055550000ffff00005555a0c8ffffc631934b4bc3ffff2000000062d3ffff000020b075d0ffff4100c0c0c2c1ffffa629c3f27a82ffff00001f1358dbffff0000405090d5ffff4060c3c07fd5fff70000c2fd5555ffff0000003f5555ffff000000ff5555d4b40000c03a5555, 1);
UPDATE clan_data SET crest_id=9525, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000023;
INSERT INTO crests (crest_id, data, type) VALUES (9526, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000201000005555800266d200005555b52da6d200005555fe80c150000055555e7ca7b2000055a50585c4da00280d2f020b43f2002880c0687ee16000005c5e77df035100008df5555563da00000d0d25ad63f20050e820e07b86d200005f58585e00100000b5355555418900008a0f555583790000f05f555540080000b0d05555, 1);
UPDATE clan_data SET crest_id=9526, ally_id=300000017, ally_name='SilverUnion', ally_crest_id=10018 WHERE clan_id=300000024;
INSERT INTO crests (crest_id, data, type) VALUES (9527, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000c1d0000055551535a1e0000055555c5864a100000d89a92923c90000eaca6268c2e80000c9c96bed03c100006b6b717303b100000d25555522b9000072785555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9528, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dfff000055555545dff700005555fcf1dff7000055554f53bff70000555555549ff7000035f575f59fef0700507755557fff070003555555ea2800007c5e5e5edff7000035455555ffff000050715151bff7000001515151bff70000555455550100000025055555ffff000051d45555dfff0000514555552000000000085555, 1);
UPDATE clan_data SET crest_id=9528, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000025;
INSERT INTO crests (crest_id, data, type) VALUES (9529, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000324a00005555f501924200005555ff00933a00005555ff00b04a000055555fc0dade020095a99989d3f7080015ff15009dff020054ff54005ff70500577f6662ffe60000899901157cef0100001500159cff0000005402543fef0000626640547ff70000015555557eef0000005555557eef0000005555557ff7000040555555, 1);
UPDATE clan_data SET crest_id=9529, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000026;
INSERT INTO crests (crest_id, data, type) VALUES (9530, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ec83000055555515bbf7000055555512fdff0000555555008c73000055555554d6f70000b52d6929f9ff200040051000dcff000054555555dcf700005c615561f8ff0000292d09b5f9ff200000000154fbff200054004015f8ff00007060605ceb7b000015555555dbf7000002555555dbff000000555555cb73000054555555, 1);
UPDATE clan_data SET crest_id=9530, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000027;
INSERT INTO crests (crest_id, data, type) VALUES (9531, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000009b310000555500805829000055550000134a00005555a0006f39000055558000bfd6d6105ff515357fef6b08ff7ed4ac7ff7d339f5ad0df8d5830710fa78fc7e9fef90183535b557ffffca390060600336842908aaff5f78cd410200a0fa7e7fb139000000805555ad390000e8bf55550a210000a0ab555507080000e0e05555, 1);
UPDATE clan_data SET crest_id=9531, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000028;
INSERT INTO crests (crest_id, data, type) VALUES (9532, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ecfc00005555aa8aafed00005555f8f389a2000055558fbf02c200005555020a0cfd0010c0fe6e6e19d70030fdeb070f9def4408c953c0009cff0020f5fcd05befa30008db2b1b5dbbf763418cb042007cdf2151000e0d2f59e70000705e5e56d4c5000015d555557ef700003ddb555579ee00008ceb55550f7c000055515555, 1);
UPDATE clan_data SET crest_id=9532, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000029;
INSERT INTO crests (crest_id, data, type) VALUES (9533, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000430800005555d527f76b00005555f161d97b000055555372610800005555fc52578c01005595554596ed0a00597b177db78b060051a37d5eb57300005555555cd9630008cbcaada515840300496145c5dd63060058529696dd730300586a5757714a000035f555554e4200003ffb5555334a00003ffd55552100000000085555, 1);
UPDATE clan_data SET crest_id=9533, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000030;
INSERT INTO crests (crest_id, data, type) VALUES (9534, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000fef700005555552bffff000055558270aa52000055555605600800005555fff1dff70000a8952a3effff0000dc007c5cffff0000892a35359eef000055560ee0ffff000025c17857ffff000070c32d95ffff000015951520ffff01005c5c5ca821000000b2725555107c000035555555ffff000000755555dad60000e0555555, 1);
UPDATE clan_data SET crest_id=9534, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000031;
INSERT INTO crests (crest_id, data, type) VALUES (9535, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000041ff0000555500002aff00005555c030dfff00005555030effff000055550000eafea0a1202240908cfe00289c1795005cff002836d656aafeffa49900000107ffff40a1904000005eff0028aa95979cccfe00382254d4342aff40a906812020ffff000000005555deff0000b0c05555e8fe00000c03555521ff000000005555, 1);
UPDATE clan_data SET crest_id=9535, ally_id=300000025, ally_name='NorthernUnion', ally_crest_id=10027 WHERE clan_id=300000032;
INSERT INTO crests (crest_id, data, type) VALUES (9536, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000003aef00005555550b18e60000555555c89cff20202b2f3fb557deaa724c48484a17c60030b0afb05e38de0018c8c8c855f3a400008255555575cd0000c0555555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9537, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000300000055551f1f04e2000055552b2b51db00005555b0b00500000055555c5c864100005555d5150ce5eb18adaf0b8a4dcd872890dae082c641000057555f540e9c0000951515d5f2fe27b30ef6cef799ff8b8bffbf3f4e2d9c00005654545520000000088c5555aedd00000aad555516ff0000605a55550100000002005555, 1);
UPDATE clan_data SET crest_id=9537, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000033;
INSERT INTO crests (crest_id, data, type) VALUES (9538, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f07b000055555515ffff000055550000bfef0000555540a02108000055558b1ad6bd000095155515ffff20000200959b3edf0000a0a055a941080000cf3effffd7bd000015555555ffff000060a125e9fff700008252565a01000000fffffdf501000000f5755555ffff0000f1055555d7b500005254555501000000d5575555, 1);
UPDATE clan_data SET crest_id=9538, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000034;
INSERT INTO crests (crest_id, data, type) VALUES (9539, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000230000005555f73fdff7000055550db39ff70000555562fe2000000055550201ed41000035155555dff7ad310404f305bff70200bcce725f030000005cfc5e5e6d52000055551515ffffcc3901030200bfd604005c5ed7bf516b0000555556542c4a000015d55555ffff000000e35555bff70000e07f5555450800007c7e5555, 1);
UPDATE clan_data SET crest_id=9539, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000035;
INSERT INTO crests (crest_id, data, type) VALUES (9540, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dfff000055558054fbac00005555a8ffac5a00005555e0552521000055555c55bfff28002454d414ffff461858606cc4ffff0000c261725040080000e31967d9bff72a0034240054dfff0710ce359855ffff00005854145595ad000055555155dff7000054805555da9c0000ffa85555ad5a000055e85555c318000055545555, 1);
UPDATE clan_data SET crest_id=9540, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000036;
INSERT INTO crests (crest_id, data, type) VALUES (9541, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000a731000055557f00dbd600005555550ab7ad0000555555fc493100005555fd0efff704112589f3dadfef8719007fddbfffff03005fd65ee89bcec618775fef2779b60200f3efffd578bec61837bdddf5dfff0000fed5ab00bff701002f8be05ee3180000f4d55555b39c00003d55555515a50000e855555546290000b0555555, 1);
UPDATE clan_data SET crest_id=9541, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000037;
INSERT INTO crests (crest_id, data, type) VALUES (9542, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000200800005555ff082bff0000555505a106fe0000555550502018000055557f0210ff2010413cf18536f70010a1858483cfe50008505010808fed0020413c4e522fff0008c585b131f1f64028aa2a2821efe50038a0a020408ffd000852524e4c4ff700003141555533ff0000851555558fd5000050545555ece5000048415555, 1);
UPDATE clan_data SET crest_id=9542, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000038;
INSERT INTO crests (crest_id, data, type) VALUES (9543, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000ffff00005555b0085dff00005555800affff000055550200ffff253900240708ffff8340c34040605eff02400d2d3534dfff694aa0604000ffff242900808034ffff37ce60a8e36a7dff25490d002aa8beff2f84a2f8ef7dbeff00002c805555feef000000a05555fef7000082005555feff0000eac05555, 1);
UPDATE clan_data SET crest_id=9543, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000039;
INSERT INTO crests (crest_id, data, type) VALUES (9544, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dfd60000555500f49fad0000555500f5bf9d00005555007fdab500005555404fbfde0f0014545454f5ff2810404000105aef0b00059595949fde00006c65674fbfd62700545c9454f3f743105c025a551ce70c08912805557fd600004f6f4c6f5fe70000a8555555f7bd000000555555dabd000000555555d9b5000040555555, 1);
UPDATE clan_data SET crest_id=9544, ally_id=300000033, ally_name='OldUnion', ally_crest_id=10036 WHERE clan_id=300000040;
INSERT INTO crests (crest_id, data, type) VALUES (9545, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000007153000055555ff2f25200005555fd8b3353a50050da587ef35225008b0b55550e53a4003e3e9d15ffffa510a8a5fafd4e5300003df55555ffff0000f8555555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9546, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004fff0000555500fcf3e50000555500af0ebd0000555500ae4a9300005555a0b097f63030545454d6ffff4d410101fb00deff87480a827f607fff0738d8d7d5d53fee4e4056d69617feff401800a0f87edeff48407060e2fa687a03483bbb3f398d8300003c805555c5490000e0a05555654900002aaa5555a57200002b0a5555, 1);
UPDATE clan_data SET crest_id=9546, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000041;
INSERT INTO crests (crest_id, data, type) VALUES (9547, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000055ae0000555500fc56cf0000555500fd17bf0000555500fffef700005555023ef095e019545454b4f8bea01955f52b02d9b60322bd8be8a878dfc0191e171517f5858722a4ceca0e39bf8a2baf252eb6d9b6c121807e787a15c7a2211715151573750000ac005555f58d0000ff00555533ae0000ff00555517c7000037285555, 1);
UPDATE clan_data SET crest_id=9547, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000042;
INSERT INTO crests (crest_id, data, type) VALUES (9548, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000684000055550bd4f6ff000055550a40b8f700005555a055a77b00005555e01ddaff000857560600fbffc020010504804cc600005557ff78b7ff0000dd979715f7ff000000405657f0ffc02080616979c99c000078575555b8ff000015159595ab940000562b5555f7ff000075025555b4f7000055e85555a973000035e85555, 1);
UPDATE clan_data SET crest_id=9548, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000043;
INSERT INTO crests (crest_id, data, type) VALUES (9549, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000feff000055550000ffff000055550038ffff000055550000ffff000055550000ffffe3180000b8dcffff294a1c3cbcdcffffe83900000009ffff0f7c00200835ffffe3189c383898ffffcb5a1c3c3c14ffff0842cd4f4fc5ffff8629bb2a022dffff0000b8005555ffff0000a8005555ffff00000a005555deff00000e005555, 1);
UPDATE clan_data SET crest_id=9549, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000044;
INSERT INTO crests (crest_id, data, type) VALUES (9550, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000048420000555555350f84000055552ba2dbd600005555ea0b9ace00005555555caa4a00002d897b7bad730000d617aa721cdf0000d57e0b25bff7010050c38ea503210000d94dc5b5cf8300007bab985755a50000050b5ed555ad0000058feb7a21080000f0cf5555442900002a255555eb5a00000beb55558a5200005c555555, 1);
UPDATE clan_data SET crest_id=9550, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000045;
INSERT INTO crests (crest_id, data, type) VALUES (9551, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000100000055555555410800005555fd78200000005555489d0100000055555dffffff000000414141ffff000001050505ffff000040410505ffff000055414155ffff000041414100ffff000005050501ffff000005054140ffff00005541415520000000185755552000000064a5555520000000835755550100000055555555, 1);
UPDATE clan_data SET crest_id=9551, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000046;
INSERT INTO crests (crest_id, data, type) VALUES (9552, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000053f7000055551581c2ff000055550000c9ff0000555550624008000055557a0a69fe00080125955522fe20300000000148ff00186058180622f6000855514040cddd000055551505e1fd00180509905423fe00200000002104fe001040404050b3c50000415555552010000008f555554cd50000155555556bd5000050555555, 1);
UPDATE clan_data SET crest_id=9552, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000047;
INSERT INTO crests (crest_id, data, type) VALUES (9553, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055555505ffff000055551485ffff000055551452ffff000055555550ffff000011511181ffff1184e0f0034fffff704a0f0f4053ffff000044454443ffff0000a0915111ffff514acf03505cffff0d3a53400735ffff00000b464544ffff000005555555ffff0000e5945555dfff00005b165555ffff000050555555, 1);
UPDATE clan_data SET crest_id=9553, ally_id=300000041, ally_name='WildUnion', ally_crest_id=10045 WHERE clan_id=300000048;
INSERT INTO crests (crest_id, data, type) VALUES (9554, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000d5a000055550ecb495100005555808f728b0418d93d7b7eb5ac67307d7bf9d894b402205f6d6d8d7dee0520dd7273d558bd00002fd75555ad610000a2aa5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9555, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000456900005555fa22a68100005555af00a7a900005555faa0868900005555fe28e8a10038dbb3939366b10040f5abfd2bc7a9006855ca7e0848b20050959dd119e7a10048939b939bc6a10050f921dd2d08ba00689e2b57f807b20060d1999ddd65910000b2a25555c6990000af025555c7b10000aa805555859900002a2a5555, 1);
UPDATE clan_data SET crest_id=9555, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000049;
INSERT INTO crests (crest_id, data, type) VALUES (9556, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000054f700005555550172f700005555550450ef00005555551053ef00005555554075ef00005151515170f720084444444490f7400811111111211800007e7b6b2a8ff72008a25151516dff2010aa44444491f74008bb111111a68a000080ff555556ef00000155555572ef00000455555556f700001055555595ef000040555555, 1);
UPDATE clan_data SET crest_id=9556, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000050;
INSERT INTO crests (crest_id, data, type) VALUES (9557, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002efc00005555aa0290fc00005555aa00fcdc00005555ff00bddc00005555fff013fe00601f7f434332fe0050004147dfa5e10060f5ebfa8ccffe69207f474040b9fe09301151a5a90bfe00a855d554000ffe20881f777112adfe0080c0c0eae919cd000003ff555519d5000000ff55556ffc00000caa55552dfc000088aa5555, 1);
UPDATE clan_data SET crest_id=9557, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000051;
INSERT INTO crests (crest_id, data, type) VALUES (9558, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000ffff00005555b0085dff00005555800affff000055550200ffff253900240708ffff8340c34040605eff02400d2d3534dfff694aa0604000ffff242900808034ffff37ce60a8e36a7dff25490d002aa8beff2f84a2f8ef7dbeff00002c805555feef000000a05555fef7000082005555feff0000eac05555, 1);
UPDATE clan_data SET crest_id=9558, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000052;
INSERT INTO crests (crest_id, data, type) VALUES (9559, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ed5200005555a8a8ea310000555500004b2a00005555000088410000555508a0ffffc93157577515f7b5aa315555dff30b630641fafa7a78449943407a725252beffcb3917595555d6bda831b89dddb5eb6ac250e87edf7e02818130f0f87ad64b420000000255559fff00003fbf55558751000028005555ae520000ea0a5555, 1);
UPDATE clan_data SET crest_id=9559, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000053;
INSERT INTO crests (crest_id, data, type) VALUES (9560, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ff39000055552f2dffff000055550352ffff00005555a0a1183a00005555585c1f3a00002d2d2d2dffff0000424a4a4adfff1a5b70787878554201005c5c5c5cdf3100002d2d2d2ddfff01004a4a4a42fffffa5278787870553a00005c5c5c5cfe3900002d2d5555dfff000052025555dfff0000a1a05555394200005c785555, 1);
UPDATE clan_data SET crest_id=9560, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000054;
INSERT INTO crests (crest_id, data, type) VALUES (9561, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000e87100005555ff33a88100005555ff00849100005555ff00878900005555ff30a69100285311133345a90028d529f52985a1004055805c00a68900381531313386a1004819119191e7810030f103d50165b90058bc005ea0a69100481111111186910000f333555567a10000ff00555585910000ea005555659900003f305555, 1);
UPDATE clan_data SET crest_id=9561, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000055;
INSERT INTO crests (crest_id, data, type) VALUES (9562, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000005100005555fb00005100005555ffa0e05800005555ff20c05800005555ff00e7fe00480202cbc98cfe0050e8828d2506ff80b9ab22929ac6fe60b1ca8886a6c7fe0050c9cb0202cafe0058058dc27a23fe00303735090923fe00385c5c6060e058000000ff555500510000e0df55552051000003fd555520590000e07f5555, 1);
UPDATE clan_data SET crest_id=9562, ally_id=300000049, ally_name='RoyalUnion', ally_crest_id=10054 WHERE clan_id=300000056;
INSERT INTO crests (crest_id, data, type) VALUES (9563, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000fff700005555ff2fffef00005555fff8bff70b192f70e8e9ffefea08f80f2f6cfff7a708abed792dffe7c7008e7bed78fff700002fff5555ffef0000f8ff5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9564, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000051cc0000555502fc96dd0000555500ff59f60000555500fffbf60000555580bf3fa53418543e365cbfef51187d6ac00f5fefb210f5ca628297de37003735323a96d54e185cdcdc9cbfff73000b22de57dfff37080028d555ffbd9018171ca2bdd9e60000fe02555519e70000ff00555598de0000ff00555513d500003f805555, 1);
UPDATE clan_data SET crest_id=9564, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000057;
INSERT INTO crests (crest_id, data, type) VALUES (9565, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000072bd00005555005493bd00005555a0550fad00005555205d4d9c0000555500154eb5001054544424afbd0008253515028fbd401058565408f0ac000895959198edac00080c6454540dad0008889db5bdeeac00102256567e2d940008309195150dad00005c085555509c0000550055550d940000550055551094000095a05555, 1);
UPDATE clan_data SET crest_id=9565, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000058;
INSERT INTO crests (crest_id, data, type) VALUES (9566, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000006812000055551505ffff000055551f1fdfff00005555f4f48d13000055555868ffe76300fdffff00ffff42001f1f1f00ffff2200f4f4f400ffd7080a5f7fdf00ee3361005502220affff2000151b1f1fffff410054e4f4f47134800055a8a8ea4d130000292d5555bff700001f1f5555ffff0000f4f455556d0b0000a0685555, 1);
UPDATE clan_data SET crest_id=9566, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000059;
INSERT INTO crests (crest_id, data, type) VALUES (9567, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000001de7000055555515fed60000555501a8bdad00005555507e4208000055558280ffff00008585a1a1ffe66d313a3a3e1f3def8a10545454540d4200007e5c48489dce200081a1c5c5dcee3310175515151cef0e00545554544a31000040707e5eb473000015555555b3520000f8a155554f390000afe0555507290000dc575555, 1);
UPDATE clan_data SET crest_id=9567, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000060;
INSERT INTO crests (crest_id, data, type) VALUES (9568, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000006218000055553f2f92d3000055555522dfff000055555582e42000005555d55c0bba0000352535154fdb00302a2af868deff0038030b5555feff000056585557d7de0000d5555535bcff2020add5eb00feffec837b7d6d00deff000057555558b4de000009c25555f2c50000e855555577ce00002d555555feff0000600b5555, 1);
UPDATE clan_data SET crest_id=9568, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000061;
INSERT INTO crests (crest_id, data, type) VALUES (9569, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000087ff000055550000e4ff00005555000ae2ff0000555500a847ff00005555002a6effe7a4b868f83c29df403bf2f47e55ace6ca0b0b3ff5d5f0fea0a21f1716144eff88b43cbc7c7cebee2000fffe5c602af76000ffbf3509d1fea0a114161715a5ff0000a8005555e2ff00000a005555e1ff0000e000555526ff00003e005555, 1);
UPDATE clan_data SET crest_id=9569, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000062;
INSERT INTO crests (crest_id, data, type) VALUES (9570, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000e9ff000055550505caff000055555758cdee00005555d515edff00005555585ce4c5000015555555e8ff000060290589d1fe0100f5707caeeaff20005555b50afeff00005595896a21f700000a16d555ffff40c35f7ae283ecffe051020382602000000015105555f5ff000055155555f6ff000000a05555a2ff000078575555, 1);
UPDATE clan_data SET crest_id=9570, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000063;
INSERT INTO crests (crest_id, data, type) VALUES (9571, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000007cff0000555515459cff000055555045bcff0000555505519cff0000555554517dff0008d14545451dff00401d1f1f7f1dff0040f4f4f4fd7dff0000475151515cff0008454545f11dff02381d1f171f1cff005074f4d4f41bff00007153514fdcf6000045155555ddff000065505555bcff000059055555dbee000051545555, 1);
UPDATE clan_data SET crest_id=9571, ally_id=300000057, ally_name='UnitedUnion', ally_crest_id=10063 WHERE clan_id=300000064;
INSERT INTO crests (crest_id, data, type) VALUES (9572, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000a8620000555557c74b4d00005555c9cd4dbc0020b8afaac90b7c4000b6f7ebb30d9c0000bae9e1a553a58000dbd64d656591000025b55555b39400004f7e5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9573, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000c8990000555500f80fbc00005555025f58e6000055550255feff00005555001549a200102cfc5e56eec302080215155517e6000802555456ffff000010151515a69100005c5c5c7c30bc010015151555d4cc000054555454ffff000015151515089a0000fc005555afc300005502555538d6000055005555ffff000015005555, 1);
UPDATE clan_data SET crest_id=9573, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000065;
INSERT INTO crests (crest_id, data, type) VALUES (9574, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000821000005555f5ccffff000055555101a310000055559512e318000055556549ffff000035550909ffff6208c0021500ffff000055014006ffff000041406160ffff00000d055501ffff01002b000180ffff0000450d4c51ffff0000614041415ace000051555555dfff000051515555ffff0000505555557eef000041555555, 1);
UPDATE clan_data SET crest_id=9574, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000066;
INSERT INTO crests (crest_id, data, type) VALUES (9575, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004421000055559517f6b500005555000078c600005555002a99ce00005555625ab29c000055d5353555b5694aaff338bddbdee841abbfdb4d14a5000078585a5e34a500003595353538c6aa5282976aef99ce0842fd0ea4af95ad00005e5c5656149d00002dad5555d3a40000c822555514ad0000822055550b5b000054545555, 1);
UPDATE clan_data SET crest_id=9575, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000067;
INSERT INTO crests (crest_id, data, type) VALUES (9576, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000009ace0000555555f3210000005555905a200000005555029cbbce0000555555cf7ef70100adb5354fffff00005776b425ffff0000d59d1f58dfff00007a5e5cf1dfff0000003fd555ffff0000f754524fffff00005f1585e1ffff000000fc57552000000062e955551ce70000456555553ce700005159555520000000394b5555, 1);
UPDATE clan_data SET crest_id=9576, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000068;
INSERT INTO crests (crest_id, data, type) VALUES (9577, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000ffff000055550050ffff000055550005ffff000055550000dff7dfad00aaff55ffff0700d0d0dadaffff0600070707a7ffffbf9d00aabf551f85bd2a00aaaa57fff721009a9a5a1efff72200a6a6a5f47f859d2200aaffd51f3b000000005555fc3a0000f0a055551d4300000f0a5555ff32000000005555, 1);
UPDATE clan_data SET crest_id=9577, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000069;
INSERT INTO crests (crest_id, data, type) VALUES (9578, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000079ad0000555555153ea50000555521ba389d00005555522b38c6000055555515bfd60100c5e9391abfd6290001500447dfce060070c1444998ce0000545119537fd6030038d96da5bfd62900045001257fce050050954ad07fce0100d6746d5a5bad0000155555553fa50000b20155551fa50000ae50555579ad000054555555, 1);
UPDATE clan_data SET crest_id=9578, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000070;
INSERT INTO crests (crest_id, data, type) VALUES (9579, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000079fe00005555ff0bd4fd00005555ae8068e30000555500802fff00005555aa8f3ae7075a098989a918af523da86bee6f3ab7c329fa03f273b1f7600987048484d5fee571a1a1a1e139970753cecec87f7bd7412173530357dee74000a4a4a4a512fe0000f3aa555546fc0000aa0055554cfe00002a00555569ff000020005555, 1);
UPDATE clan_data SET crest_id=9579, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000071;
INSERT INTO crests (crest_id, data, type) VALUES (9580, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000000800005555020b003000005555bf2fc5b800005555cd0b447900005555f22800300000d5d5df0bc3d00010f52d0d2b83e000b08900aa69e3c800b0cbfeb581c3d00040bd09092f82e000a8ede068aaa2d800a83b3e818a82e800680000aadae4c000000828555581e80000020a5555a2d0000000205555a691000030385555, 1);
UPDATE clan_data SET crest_id=9580, ally_id=300000065, ally_name='DarkUnion', ally_crest_id=10072 WHERE clan_id=300000072;
INSERT INTO crests (crest_id, data, type) VALUES (9581, 0x444453207c00000007100800100000000800000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000057a5000055550080272900005555545638a50721a0c0c0408e5200005d59b5c5789400004060626ab57b00004d6373905683000080005555337300003e245555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9582, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000006a830000555500022a830000555500004b7b000055550000497b00005555000035e760004e6a4aaa97d76000c6e586aa78df4100935a92aa36e76000b1a9a1aa57d7a000aa4a6a4098cf8000aa86a5869ac78100aa925a9298d76000aaa1a9a1b9ce000080005555b7d6000020005555f9de00008a005555b8ce000008005555, 1);
UPDATE clan_data SET crest_id=9582, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000073;
INSERT INTO crests (crest_id, data, type) VALUES (9583, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000001a950000555580a8ffff00005555aa027c7c0000555500aa1c6c000055550e00ffff514acb0be555ffff0f3a030fd705ffffef31ff785f94bfd72e22fff3c661dfefe918f31d4559dff76921c41b900ddfff0d00fc0c5950bfe70c00eccddbf52c29000020005555f1310000b3a255559fff0000dcfd5555db9c0000ffbc5555, 1);
UPDATE clan_data SET crest_id=9583, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000074;
INSERT INTO crests (crest_id, data, type) VALUES (9584, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000400800005555f8fa200800005555962240000000555541a3400800005555968092ef00005501550590ff20085501510151ef20005550511172ef20005140510037df00005501555573ef600811015101aff700085151511054ef000851551441200000000a4a555520080000b280555540000000328b55554000000028d75555, 1);
UPDATE clan_data SET crest_id=9584, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000075;
INSERT INTO crests (crest_id, data, type) VALUES (9585, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000002800005555bf2f03b9000055550baa24b900005555e0ab003000005555565c45a90008b525858dc691003856253313868900281f58eccc469900005e58507225a100008d8d25b586910030331305d645810030c4cc501f87a100007252585e002000002f95555504b10000a20b5555e4b800008ae05555002800007c565555, 1);
UPDATE clan_data SET crest_id=9585, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000076;
INSERT INTO crests (crest_id, data, type) VALUES (9586, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000020000005555f0286d2900005555f52bad39000055555f62410800005555f6f00d290000d5f5b535bff70a00fd3f3fcfbfff4b297dfcfcffd27b0000555756546e31000035b595559ff70821f3f30ffdfaf70a5bf3f1c06312840000545457554108000033925555328400002d5555557184000070555555400000009b4b5555, 1);
UPDATE clan_data SET crest_id=9586, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000077;
INSERT INTO crests (crest_id, data, type) VALUES (9587, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000020000000555500c0bd9b00005555a52dfcbc000055556a0004000000555578f841000000202030a0bd8209008fb37b7b1bc506000a0125a55684010054565654021800002d2dbfffaf810410f3fbedfd109a06602f32652bf25100005c5e5f5a020000000f0d5555252000002baf5555727900002faf5555d8610000587a5555, 1);
UPDATE clan_data SET crest_id=9587, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000078;
INSERT INTO crests (crest_id, data, type) VALUES (9588, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f97b00005555f5351fbe00005555befc5fff00005555ff03bff7000055555540dfff040005555505fff72c00523b8baaffff68008ac080799fc503007c5f55559ff70200cd3d5555dcc60e000925b595dff72b0054801a14f73102007a787f6acf410000fd8d55557fef0000fd8f55555fef00003cff5555ec200000c0e05555, 1);
UPDATE clan_data SET crest_id=9588, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000079;
INSERT INTO crests (crest_id, data, type) VALUES (9589, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000230800005555af0f1fce00005555b535188c00005555545c4108000055552880da7b0000d535f5f5dfff3300352ab5adbff7340856a0587a9a7b02005f5c5757300020002d35d5d51fbe0a003fbfbf95f77b0c00f0fcfc7f300821005c5c575720000000c0f05555b57200003db55555f0480000f87c55552000000003005555, 1);
UPDATE clan_data SET crest_id=9589, ally_id=300000073, ally_name='GoldenUnion', ally_crest_id=10081 WHERE clan_id=300000080;
INSERT INTO crests (crest_id, data, type) VALUES (9590, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000006d1a00005555aa2abd96000055550f8b586dc70095d5e5c595544701a05fff5d7554c700e9e3e2aaf24b0601afaff700b42b000080005555377d00002a0a5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9591, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000003ddf00005555aa02bfef00005555aa289fe700005555fea8ce5b00005555aa883ddfd594a1a9a1a1d8ce8f6b78eaa8e815a5ee52f7e8bcf8cd6ba829e86868681ddff59cb1a1a1a1d9c6f5947c7c786c159df17bd454545caf6bc9316e6e6c68ffef00002aaa5555ffef0000e0aa5555ffef00000efe5555ffef0000fcff5555, 1);
UPDATE clan_data SET crest_id=9591, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000081;
INSERT INTO crests (crest_id, data, type) VALUES (9592, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000801800005555e525ea5a00005555af0aca5a00005555faa8e11800005555575847520000b5b52525f8de064af5fdbf2f3ae76852577f7e78ca6200085e5e5e78c95a000025b5b5159cf788522dadfb5fb7d60642f8feff7fea5a0000785e5e5eac7b000025c55555675200000abd5555664a000080fe5555c11800007c5f5555, 1);
UPDATE clan_data SET crest_id=9592, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000082;
INSERT INTO crests (crest_id, data, type) VALUES (9593, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000430800005555d527f76b00005555f161d97b000055555372610800005555fc52578c01005595554596ed0a00597b177db78b060051a37d5eb57300005555555cd9630008cbcaada515840300496145c5dd63060058529696dd730300586a5757714a000035f555554e4200003ffb5555334a00003ffd55552100000000085555, 1);
UPDATE clan_data SET crest_id=9593, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000083;
INSERT INTO crests (crest_id, data, type) VALUES (9594, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000490000005555b52fc82d00005555bf03e723000055555a7c600800005555833158080000bd2d2d2da6261a000d0d0da5f24403087bfcfcff0942000055717e7f920800003dbdf5d532010900000002ad6d43000082e272558000200075fc575540000000293d5555660800000bfd555540000000f378555501000000ffff5555, 1);
UPDATE clan_data SET crest_id=9594, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000084;
INSERT INTO crests (crest_id, data, type) VALUES (9595, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002228000055553f2fab82000055557f024230000055557a7c2000000055550000b9fd0000b5b5b535beffb3bcc3ad3e3f5eff00085f78c882c96900005555575c4b9a000025b5d5d53eff67610b0bbbd7bfff476920080bd79fff00085e585858400800000e0f5555e44000003e775555896900003ffd5555fae50000585a5555, 1);
UPDATE clan_data SET crest_id=9595, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000085;
INSERT INTO crests (crest_id, data, type) VALUES (9596, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000092fc00005555ff03d3fc00005555ae00addb000055550800d5d400005555002a76fd8078a1213121fbfe8bcafdeaa8d4f4fca9c90afa7e42b7fd2baa9293919396fd6078213131a11bffacd254a8ead555fdaad96a7efb2a58fd4ca291939392b3fc000003ff5555b3fc000080ab55556cdb0000000a5555d4cc00002a005555, 1);
UPDATE clan_data SET crest_id=9596, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000086;
INSERT INTO crests (crest_id, data, type) VALUES (9597, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000eaf300005555aa0271fc00005555fa0098ec00005555ff007ccc00005555fff0b5fd8068975d434351fd0068004147f585c90168d5ffebacd5fd0038f74742481ffe094091518d8b73fd20a85fd5540093fda0781f77519011fe209940406ae936cd000003ff5555d7dd000022df55558fec00008caf55552bfc00008caa5555, 1);
UPDATE clan_data SET crest_id=9597, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000087;
INSERT INTO crests (crest_id, data, type) VALUES (9598, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000100000055555555410800005555fd78200000005555489d0100000055555dffffff000000414141ffff000001050505ffff000040410505ffff000055414155ffff000041414100ffff000005050501ffff000005054140ffff00005541415520000000185755552000000064a5555520000000835755550100000055555555, 1);
UPDATE clan_data SET crest_id=9598, ally_id=300000081, ally_name='StormUnion', ally_crest_id=10090 WHERE clan_id=300000088;
INSERT INTO crests (crest_id, data, type) VALUES (9599, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000e9ee0000555580c0ffff0000555582cfe7fe0a634ae2ca8affff8b834542607a6affc75a4a4afbffffff0000e9a28fc2149d00002a7f55557cf70000cad55555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9600, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000c631000055555715fdff000055552d027bff0000555578a881300000555577fcfeff000095252505fcff237380a098a875ffe028a0a8b6ad8cdb0008565c585019ff00000525359554ff216b40e0dcfed1fd4039a1b0fefe07ea000058585e5ee240000037d55555cdd30000022f555529f20000a0e85555004000005c5e5555, 1);
UPDATE clan_data SET crest_id=9600, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000089;
INSERT INTO crests (crest_id, data, type) VALUES (9601, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000009452000055552df9dfff00005555021dbfff000055558054b55200005555786fdfff00005dd97101bfff0a0015151500dfff090054545400dfff000075674d40ffff0000015159dddfff0a0800151515dfff0a0800545454ffff00004045677594520000f92d5555ffff000015025555dfff000054805555544a00006f785555, 1);
UPDATE clan_data SET crest_id=9601, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000090;
INSERT INTO crests (crest_id, data, type) VALUES (9602, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000200800005555ff082bff0000555505a106fe0000555550502018000055557f0210ff2010413cf18536f70010a1858483cfe50008505010808fed0020413c4e522fff0008c585b131f1f64028aa2a2821efe50038a0a020408ffd000852524e4c4ff700003141555533ff0000851555558fd5000050545555ece5000048415555, 1);
UPDATE clan_data SET crest_id=9602, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000091;
INSERT INTO crests (crest_id, data, type) VALUES (9603, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff0000555500c09fef00005555a0ae5de700005555be2bffff000055550000ffffc510bcaa7850dff7c510cae0dc44ffffc4100f030201ffffbacea02a2184ffff8829004080f8dff703004372705ddff72000035855fdffffa208fffd0500b2730000282b5555d26b00000ae05555fff700000f005555ffff000000005555, 1);
UPDATE clan_data SET crest_id=9603, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000092;
INSERT INTO crests (crest_id, data, type) VALUES (9604, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000000d800005555e0a0002000005555d6f8002800005555953d20d8000055550b0a83a8003080afb5bdecac0020df9f1f1f2cad0018f7f6f4f4a3a0002002ea5e7a6368001882e0787aa47a0018152d09ad859a00185678607a6160001880030da582400000a22b555521390000af8a555543410000ffa255554138000000a05555, 1);
UPDATE clan_data SET crest_id=9604, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000093;
INSERT INTO crests (crest_id, data, type) VALUES (9605, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000a3a90000555508a8a2a100005555aa0a449100005555bfb0e3a10000555500aac5810040eebe1e37a4d1005802002db542e1004872e0dedee78900182b2d35f7e5a900203f5edaf8a9b20018888b0df507ca001088f25857c5b10018f5b5af2be49900002a20555543a900002f2e5555a4a10000fcb85555a3990000aa005555, 1);
UPDATE clan_data SET crest_id=9605, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000094;
INSERT INTO crests (crest_id, data, type) VALUES (9606, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002621000055557f3f5fff00005555958bbfff0000555554830449000055557f58d2830000d515d5557fffa828fd9d03009fff2238575814404e8b01005f5f5b53fdee00001595d5d5bfffac4900151515ffff4260c0004057dcfe0000585353532108000000bc5555bfff0000bf8055553cff0000ff0f55555dff00005b545555, 1);
UPDATE clan_data SET crest_id=9606, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000095;
INSERT INTO crests (crest_id, data, type) VALUES (9607, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000fff7000055552509fff7000055555840fff7000055550903fff7000055555040ffff2f53090a0a0afffff37340800000fffffbb5090f2b03ffff8721c0a81c28fffff1630a0a0a09ffff4621381c20c0ffff2f53020a0a09ffff681900808040fff7000009255555fff7000040705555fff7000009b55555ffff0000705e5555, 1);
UPDATE clan_data SET crest_id=9607, ally_id=300000089, ally_name='SilentUnion', ally_crest_id=10099 WHERE clan_id=300000096;
INSERT INTO crests (crest_id, data, type) VALUES (9608, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000007fe70000555500001fff0000555528b8ffff751bfbef9f257effa420facbc1e1dff7ad01159dd5d53ce70348627b7a6a748c00003f3f55559ad60000ce865555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9609, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000400800005555a323c241000055557517c02000005555f76c2000000055553505ebf60000d5bd09b5cdfe80491fa08022ccfe205a67a000a22cde0000557e5057601000003fe7f5d9d4ee20080dcbe1e9d8f60000585a7353610801003ab5ffff40000000c5dd555575ff000089ad5555d3f60000505655550100000000005555, 1);
UPDATE clan_data SET crest_id=9609, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000097;
INSERT INTO crests (crest_id, data, type) VALUES (9610, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000400800005555f8fa200800005555962240000000555541a3400800005555968092ef00005501550590ff20085501510151ef20005550511172ef20005140510037df00005501555573ef600811015101aff700085151511054ef000851551441200000000a4a555520080000b280555540000000328b55554000000028d75555, 1);
UPDATE clan_data SET crest_id=9610, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000098;
INSERT INTO crests (crest_id, data, type) VALUES (9611, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004208000055558ae0dffe00005555552b1efe0000555555e861000000555582b85fff0100d53525051ffff5b0025c57553ffd4d5880affe5ffcd20000575c58507ffe0000052535d5bffd1188ffff5caa3ccb2b485fdfb5a01cd2000050585e5720080000386e555516bb00000bf5555537bb0000f85f5555031000007c775555, 1);
UPDATE clan_data SET crest_id=9611, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000099;
INSERT INTO crests (crest_id, data, type) VALUES (9612, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000610800005555b2b2410800005555eb478b7300005555c5d5610800005555f6cf6bac200055fd0b9789cc000075cfc8a88a83000877585ebc2c6b000075496d5faeac0000d5b535fd4ded0008f8f8ff55edd400102f0fff55f1cd00007e424b79200800008ed75555ac7b0000d5c555556eac0000007f55550a7b00005c555555, 1);
UPDATE clan_data SET crest_id=9612, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000100;
INSERT INTO crests (crest_id, data, type) VALUES (9613, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550150dfff00005555005591df000055553b16ffff000055554005dfff000054545454b282000835f57fb374f7c00017191b1affff000015151515deff01005454d4d4f38a08009f74a15534ef600019191855ffff000815151615dfff0000d00155557fff0000fb00555553ef0000181b5555feff000006405555, 1);
UPDATE clan_data SET crest_id=9613, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000101;
INSERT INTO crests (crest_id, data, type) VALUES (9614, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000000bbe000055555525edff000055550002f2ff000055558080eab50000555555582c8c20004152565656ff01001152515597ff2000448545554c8c000041a59595ecbd0000575b692597f7000055524145d6f7000055854151cbbd0000d5a56958efc5000015555555b5ef000002555555d7f7000080555555cebd000054555555, 1);
UPDATE clan_data SET crest_id=9614, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000102;
INSERT INTO crests (crest_id, data, type) VALUES (9615, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000021000000555580c0d5ef00005555d5356a9500005555d7dca9b1000055555a78f6e700005555d535faf700108da3280af6ffe0585c62c820499500005e5e5e58fbff00108db32a05f7ff406202548484f8ff205a88151a1ef6ff600078e2a850f7ff000005055555d1ff00000c8c55554cff00003a3e5555f3ff000050505555, 1);
UPDATE clan_data SET crest_id=9615, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000103;
INSERT INTO crests (crest_id, data, type) VALUES (9616, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004000000055550d2d6b4a00005555950d757b0000555583aa2829000055557f5ca139000055555515b4d600005555ed706d8a0300a9ada022ecda0008575e6240b8ff0000b5252901b5ff01005cd65756aeb30008fbde95358ec301084260705e2ef70000092555550ef70000a86855558d8b00008b5d555500100000282f5555, 1);
UPDATE clan_data SET crest_id=9616, ally_id=300000097, ally_name='FreeLeague', ally_crest_id=10108 WHERE clan_id=300000104;
INSERT INTO crests (crest_id, data, type) VALUES (9617, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004c7b00005555ff3fec7200005555fffc95c58030153797175dffe71874efbfbfeee4a030169cfcf8f7cdc038afaa22552fed0000e8e0555527290000030a5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9618, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002100000055558812a51800005555fd3f5ace000055557f3ffff700005555555c2200000077db9b0f9ff70100d5f5e927ffff0c4a2d0fc2e09bce0300e8aa7e5f6c52010095250985ffffca39350d83e2dfff200080be7e5f1063000054575555c839000015555555bff70000f8f55555a93900005856555501000000aaaa5555, 1);
UPDATE clan_data SET crest_id=9618, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000105;
INSERT INTO crests (crest_id, data, type) VALUES (9619, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000000800005555e0a01f9600005555b585df9e000055555652010000005555a0809fd60400555581fcffbf0000a5a5aa2a7f872200525a2aaaffc701005555402adfde0000bc815555ffbf01002aaaa5a51f7f0100080a5252ffb701000240555501000000aaa055553fa6000085955555bfa600005256555501000000a0ea5555, 1);
UPDATE clan_data SET crest_id=9619, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000106;
INSERT INTO crests (crest_id, data, type) VALUES (9620, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000266100005555bf0f2579000055553f0be57000005555aa2ae56000005555def06679001827ef898f0481004825143eaba891006035bbf353457120087a7af0f0257900182f090f0da26800508bf9eb2da6910058fb7a58ffa3680030586082fe046100000f0e5555a26800000a825555e36800000aaa555504710000ea805555, 1);
UPDATE clan_data SET crest_id=9620, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000107;
INSERT INTO crests (crest_id, data, type) VALUES (9621, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000e969000055550fbf91ba000055558a3f4fb2000055555f70235900005555220a2a9a0138545303ab74c3295057fadf94539b016068a20a2a4fa30060fdeea0c82dc3ce2000c0f05817738f303d2dafba7a736900a8aa2ab5976405587aeaaaa09a9b0000a8ea5555362a0000f85e5555cc0900003fb755557a4d000002035555, 1);
UPDATE clan_data SET crest_id=9621, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000108;
INSERT INTO crests (crest_id, data, type) VALUES (9622, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000ffff00005555b008bfff00005555800affff000055550200deff276900240708ffff0150c3404040dfff00580d2d3d34dfffc440a060c000ffff234100008036ffffd7ed60a043ebffff25590d00aaaaffff709ca078de75bdff00002ca05555ddff000080285555fef70000a20055555cdf0000c2805555, 1);
UPDATE clan_data SET crest_id=9622, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000109;
INSERT INTO crests (crest_id, data, type) VALUES (9623, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000009e00000055550000941000005555c0c0a83e000055550101e1070000555500009f561e00555555805a2307006a7a7a584a568000010101d5b236691cffffab2d3f471004e05555551104030058606040b6b10318d50101011d7d714a2daaaaaa0f04000000005555110400004040555578c10000030355551ff8000000005555, 1);
UPDATE clan_data SET crest_id=9623, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000110;
INSERT INTO crests (crest_id, data, type) VALUES (9624, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004b890000555505bd72aa00005555803f8ab2000055555f70e76100005555f3cf05c900185e7b0b0314c315285e5adebc6ad3005068822a2a0ba328000b08a060ced22c400ac0d07810a3b8310f25d55556536800000000155274004078e0a080f6bb0000a8ea5555954a0000fc575555660000002e9755551f1600000a035555, 1);
UPDATE clan_data SET crest_id=9624, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000111;
INSERT INTO crests (crest_id, data, type) VALUES (9625, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000017bf0000555500dcb9ce0000555500ddbad60000555554542000000055550000d8ce000050d1d151facec01015151515d8ce000054555501b9d6000055555540dad6200051d35150d8d6a11815151515f9ce021950140000d9ce400045041044b8de000054005555d8d6000075005555d8d600005d005555d9d6000041505555, 1);
UPDATE clan_data SET crest_id=9625, ally_id=300000105, ally_name='IronLeague', ally_crest_id=10117 WHERE clan_id=300000112;
INSERT INTO crests (crest_id, data, type) VALUES (9626, 0x444453207c0000000710080010000000080000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002000000055553555ff1f000055552535cb1a000055d52626ff27000025adbbfbb64d451915affee7f85d0000fa7a707c2c2300006941555541000000e0ff5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9627, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002110000055552800e9710000555555544008000055550b0056ef000055555144daff000015d5057dffbf00085357aaa8ffaf00001545aa2affdf00001555545541100000621eddddffef0000a1851515fff700004a5254546100000094bdff7a2000000002025555dfff000005015555fff7000050405555083a000055515555, 1);
UPDATE clan_data SET crest_id=9627, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000113;
INSERT INTO crests (crest_id, data, type) VALUES (9628, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000060100000555545ca457900005555f50d8689000055555ff8201000005555ba2836ff001849261415fcfe0028ed49555521a900306bad092914ff20086198145496ff001005515a162ffe0008fcf1254591ee00083f4f5851d6ff00085065a59440100000808e555592de000052555555b3e60000855555552010000002ea5555, 1);
UPDATE clan_data SET crest_id=9628, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000114;
INSERT INTO crests (crest_id, data, type) VALUES (9629, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000410800005555cbca410800005555f88f410800005555abcf410800005555f65479ce000055dfab2bffff0000555de200ffff000055f58900ffff000055e5eac099ce0000e9ad3595ffffc21025af80abffffc31870f000e0dfff0000e362585c400000000dfd5555ef83000035555555108400005c55555520000000c2705555, 1);
UPDATE clan_data SET crest_id=9629, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000115;
INSERT INTO crests (crest_id, data, type) VALUES (9630, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000039c6000055555c5698b50000555561799dd600005555e05eb9b500005555a179cc5a0100565c5c5ecd5a020051535151ce5a0200d65e58f8b17b0200595951b12d630100565c5cfc0e630100515151790a420100dc765c5e0e630200515951f1eb390000f8df555547290000f37d5555c5180000f87f555509420000f1d95555, 1);
UPDATE clan_data SET crest_id=9630, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000116;
INSERT INTO crests (crest_id, data, type) VALUES (9631, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000b8c70000555585c5f29f0000555580ff107e0000555580ff2f84000055555053749f000085c5c5e58325e0037c7ede5ee118e018881010804e8c0000535b5959949d0000c58545456d640200baa055ff894b00007ea0555fec5a000051525951f07b0000451555551084000055a05555aa52000055005555c831000051545555, 1);
UPDATE clan_data SET crest_id=9631, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000117;
INSERT INTO crests (crest_id, data, type) VALUES (9632, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000077de0000555500a0b6e600005555000077e600005555000099de00005555880077e6cc83d28a2822f8f66762aaaa2a37b7e6b3c59ffffc4a56de8549bcd4dc2077ded4d57d77df0576de8449b537080036de52c5ac8aaa6296e66a930000a27257de00000000555556d600000020555536ee00008000555514de0000aa005555, 1);
UPDATE clan_data SET crest_id=9632, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000118;
INSERT INTO crests (crest_id, data, type) VALUES (9633, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000086200005555387ee5c8000055550b2f6be300005555827e5df700005555881565390008dfdef6944af100300d2f2b83b8cc00307bab17f7dfff010015959596bdef0000d65454543c9c0600f7f6c289b9590800e2a87a7a662002003c2fb73f5fef000056a85555156300003d00555538310000f8a05555ae200000af0a5555, 1);
UPDATE clan_data SET crest_id=9633, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000119;
INSERT INTO crests (crest_id, data, type) VALUES (9634, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000041ff0000555500002aff00005555c030dfff00005555030effff000055550000eafea0a1202240908cfe00289c1795005cff002836d656aafeffa49900000107ffff40a1904000005eff0028aa95979cccfe00382254d4342aff40a906812020ffff000000005555deff0000b0c05555e8fe00000c03555521ff000000005555, 1);
UPDATE clan_data SET crest_id=9634, ally_id=300000113, ally_name='SilverLeague', ally_crest_id=10126 WHERE clan_id=300000120;
INSERT INTO crests (crest_id, data, type) VALUES (9635, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f9ee0000555555cbc3800000555555bc16d68230c3e95d5d04a90000f8525cc800c16120df9a9a3a20d920205c7e5cac20c900002a55555520d100005c555555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9636, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002108000055558d005db50000555555207fc600005555558e51630000555555549fff001017171db5bfef901083810516dff7331886c62696bff70100d475f55a909b0000155555555fffcb18aaaaa2217fff4c28aaea44c63dfe00005e54545701000000203055559ece0000045555557fd6000088555555bfd6000052555555, 1);
UPDATE clan_data SET crest_id=9636, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000121;
INSERT INTO crests (crest_id, data, type) VALUES (9637, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004100000055550000610000005555a3082d22000055557dc3200000005555050d210800000000c0227a8d4200a509e3a2ff5e2a00ea0a2d2f973c00005756545c53630000d5951595fc4a27008aca2eb5f92b09002ab50b8a733300005c565657ea39000015555555ea390000f05555554a190000f05f5555220000002a055555, 1);
UPDATE clan_data SET crest_id=9637, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000122;
INSERT INTO crests (crest_id, data, type) VALUES (9638, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000ffff000055550050ffff000055550005ffff000055550000dff7dfad00aaff55ffff0700d0d0dadaffff0600070707a7ffffbf9d00aabf551f85bd2a00aaaa57fff721009a9a5a1efff72200a6a6a5f47f859d2200aaffd51f3b000000005555fc3a0000f0a055551d4300000f0a5555ff32000000005555, 1);
UPDATE clan_data SET crest_id=9638, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000123;
INSERT INTO crests (crest_id, data, type) VALUES (9639, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000086200005555387ee5c8000055550b2f6be300005555827e5df700005555881565390008dfdef6944af100300d2f2b83b8cc00307bab17f7dfff010015959596bdef0000d65454543c9c0600f7f6c289b9590800e2a87a7a662002003c2fb73f5fef000056a85555156300003d00555538310000f8a05555ae200000af0a5555, 1);
UPDATE clan_data SET crest_id=9639, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000124;
INSERT INTO crests (crest_id, data, type) VALUES (9640, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000b8c70000555585c5f29f0000555580ff107e0000555580ff2f84000055555053749f000085c5c5e58325e0037c7ede5ee118e018881010804e8c0000535b5959949d0000c58545456d640200baa055ff894b00007ea0555fec5a000051525951f07b0000451555551084000055a05555aa52000055005555c831000051545555, 1);
UPDATE clan_data SET crest_id=9640, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000125;
INSERT INTO crests (crest_id, data, type) VALUES (9641, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f84a0000555580805132000055550000dabd00005555ff30d4390000555580a29884ea293e7a7a7ebfe7f229565652415732d4186b587959f6bc1000575b6d47183a6b19e0ea6afe9aef021903f1f2c7b3ff0810d35062033bbd051041556d62b9e70000ff00555574df0000ff005555b3d5000057005555d4dd000055005555, 1);
UPDATE clan_data SET crest_id=9641, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000126;
INSERT INTO crests (crest_id, data, type) VALUES (9642, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ed3900005555fd3f33840000555555023484000055555580ed3900005555fffc8608030007a50d05dff7060015952b15dff705005454e85467080200f078705047080100bfbf250df88302000fcb7e57d883020070e3b5f56808010076575a70490800000bab555524000000f0785555270000002db5555549080000e0ea5555, 1);
UPDATE clan_data SET crest_id=9642, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000127;
INSERT INTO crests (crest_id, data, type) VALUES (9643, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004b520000555555357fc6000055553537ffb5000055555cfc306b000055555554949400001595d5d5ffde020096d4342cffe66910280c2a1859ad000054565757f79c0000d5d595159ff7ed39ae3aa86cdfe6020034371797f17b000057575754f27b00001555555552630000e2d555556b520000165655552e63000054555555, 1);
UPDATE clan_data SET crest_id=9643, ally_id=300000121, ally_name='NorthernLeague', ally_crest_id=10135 WHERE clan_id=300000128;
INSERT INTO crests (crest_id, data, type) VALUES (9644, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000aedc00005555f2f2aac300005555e2c0b4f5805096b2b29c6adb4070e4a6aeaa6cdc6330b8ba9cdaa8a24250a8aaf6e470e40000e2625555cde40000e0e25555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9645, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f9b40000555500aa39a50000555500ea17a500005555000b369d00005555003c3fd76e325aebdf02fbb5aa212a5eaaaa3fdfca2937b5ebaaded6c7180a8e578a76ad8a19ff00ea5a3caecb29ffaa55531dbe0919ffaa5b5856a52911ff00a7f6f9940000af825555d784000000fa55551dbe0000f8bf55555eae0000bf025555, 1);
UPDATE clan_data SET crest_id=9645, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000129;
INSERT INTO crests (crest_id, data, type) VALUES (9646, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000f464000055552f2fffe7000055558a8a514c00005555a000f35c00005555aaa0d35ca1002dab2aaaffef4d4b8f0b026fffef4d3bfd890381ffef935cbaf7605fffe731447d03f7aafff7ce534ac0c2efffefec3a79a2c0e21465281afefc7a7cb78500000bff55551475000088aa5555ffef0000a2a25555d78500007cfe5555, 1);
UPDATE clan_data SET crest_id=9646, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000130;
INSERT INTO crests (crest_id, data, type) VALUES (9647, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000004208000055552a38faa4000055555529fb83000055557508dfde000055555554d99c000095a5298adf9c1600c25c5655f6301708ddd5273efff700005353540a3dad0000e228886a3fce0200f8f22ae596390f00abaaf895dff70200a22bf354108c00005015555598b50000e2b85555bfef00009bb05555ffff000054555555, 1);
UPDATE clan_data SET crest_id=9647, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000131;
INSERT INTO crests (crest_id, data, type) VALUES (9648, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000631800005555e1831bd60000555555409de60000555555e0e42000005555cfdf1fff011017050189befeee810000a8dadffef2a2a88a22853cee0100555e50501fff0208800005075efeca60560cda289ffe8d810929b1883bde0100504054509ef600001d775555bffe000000d055551efe000000545555c41800005cca5555, 1);
UPDATE clan_data SET crest_id=9648, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000132;
INSERT INTO crests (crest_id, data, type) VALUES (9649, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000410800005555584affff0000555551012100000055550c2a200800005555858affff000025550909ffff0100c0021500ffff000075014006ffff020041406160ffff000005055501ffff00002b000120ffff0000450d4c51ffff020061404141ffff000051555555ffff000051515555ffff000050555555ffff000041555555, 1);
UPDATE clan_data SET crest_id=9649, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000133;
INSERT INTO crests (crest_id, data, type) VALUES (9650, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000bace0000555585011bcf000055551500dad6000055555600b9ce000055555450dfd600004545c5457ff70d0055555451ffff470800001004b9ce0000545454547ece000045454505fff70d0041051515ffff4a0805010080bace200054545454420000002bcc5555bfef000010055555fff70000a85e555540000000e87a5555, 1);
UPDATE clan_data SET crest_id=9650, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000134;
INSERT INTO crests (crest_id, data, type) VALUES (9651, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000200000005555000001180000555589d5fdd6000055555515ffff000055550100480801005555bf0fd7430100d509032f3fd7420015000000ffff9dce36bd1fb78d0800000d0d0d2d9b330f00353535059fb70200000042575fdf010040405555480800002dbf555529000000e07a555563000000f8ff55550100000000005555, 1);
UPDATE clan_data SET crest_id=9651, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000135;
INSERT INTO crests (crest_id, data, type) VALUES (9652, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000cafd00005555ff03effd00005555f50f2ffd000055557ffca27100005555ffc06afe00280c0f0dcd29fe0030735cdcdccffd2078055154142efe4070404450406cfe0010c3f0775510fe20285c73f105cdfd0058373d0f54cefd4008cfcf705f4010000032fb5555c15800000dfd5555c2500000fc57555500100000705c5555, 1);
UPDATE clan_data SET crest_id=9652, ally_id=300000129, ally_name='OldLeague', ally_crest_id=10144 WHERE clan_id=300000136;
INSERT INTO crests (crest_id, data, type) VALUES (9653, 0x444453207c00000007100800100000000800000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000075c500005555cdcfe0a0000055552a2a5de700004b63e1e112e52030cdcd49ffffff4000e1abbff599de6160577ee84031a40000b515555597d50000727f5555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9654, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000bfff0000555500fcdfff0000555500dfffff00005555005fdfff00005555003dffffe418fc681810ffffc61801040400ffffc11805414101ffff811034101004ffffe7204018187cffffa018050444d0ffff0119414141d1bfff2421012a2a3f9fff0000fc005555dfff0000f50055557fff0000ff0055559fff00003f005555, 1);
UPDATE clan_data SET crest_id=9654, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000137;
INSERT INTO crests (crest_id, data, type) VALUES (9655, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055555501fff7000055555550ffff000055555505ffff000055555540ffff000001c1c1c1dfff0000505f5e5edff7000005f5b5b5ffff000040434343ffff010055017191ffff010055007799ffff000055007799ffff010055404749ffff000001555555ffff000000555555ffff000000555555ffff000040555555, 1);
UPDATE clan_data SET crest_id=9655, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000138;
INSERT INTO crests (crest_id, data, type) VALUES (9656, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000002400000055555525a41000005555af0204210000555555f42110000055552f2a92180200b5250d0d116101005efe2f2fbdff000801ffffffdcff000054534f3fd12001000d0d25b5106104002737de5ebaff0010ffffff01fdff01003f4f5354210000000838555564100000008b5555e3200000f4d7555542000000a5a05555, 1);
UPDATE clan_data SET crest_id=9656, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000139;
INSERT INTO crests (crest_id, data, type) VALUES (9657, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000050cc0000555500024ecc0000555500000ec40000555580006cb300005555aa20d7fe2449aaaa25feb7fe2872aeeadcffadbb899278e85b2a8fabc658fffe5f00cfb300100008855530c400080000d75571c4000800aad75571c4002080eaaa81201000000008555521100000882255550010000028005555afb3000009d55555, 1);
UPDATE clan_data SET crest_id=9657, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000140;
INSERT INTO crests (crest_id, data, type) VALUES (9658, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000ffff000055550000deff000055550028dfff000055550002dfff000055550000dfffa4b800c060609aff80682f8b835fbeff81600309ab55deff0b7b00000209ffff83a07060c080bdffa8a1bf83818dffffe2602d090983ffffcea300000001fdff000000005555bdff0000aa005555feff000082205555ffff000003005555, 1);
UPDATE clan_data SET crest_id=9658, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000141;
INSERT INTO crests (crest_id, data, type) VALUES (9659, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000dbd60000555500a8fff70000555500aafff70000555500aa39d700005555000cb9d6ca52a8fc54049defea5ababfa5e0fff76a6a8b30af8dfeffa3218b80908cd9cee1102cbcfcd4fff72000a0565ea8fef700080a018282feffa3298aa2ac90d8ce000054005555fff70000a2005555fef70000bf005555feff000080aa5555, 1);
UPDATE clan_data SET crest_id=9659, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000142;
INSERT INTO crests (crest_id, data, type) VALUES (9660, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000feff000055550000fdff000055550000feff000055550000ffff0000555500005bff4cf400aabf95faffee41a0c0c355daff104a0a03c3553bffaef300aafc56b3fea0d03f3f55559fde05207d416a5f9fde05207d41a9f5b4fea0d0bcfc555520a80000000a55558dd400000fff55558dd40000f0ff555500a8000000a05555, 1);
UPDATE clan_data SET crest_id=9660, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000143;
INSERT INTO crests (crest_id, data, type) VALUES (9661, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000230000005555f73fdff7000055550db39ff70000555562fe2000000055550201ed41000035155555dff7ad310404f305bff70200bcce725f030000005cfc5e5e6d52000055551515ffffcc3901030200bfd604005c5ed7bf516b0000555556542c4a000015d55555ffff000000e35555bff70000e07f5555450800007c7e5555, 1);
UPDATE clan_data SET crest_id=9661, ally_id=300000137, ally_name='WildLeague', ally_crest_id=10153 WHERE clan_id=300000144;
INSERT INTO crests (crest_id, data, type) VALUES (9662, 0x444453207c000000071008001000000008000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000feff00005555f0e0477200005555509efeff00004070585e50940000d595f5cd7bf700005757fef870940000cbe3b5a5feff0000f8a85555a1a0000025275555, 3);
INSERT INTO crests (crest_id, data, type) VALUES (9663, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000200000005555e0e0fb9500005555f32fcd3a000055555594b06b0000555555820319000055555545157500000d09090dbfa64300de628b2f5cb604006f7e6e4e107c00002d155555b874c82929fffffd53744200adcbab5e197545004a6b6f6d01000000200055556821000000ff55552629000078555555620000000feb5555, 1);
UPDATE clan_data SET crest_id=9663, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000145;
INSERT INTO crests (crest_id, data, type) VALUES (9664, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000c66100005555f535277200005555ffcc221000005555e2ca620800005555bf86266a0000f515d5d5b3f6001851c07f7f8591000055f5c50d6208000086afb2ae400000006baf2f3feca46000151500410e952000515114510f95010055505455200000000080555560000000025555550e950000155555550c95000055505555, 1);
UPDATE clan_data SET crest_id=9664, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000146;
INSERT INTO crests (crest_id, data, type) VALUES (9665, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000007fff0000555514f17fff0000555545c47fff0000555551137fff00005555144f9fff0528c51500d5ddfec478414540d5ddfea378415101575fff0d08535400577fff0c08d50015c5befea370f5404541befe8270570151415fff0c08570054539fff0000f11455555fff0000444555555fff0000115155559fff00004f145555, 1);
UPDATE clan_data SET crest_id=9665, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000147;
INSERT INTO crests (crest_id, data, type) VALUES (9666, 0x444453207c000000071008001000000010000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000040000004458543100000000000000000000000000000000000000000010000000000000000000000000000000000000b8b5000055550a0abbee00005555000219ee00005555003eb8d50000555580a09ce65194e5adad2f9fff27514278c040bcfe854875571501bcf6a2488aa389a8bde6b07bfbcfef89bfff6a00004f54567fff28080d351d85fdfef28bf0e8ceda7ece00002aaa55557fe60000e00255553fe7000000805555fede000088aa5555, 1);
UPDATE clan_data SET crest_id=9666, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000148;
INSERT INTO crests (crest_id, data, type) VALUES (9667, 0x444453207c00000007100800100000001000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000004000000445854310000000000000000000000000000000000000000001000000000000000000000000000000000000011cc00005555ff0b92dc00005555ff008fcc00005555fd004ec400005555ffc05aff2018a9e95d2579ff40407a250002b8ff804055208020b8ff60286a755860dcff201005012139dbff33d648bae4f1b6ff70eec83baa9ab5ff802048426e48d8ee00000155555518f7000000775555d3f6000000fd5555b2ee000040755555, 1);
UPDATE clan_data SET crest_id=9667, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000149;
INSERT INTO crests (crest_id, data, type) VALUES (9668, 0x444453207c0000000710080010000000100000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000400000044585431000000000000000000000000000000000000000000100000000000000000000000000000000000006e1800005555b52db41800005555aa804910000055555eaca91800005555552e59180200b5ad2f0fd81813006a0ab7a5d5200700250f0a0a93201500a07b7def791006002d2d2aba14290b008a8020702f3103000b0ac078ce20030800000b094808000020bf555552080000e0f2555549080000e0fa5555881800008bff5555, 1);
UPDATE clan_data SET crest_id=9668, ally_id=300000145, ally_name='RoyalLeague', ally_crest_id=10162 WHERE clan_id=300000150;
@@ -5,7 +5,7 @@
# to function seamlessly like NPCs with minimal impact on server performance. # to function seamlessly like NPCs with minimal impact on server performance.
# Enable fake players. # Enable fake players.
EnableFakePlayers = False EnableFakePlayers = True
# Enable chatting with fake players. # Enable chatting with fake players.
FakePlayerChat = True FakePlayerChat = True
@@ -26,7 +26,7 @@ FakePlayerAutoAttackable = False
FakePlayerAggroMonsters = True FakePlayerAggroMonsters = True
# Aggressive AI fake players attack nearby players. # Aggressive AI fake players attack nearby players.
FakePlayerAggroPlayers = False FakePlayerAggroPlayers = True
# Aggressive AI fake players attack nearby fake players. # Aggressive AI fake players attack nearby fake players.
FakePlayerAggroFPC = False FakePlayerAggroFPC = False
@@ -36,3 +36,68 @@ FakePlayerCanDropItems = True
# Fake players can pickup dropped items. # Fake players can pickup dropped items.
FakePlayerCanPickup = True FakePlayerCanPickup = True
# ---------------------------------------------------------------------------
# SPP additions: LLM chat and AI brain
# ---------------------------------------------------------------------------
# Generate fake player chat lines with a local LLM (Ollama).
# When Ollama is not running, bots silently fall back to FakePlayerChatData.xml templates.
FakePlayerLlmChat = True
# Ollama generate endpoint.
FakePlayerLlmUrl = http://127.0.0.1:11434/api/generate
# Ollama model name (run: ollama pull gemma3).
FakePlayerLlmModel = gemma3
# Language the bots chat in.
FakePlayerLlmLanguage = русский
# LLM request timeout, milliseconds.
FakePlayerLlmTimeout = 15000
# Chance (%) per AI tick that an idle bot says something when a player is nearby.
FakePlayerAmbientChatChance = 25
# Chance (%) that a bot taunts when engaging a player or comments while assisting.
FakePlayerCombatChatChance = 60
# Chance (%) that a bot answers a general chat message said near it.
FakePlayerLocalChatReplyChance = 40
# Fake player AI tick in seconds (helper assist, ambient chat).
FakePlayerAiTick = 5
# Real progression: bots gain exp from monsters they kill, level up,
# and migrate to higher level zones with better gear (see BOTS.md).
FakePlayerProgression = False
# Multiplier for exp bots receive from kills.
FakePlayerXpRate = 3
# Trading simulation: bots periodically go to town, sit down with a private
# store (sell after successful farming, buy otherwise), shout in trade chat,
# close the shop after TradeDuration minutes and return to farming.
FakePlayerTrading = True
# Shop lifetime, minutes.
FakePlayerTradeDuration = 60
# Pause between shops, minutes (plus random up to 30 min).
FakePlayerTradeCooldown = 120
# Trade chat shout interval, seconds.
FakePlayerTradeShoutInterval = 180
# Chance (%) to actually open a shop when the time comes.
FakePlayerTradeChance = 30
# NOTE: with FakePlayerProgression = False the NPC bot population is off and the
# world is populated by headless players only (see FakePlayerHeadlessCount).
#
# Headless players: real Player instances without a client. They can join
# real parties (invite them - they auto accept), share exp, fight beside you.
# Prototype: they farm near Giran. 0 = disabled.
FakePlayerHeadlessCount = 500
@@ -269,7 +269,7 @@ MaxMonsterAnimation = 60
# Turn off for a grid and neighbors occurs after the specified number of seconds have passed during which a grid has had no players in or in any of its neighbors. # Turn off for a grid and neighbors occurs after the specified number of seconds have passed during which a grid has had no players in or in any of its neighbors.
# The always on option allows to ignore all this and let all grids be active at all times (not suggested). # The always on option allows to ignore all this and let all grids be active at all times (not suggested).
# Default: False # Default: False
GridsAlwaysOn = False GridsAlwaysOn = True
# Default: 1 # Default: 1
GridNeighborTurnOnTime = 1 GridNeighborTurnOnTime = 1
@@ -21,4 +21,11 @@
<fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="is th;server;good" answers="it's good :D;i like it :P;yes it is :)" /> <fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="is th;server;good" answers="it's good :D;i like it :P;yes it is :)" />
<fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="where;you;go;?" answers="i look for something;checking stuff;looking for curius people :P" /> <fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="where;you;go;?" answers="i look for something;checking stuff;looking for curius people :P" />
<fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="are;you;kidding" answers="^^;:D;:P" /> <fakePlayerChat fpcName="Evi" searchMethod="CONTAINS" searchText="are;you;kidding" answers="^^;:D;:P" />
<fakePlayerChat fpcName="ALL" searchMethod="EQUALS" searchText="привет" answers="привет;хай;ку;прив" />
<fakePlayerChat fpcName="ALL" searchMethod="EQUALS" searchText="ку" answers="ку;прив;хай" />
<fakePlayerChat fpcName="ALL" searchMethod="EQUALS" searchText="хай" answers="прив;ку" />
<fakePlayerChat fpcName="ALL" searchMethod="STARTS_WITH" searchText="как дела" answers="норм;фармлю;пойдет" />
<fakePlayerChat fpcName="ALL" searchMethod="STARTS_WITH" searchText="ты бот" answers="сам ты бот;лол нет;чего?" />
<fakePlayerChat fpcName="ALL" searchMethod="CONTAINS" searchText="пати" answers="я соло;не сейчас;го" />
<fakePlayerChat fpcName="ALL" searchMethod="CONTAINS" searchText="дроп" answers="дроп так себе;пока пусто;норм капает" />
</list> </list>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Боевые наборы ботов. Правится без пересборки.
role - fighter | archer | mage | healer | support (влияет на дистанцию и приоритеты)
offensive - атакующий скилл. priority: меньше = важнее.
maxTargetHp - кастовать, только если у цели HP ниже этого % (добивание)
minMp - не кастовать, если своего MP меньше этого %
self - самобаф/усиление, вешается вне боя
heal - лечение: minHp = кастовать, если HP цели/своё ниже этого %
Скиллы, которых у бота нет по уровню, молча пропускаются.
-->
<combat>
<!-- ===== Бойцы ближнего боя ===== -->
<class id="1" role="fighter" name="Warrior">
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" /> <!-- Mortal Blow -->
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Strike -->
<self id="139" /> <!-- War Cry -->
</class>
<class id="2" role="fighter" name="Gladiator">
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" /> <!-- Triple Sonic Slash -->
<offensive id="6" priority="2" maxTargetHp="100" minMp="15" /> <!-- Sonic Blaster -->
<offensive id="1" priority="3" maxTargetHp="100" minMp="10" /> <!-- Triple Slash -->
<self id="8" /> <!-- Sonic Focus -->
<self id="139" />
</class>
<class id="3" role="fighter" name="Warlord">
<offensive id="245" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="4" priority="2" maxTargetHp="100" minMp="15" />
<self id="139" />
</class>
<class id="4" role="fighter" name="Knight">
<offensive id="263" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="1" priority="2" maxTargetHp="100" minMp="10" />
<self id="98" /> <!-- Shield Fortress -->
</class>
<class id="7" role="fighter" name="Rogue">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" /> <!-- Backstab: добивание -->
<offensive id="16" priority="2" maxTargetHp="100" minMp="15" />
<self id="111" /> <!-- Rapid Shot -->
</class>
<!-- ===== Лучники ===== -->
<class id="9" role="archer" name="Hawkeye">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stun Shot -->
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" /> <!-- Double Shot -->
<self id="111" />
</class>
<class id="24" role="archer" name="Silver Ranger">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" />
<self id="111" />
</class>
<!-- ===== Маги ===== -->
<class id="12" role="mage" name="Sorcerer">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" /> <!-- Wind Strike -->
<offensive id="1230" priority="1" maxTargetHp="100" minMp="30" /> <!-- Prominence -->
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" />
<self id="1085" /> <!-- Acumen -->
</class>
<class id="13" role="mage" name="Necromancer">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
<offensive id="1234" priority="1" maxTargetHp="100" minMp="30" />
<self id="1085" />
</class>
<class id="27" role="mage" name="Spellsinger">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
<offensive id="1235" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hydro Blast -->
<self id="1085" />
</class>
<class id="34" role="mage" name="Spellhowler">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
<offensive id="1239" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hurricane -->
<self id="1085" />
</class>
<!-- ===== Лекари и саппорт ===== -->
<class id="15" role="healer" name="Cleric">
<heal id="1217" priority="1" minHp="70" /> <!-- Group Heal? нет - Heal -->
<heal id="1011" priority="2" minHp="55" /> <!-- Heal -->
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" />
<self id="1040" /> <!-- Shield -->
</class>
<class id="16" role="support" name="Prophet">
<self id="1040" />
<self id="1085" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" />
</class>
<!-- Значения по умолчанию, если класса нет в списке -->
<default role="fighter">
<offensive id="3" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="1" priority="2" maxTargetHp="100" minMp="10" />
</default>
</combat>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Intent lexicon: an intent matches when EVERY <m> group has at least one
alternative that prefix-matches a token of the normalized message.
maxTokens limits the intent to short messages. Order = priority. -->
<intents>
<intent key="botcheck" seam="reply_botcheck"><m>бот|боты|скрипт|макрос|программ</m></intent>
<intent key="insult" seam=""><m>лох|лош|нуб|дурак|дура|дебил|идиот|туп|мусор|чмо|клоун|днище|бомж|крыса|слаб|отстой|пшел|пшла|заткни</m></intent>
<intent key="thanks" seam="reply_thanks"><m>спс|спасиб|пасиб|благодар|сенкс|thx|ty|респект|красав</m></intent>
<intent key="duel" seam="reply_duel"><m>дуэль|дюль|1в1|1x1|1х1|соло|выйдем|стыкнемся|пвп</m></intent>
<intent key="ask_help" seam=""><m>помо|хелп|помощ|хиль|отхил|хил|рес|ресни|спаси|выруч|бафни|баф|подсоб</m></intent>
<intent key="ask_party" seam="reply_party_no"><m>пати|парти|групп|пл|инвайт|прими|возьми</m></intent>
<intent key="ask_level" seam="reply_level"><m>какой|скок|сколько|че|чо</m><m>лвл|левел|уровен|лева</m></intent>
<intent key="ask_price" seam="reply_price"><m>почем|цена|цену|купи|продай|продаш|скупа|стоит|аден</m></intent>
<intent key="ask_rumors" seam="reply_rumor_none"><m>что|чо|че|какие|слыш</m><m>нов|слыш|происход|творится|интересн</m></intent>
<intent key="ask_where" seam="reply_where"><m>где|куда|далеко</m></intent>
<intent key="bye" seam="reply_bye"><m>пока|бб|бай|досвид|бывай|споки|удачи</m></intent>
<intent key="how_are_you" seam="reply_how" maxTokens="5"><m>как|че|чо|что</m><m>дела|сам|оно|как|жизнь|делаешь|поживаешь|фарм|настрой</m></intent>
<intent key="greeting" seam="greeting" maxTokens="3"><m>привет|прив|хай|здаров|здоров|ку|йо|хелло|дратути|салют|даров</m></intent>
<intent key="yes_ok" seam="reply_yes_ok" maxTokens="2"><m>да|ага|ок|окей|норм|пон|ясн|угу|лан|ладно</m></intent>
</intents>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Composite phrases: a line is assembled from parts.
<part pool="x" chance="40"/> - include this pool with 40% chance.
only="ganker,pkk" / skip="helper" - personality filter.
Pool lookup tries "pool.personality" first, then "pool".
When a phrase key is missing here, the flat seam with the same key is used.
-->
<phrases>
<phrase key="greeting">
<part pool="hello" chance="100" skip="ganker" />
<part pool="taunt" chance="70" only="ganker" />
<part pool="address" chance="25" />
<part pool="ask_back" chance="30" only="helper,neutral" />
<part pool="tail_laugh" chance="10" />
</phrase>
<phrase key="gank_start">
<part pool="filler" chance="20" />
<part pool="address" chance="55" />
<part pool="threat" chance="100" />
<part pool="taunt" chance="35" />
<part pool="brag" chance="15" />
<part pool="tail_laugh" chance="30" />
</phrase>
<phrase key="pkk_start">
<part pool="address" chance="45" />
<part pool="threat" chance="100" />
<part pool="taunt" chance="15" />
<part pool="tail_urge" chance="20" />
</phrase>
<phrase key="war_start">
<part pool="address" chance="40" />
<part pool="threat" chance="80" />
<part pool="brag" chance="35" />
<part pool="tail_urge" chance="15" />
</phrase>
<phrase key="assist">
<part pool="hello" chance="25" only="helper" />
<part pool="address" chance="35" />
<part pool="offer_help" chance="100" />
<part pool="tail_urge" chance="25" />
</phrase>
<phrase key="invite_player">
<part pool="hello" chance="45" skip="ganker" />
<part pool="address" chance="50" />
<part pool="offer_party" chance="100" />
<part pool="tail_urge" chance="20" />
</phrase>
<phrase key="ambient">
<part pool="filler" chance="25" />
<part pool="smalltalk" chance="70" />
<part pool="complain" chance="35" />
<part pool="ask_back" chance="20" only="helper,neutral" />
<part pool="tail_laugh" chance="10" />
</phrase>
<phrase key="levelup">
<part pool="filler" chance="15" />
<part pool="levelup_joy" chance="100" />
<part pool="brag" chance="30" only="ganker" />
<part pool="tail_laugh" chance="20" />
</phrase>
<phrase key="migrate">
<part pool="migrate_bye" chance="100" />
<part pool="goodbye" chance="60" skip="ganker" />
<part pool="tail_urge" chance="10" />
</phrase>
<phrase key="raid_start">
<part pool="filler" chance="20" />
<part pool="raid_call" chance="100" />
<part pool="tail_urge" chance="45" />
</phrase>
<phrase key="trade_shout">
<part pool="trade_pitch" chance="100" />
<part pool="tail_urge" chance="30" />
</phrase>
<phrase key="friendly_meet">
<part pool="hello" chance="70" />
<part pool="address" chance="60" />
<part pool="praise" chance="60" />
<part pool="offer_party" chance="25" only="helper" />
</phrase>
<phrase key="revenge_meet">
<part pool="address" chance="70" />
<part pool="taunt" chance="50" />
<part pool="threat" chance="70" />
<part pool="tail_laugh" chance="20" />
</phrase>
<phrase key="killed_by">
<part pool="address" chance="40" />
<part pool="taunt" chance="40" />
<part pool="threat" chance="70" />
<part pool="tail_soft" chance="15" skip="ganker" />
</phrase>
<phrase key="reply_bye">
<part pool="goodbye" chance="100" />
<part pool="tail_laugh" chance="10" />
</phrase>
<phrase key="reply_thanks">
<part pool="praise" chance="45" />
<part pool="offer_help" chance="30" only="helper" />
<part pool="tail_laugh" chance="15" />
</phrase>
<phrase key="reply_insult_aggro">
<part pool="address" chance="40" />
<part pool="taunt" chance="60" />
<part pool="threat" chance="100" />
<part pool="tail_laugh" chance="20" />
</phrase>
<phrase key="reply_insult_soft">
<part pool="filler" chance="30" />
<part pool="taunt" chance="35" />
<part pool="tail_soft" chance="60" />
</phrase>
<phrase key="store_title">
<part pool="store_goods" chance="100" />
<part pool="store_price" chance="85" />
<part pool="store_mood" chance="45" />
</phrase>
</phrases>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>SPP: боты живьём</title>
<style>
:root{--bg:#12151a;--panel:#191d24;--panel2:#1f242c;--line:#2b323d;--ink:#dad6ca;--dim:#828a96;--dim2:#5a626e;
--ochre:#c9a227;--slate:#7d93b8;--rust:#b4553f;--moss:#809466;--plum:#8d7398;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace}
*{box-sizing:border-box}html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:12.5px;line-height:1.45}
.wrap{display:grid;grid-template-columns:280px minmax(0,1fr) 360px;gap:1px;background:var(--line);height:100vh}
.col{background:var(--panel);overflow:auto;padding:12px}
h1{font-size:15px;margin:0 0 2px;color:var(--ochre)}
.sub{color:var(--dim2);font-size:10.5px;margin-bottom:10px}
.lbl{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;border-bottom:1px solid var(--line);padding-bottom:4px}
.stat{display:flex;justify-content:space-between;padding:2px 0;color:var(--dim)}
.stat b{color:var(--ink)}
select,input,button{background:var(--panel2);color:var(--ink);border:1px solid var(--line);font-family:var(--mono);font-size:12px;padding:5px 7px;border-radius:3px}
input{width:100%}button{cursor:pointer}button:hover{border-color:var(--ochre)}
canvas{width:100%;height:100%;display:block;background:#0d1014}
#map{position:relative;height:100%}
.dot{position:absolute;font-size:10px;color:var(--dim2)}
.row{padding:3px 5px;border-bottom:1px solid var(--line);cursor:pointer}
.row:hover{background:var(--panel2)}
.p_ganker{color:var(--rust)}.p_pkk{color:var(--slate)}.p_helper{color:var(--moss)}.p_neutral{color:var(--dim)}
.kv{display:flex;justify-content:space-between;padding:1px 0}.kv span:first-child{color:var(--dim2)}
.seamline{padding:2px 5px;color:var(--dim)}
.seamline.hit{color:var(--ochre);background:var(--panel2)}
.res{background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:3px;margin-top:8px}
.res .kv span:last-child{color:var(--ochre)}
</style>
</head>
<body>
<div class="wrap">
<div class="col" id="left">
<h1>SPP боты</h1>
<div class="sub">живой срез мира, обновление каждые 5 сек</div>
<div class="lbl">Итого</div>
<div id="stats"></div>
<div class="lbl">Фильтр</div>
<input id="q" placeholder="поиск по нику...">
<div style="margin-top:6px">
<select id="fAct"><option value="">— занятие —</option></select>
<select id="fPers" style="margin-top:4px"><option value="">— характер —</option>
<option>ganker</option><option>pkk</option><option>helper</option><option>neutral</option></select>
</div>
<div class="lbl">Боты <span id="cnt"></span></div>
<div id="list"></div>
</div>
<div class="col" style="padding:0"><div id="map"><canvas id="cv"></canvas></div></div>
<div class="col" id="right">
<div class="lbl">Карточка бота</div>
<div id="card" class="sub">кликни бота в списке или на карте</div>
<div class="lbl">Тестер диалога</div>
<div class="sub">что бот "поймёт" и каким швом ответит</div>
<select id="tPers"><option>neutral</option><option>helper</option><option>ganker</option><option>pkk</option></select>
<input id="tMsg" placeholder="напиши боту... (напр: помоги / ты бот? / IIpuBeT)" style="margin-top:5px">
<button id="tGo" style="margin-top:5px;width:100%">Разобрать</button>
<div id="tRes"></div>
<div class="lbl">Дебаг-команды</div>
<div class="sub">бот: пусто = случайный · арг: ник игрока / текст</div>
<input id="cName" placeholder="ник бота (пусто = случайный)">
<input id="cArg" placeholder="аргумент: ник игрока / текст" style="margin-top:4px">
<div id="cmds" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px"></div>
<div id="cRes" class="res" style="display:none"></div>
<div class="lbl">Генератор фраз</div>
<div class="sub">одна и та же фраза, собранная из частей</div>
<div style="display:flex;gap:4px">
<input id="pKey" placeholder="ключ (gank_start)" value="gank_start">
<select id="pPers"><option>ganker</option><option>pkk</option><option>helper</option><option>neutral</option></select>
</div>
<button id="pGo" style="margin-top:4px;width:100%">Собрать 8 вариантов</button>
<div id="pRes"></div>
<div class="lbl">Швы</div>
<select id="sPick"><option value="">— выбрать шов —</option></select>
<div id="sLines"></div>
</div>
</div>
<script>
let bots=[],seams={},sel=null;
const AB={x0:-120000,x1:235000,y0:-263000,y1:270000};
const TOWNS={Giran:[83396,147904],Aden:[146737,25807],Dion:[15631,142885],Gludio:[-12736,122816],
Oren:[82992,53171],Goddard:[147966,-55228],TI:[-84108,244604]};
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
function colorOf(p){return p==='ganker'?'#b4553f':p==='pkk'?'#7d93b8':p==='helper'?'#809466':'#828a96'}
function mapXY(x,y,W,H){return[(x-AB.x0)/(AB.x1-AB.x0)*W,(y-AB.y0)/(AB.y1-AB.y0)*H]}
function draw(){
const r=cv.parentElement.getBoundingClientRect();cv.width=r.width;cv.height=r.height;
ctx.clearRect(0,0,cv.width,cv.height);
ctx.font='9px monospace';ctx.fillStyle='#3a424e';
for(const[t,[x,y]]of Object.entries(TOWNS)){const[a,b]=mapXY(x,y,cv.width,cv.height);
ctx.fillRect(a-2,b-2,4,4);ctx.fillText(t,a+5,b+3)}
for(const b of shown()){const[a,c]=mapXY(b.x,b.y,cv.width,cv.height);
ctx.fillStyle=b.activity==='combat'?'#e0c040':colorOf(b.personality);
const rad=b.headless?3.5:2;
ctx.beginPath();ctx.arc(a,c,sel&&sel.id===b.id?5:rad,0,7);ctx.fill();
if(b.headless){ctx.strokeStyle='#c9a227';ctx.lineWidth=1;ctx.stroke()}}
}
cv.addEventListener('click',e=>{
const r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;let best=null,bd=99;
for(const b of shown()){const[a,c]=mapXY(b.x,b.y,cv.width,cv.height);
const d=Math.hypot(a-mx,c-my);if(d<bd&&d<12){bd=d;best=b}}
if(best){sel=best;card();draw()}
});
function shown(){
const q=document.getElementById('q').value.toLowerCase(),
fa=document.getElementById('fAct').value,fp=document.getElementById('fPers').value;
return bots.filter(b=>(!q||b.name.toLowerCase().includes(q))&&(!fa||b.activity===fa)&&(!fp||b.personality===fp))
}
function card(){
if(!sel)return;const b=bots.find(x=>x.id===sel.id)||sel;
document.getElementById('card').innerHTML=
`<div class="kv"><span>ник</span><b class="p_${b.personality}">${b.name}</b></div>`+
['level','personality','race','zone','activity','target','hp','clanId','headless'].map(k=>
`<div class="kv"><span>${k}</span><span>${b[k]??''}</span></div>`).join('')+
`<div class="kv"><span>коорд</span><span>${b.x} ${b.y} ${b.z}</span></div>`;
}
function refresh(){
fetch('/api/bots').then(r=>r.json()).then(d=>{
bots=d;
const acts={},pers={};for(const b of d){acts[b.activity]=(acts[b.activity]||0)+1;pers[b.personality]=(pers[b.personality]||0)+1}
document.getElementById('stats').innerHTML=
`<div class="stat"><span>всего</span><b>${d.length}</b></div>`+
Object.entries(acts).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`<div class="stat"><span>${k}</span><b>${v}</b></div>`).join('');
const fa=document.getElementById('fAct');const cur=fa.value;
fa.innerHTML='<option value="">— занятие —</option>'+Object.keys(acts).map(a=>`<option${a===cur?' selected':''}>${a}</option>`).join('');
list();draw();card();
}).catch(()=>{});
}
function list(){
const s=shown().slice(0,200);
document.getElementById('cnt').textContent='('+shown().length+')';
document.getElementById('list').innerHTML=s.map(b=>
`<div class="row" onclick='selBot(${b.id})'><span class="p_${b.personality}">${b.headless?'★ ':''}${b.name}</span> <span style="color:var(--dim2)">${b.level} · ${b.activity}${b.target?" → "+b.target:""}</span></div>`).join('');
}
function selBot(id){sel=bots.find(b=>b.id===id);card();draw();if(sel)document.getElementById('cName').value=sel.name;
if(sel)document.getElementById('tPers').value=sel.personality||'neutral'}
document.getElementById('q').oninput=()=>{list();draw()};
document.getElementById('fAct').onchange=()=>{list();draw()};
document.getElementById('fPers').onchange=()=>{list();draw()};
document.getElementById('tGo').onclick=()=>{
const m=document.getElementById('tMsg').value,p=document.getElementById('tPers').value;
fetch('/api/test?msg='+encodeURIComponent(m)+'&personality='+encodeURIComponent(p)).then(r=>r.json()).then(d=>{
document.getElementById('tRes').innerHTML=`<div class="res">
<div class="kv"><span>нормализовано</span><span>${d.normalized}</span></div>
<div class="kv"><span>интент</span><span>${d.intent||'— не распознан —'}</span></div>
<div class="kv"><span>для LLM</span><span>${d.hint||'—'}</span></div>
<div class="kv"><span>шов</span><span>${d.seam||'—'}</span></div>
<div class="kv"><span>ответ</span><span>${d.line||'(молчание/паттерны)'}</span></div></div>`;
if(d.seam){document.getElementById('sPick').value=d.seam;showSeam(d.seam,d.line)}
})};
document.getElementById('tMsg').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('tGo').click()});
function showSeam(name,hit){
const lines=seams[name]||[];
document.getElementById('sLines').innerHTML=lines.map(l=>
`<div class="seamline${l===hit?' hit':''}">${l}</div>`).join('')||'<div class="sub">пусто</div>';
}
document.getElementById('sPick').onchange=e=>showSeam(e.target.value);
fetch('/api/seams').then(r=>r.json()).then(d=>{seams=d;
document.getElementById('sPick').innerHTML='<option value="">— выбрать шов —</option>'+
Object.keys(d).sort().map(k=>`<option>${k}</option>`).join('')});
const CMDS=[['list','список'],['trade','лавка'],['tradeall','лавки x10'],['closeshop','закрыть'],
['party','в пати'],['come','ко мне'],['gank','гангнуть'],['levelup','+1 лвл'],['migrate','переезд'],
['say','сказать'],['kill','убить'],['rumor','дать слух'],['clanwar','война кланов']];
document.getElementById('cmds').innerHTML=CMDS.map(([c,t])=>`<button onclick="runCmd('${c}')">${t}</button>`).join('');
function runCmd(c){
const n=document.getElementById('cName').value,a=document.getElementById('cArg').value;
fetch(`/api/cmd?do=${c}&name=${encodeURIComponent(n)}&arg=${encodeURIComponent(a)}`)
.then(r=>r.json()).then(d=>{const el=document.getElementById('cRes');
el.style.display='block';el.textContent=d.result;refresh()});
}
document.getElementById('pGo').onclick=()=>{
const k=document.getElementById('pKey').value,p=document.getElementById('pPers').value;
fetch(`/api/phrase?key=${encodeURIComponent(k)}&personality=${p}`).then(r=>r.json()).then(d=>{
document.getElementById('pRes').innerHTML='<div class="res">'+d.map(l=>`<div class="seamline">${l||'(пусто)'}</div>`).join('')+'</div>';
})};
refresh();setInterval(refresh,5000);
window.addEventListener('resize',draw);
</script>
</body>
</html>
@@ -27,6 +27,10 @@ import org.l2jmobius.gameserver.handler.IChatHandler;
import org.l2jmobius.gameserver.handler.IVoicedCommandHandler; import org.l2jmobius.gameserver.handler.IVoicedCommandHandler;
import org.l2jmobius.gameserver.handler.VoicedCommandHandler; import org.l2jmobius.gameserver.handler.VoicedCommandHandler;
import org.l2jmobius.gameserver.network.SystemMessageId; import org.l2jmobius.gameserver.network.SystemMessageId;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.managers.FakePlayerChatManager;
import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.network.enums.ChatType; import org.l2jmobius.gameserver.network.enums.ChatType;
import org.l2jmobius.gameserver.network.serverpackets.CreatureSay; import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;
@@ -119,6 +123,26 @@ public class ChatGeneral implements IChatHandler
}); });
activeChar.sendPacket(cs); activeChar.sendPacket(cs);
// Headless bots nearby may react to general chat.
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE))
{
final Player headless = World.getNearestVisibleObjectInRange(activeChar, Player.class, 1250, nearby -> nearby.isHeadlessBot() && !nearby.isDead());
if (headless != null)
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onLocalChat(activeChar, headless, text);
}
}
// Fake players may react to nearby general chat.
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE))
{
final Npc fpc = World.getNearestVisibleObjectInRange(activeChar, Npc.class, 1250, nearby -> nearby.isFakePlayer() && !nearby.isDead());
if (fpc != null)
{
FakePlayerChatManager.getInstance().manageLocalChat(activeChar, fpc, text);
}
}
} }
} }
@@ -62,6 +62,15 @@ public class ChatWhisper implements IChatHandler
return; return;
} }
// Headless bots are real players - answer their whispers with the bot pipeline.
final Player headlessTarget = org.l2jmobius.gameserver.entity.World.getPlayer(target);
if ((headlessTarget != null) && headlessTarget.isHeadlessBot())
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onWhisper(activeChar, headlessTarget, text);
activeChar.sendPacket(new CreatureSay(activeChar, ChatType.WHISPER, target, text));
return;
}
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && (FakePlayerData.getInstance().getProperName(target) != null)) if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && (FakePlayerData.getInstance().getProperName(target) != null))
{ {
if (FakePlayerData.getInstance().isTalkable(target)) if (FakePlayerData.getInstance().isTalkable(target))
@@ -99,7 +108,7 @@ public class ChatWhisper implements IChatHandler
return; return;
} }
if ((receiver.getClient() == null) || receiver.getClient().isDetached()) if (!receiver.isHeadlessBot() && ((receiver.getClient() == null) || receiver.getClient().isDetached()))
{ {
activeChar.sendMessage("Player is in offline mode."); activeChar.sendMessage("Player is in offline mode.");
return; return;
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<list enabled="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../xsd/spawns.xsd"> <list enabled="false" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../xsd/spawns.xsd">
<spawn name="FakePlayers"> <spawn name="FakePlayers">
<npc id="80000" x="83485" y="147998" z="-3407" heading="23509" respawnDelay="60" /> <!-- Evi --> <npc id="80000" x="83485" y="147998" z="-3407" heading="23509" respawnDelay="60" /> <!-- Evi -->
</spawn> </spawn>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:complexType name="lineType">
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="trait" type="xs:string" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<xs:element name="lines">
<xs:complexType>
<xs:sequence>
<xs:element name="seam" maxOccurs="unbounded">
<xs:complexType>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="line" type="lineType" />
<xs:element name="rule">
<xs:complexType>
<xs:sequence>
<xs:element name="line" type="lineType" maxOccurs="unbounded" />
</xs:sequence>
<xs:attribute name="need" type="xs:string" />
<xs:attribute name="like" type="xs:string" />
</xs:complexType>
</xs:element>
</xs:choice>
<xs:attribute name="key" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -121,7 +121,16 @@ import org.l2jmobius.gameserver.managers.DailyResetManager;
import org.l2jmobius.gameserver.managers.DayNightSpawnManager; import org.l2jmobius.gameserver.managers.DayNightSpawnManager;
import org.l2jmobius.gameserver.managers.DimensionalRiftManager; import org.l2jmobius.gameserver.managers.DimensionalRiftManager;
import org.l2jmobius.gameserver.managers.EventDropManager; import org.l2jmobius.gameserver.managers.EventDropManager;
import org.l2jmobius.gameserver.managers.FakePlayerAiTaskManager;
import org.l2jmobius.gameserver.managers.FakePlayerChatLines;
import org.l2jmobius.gameserver.managers.FakePlayerChatManager; import org.l2jmobius.gameserver.managers.FakePlayerChatManager;
import org.l2jmobius.gameserver.managers.FakePlayerCombatData;
import org.l2jmobius.gameserver.managers.FakePlayerMemoryManager;
import org.l2jmobius.gameserver.managers.FakePlayerDashboard;
import org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager;
import org.l2jmobius.gameserver.managers.FakePlayerIntentParser;
import org.l2jmobius.gameserver.managers.FakePlayerRumorManager;
import org.l2jmobius.gameserver.managers.FakePlayerProgressionManager;
import org.l2jmobius.gameserver.managers.FishingChampionshipManager; import org.l2jmobius.gameserver.managers.FishingChampionshipManager;
import org.l2jmobius.gameserver.managers.GlobalVariablesManager; import org.l2jmobius.gameserver.managers.GlobalVariablesManager;
import org.l2jmobius.gameserver.managers.GrandBossManager; import org.l2jmobius.gameserver.managers.GrandBossManager;
@@ -283,6 +292,13 @@ public class GameServer
NpcData.getInstance(); NpcData.getInstance();
LevelUpCrystalData.getInstance(); LevelUpCrystalData.getInstance();
FakePlayerChatManager.getInstance(); FakePlayerChatManager.getInstance();
FakePlayerCombatData.getInstance();
FakePlayerAiTaskManager.getInstance();
FakePlayerChatLines.getInstance();
FakePlayerMemoryManager.getInstance();
FakePlayerRumorManager.getInstance();
FakePlayerIntentParser.getInstance();
FakePlayerDashboard.getInstance();
WalkingManager.getInstance(); WalkingManager.getInstance();
StaticObjectData.getInstance(); StaticObjectData.getInstance();
CastleManager.getInstance().loadInstances(); CastleManager.getInstance().loadInstances();
@@ -337,6 +353,8 @@ public class GameServer
} }
SpawnData.getInstance(); SpawnData.getInstance();
FakePlayerProgressionManager.getInstance();
FakePlayerHeadlessManager.getInstance();
DayNightSpawnManager.getInstance().trim().notifyChangeMode(); DayNightSpawnManager.getInstance().trim().notifyChangeMode();
DimensionalRiftManager.getInstance(); DimensionalRiftManager.getInstance();
RaidBossSpawnManager.getInstance(); RaidBossSpawnManager.getInstance();
@@ -491,11 +491,15 @@ public class AttackableAI extends CreatureAI
return; return;
} }
if (npc.isFakePlayer() && npc.isAggressive()) if (npc.isFakePlayer())
{ {
final List<Item> droppedItems = npc.getFakePlayerDrops(); final List<Item> droppedItems = npc.getFakePlayerDrops();
if (droppedItems.isEmpty()) if (droppedItems.isEmpty())
{ {
if (!npc.isAggressive())
{
return;
}
final Creature nearestTarget = World.getNearestVisibleObjectInRange(npc, Creature.class, npc.getAggroRange(), t -> (t != _actor) && !t.isDead() && ((FakePlayersConfig.FAKE_PLAYER_AGGRO_FPC && t.isFakePlayer()) || (FakePlayersConfig.FAKE_PLAYER_AGGRO_MONSTERS && t.isMonster() && !t.isFakePlayer()) || (FakePlayersConfig.FAKE_PLAYER_AGGRO_PLAYERS && t.isPlayer())) && (npc.getHating(t) == 0)); final Creature nearestTarget = World.getNearestVisibleObjectInRange(npc, Creature.class, npc.getAggroRange(), t -> (t != _actor) && !t.isDead() && ((FakePlayersConfig.FAKE_PLAYER_AGGRO_FPC && t.isFakePlayer()) || (FakePlayersConfig.FAKE_PLAYER_AGGRO_MONSTERS && t.isMonster() && !t.isFakePlayer()) || (FakePlayersConfig.FAKE_PLAYER_AGGRO_PLAYERS && t.isPlayer())) && (npc.getHating(t) == 0));
if (nearestTarget != null) if (nearestTarget != null)
{ {
@@ -43,6 +43,23 @@ public class FakePlayersConfig
public static boolean FAKE_PLAYER_AGGRO_FPC; public static boolean FAKE_PLAYER_AGGRO_FPC;
public static boolean FAKE_PLAYER_CAN_DROP_ITEMS; public static boolean FAKE_PLAYER_CAN_DROP_ITEMS;
public static boolean FAKE_PLAYER_CAN_PICKUP; public static boolean FAKE_PLAYER_CAN_PICKUP;
public static boolean FAKE_PLAYER_LLM_CHAT;
public static String FAKE_PLAYER_LLM_URL;
public static String FAKE_PLAYER_LLM_MODEL;
public static String FAKE_PLAYER_LLM_LANGUAGE;
public static int FAKE_PLAYER_LLM_TIMEOUT;
public static int FAKE_PLAYER_AMBIENT_CHAT_CHANCE;
public static int FAKE_PLAYER_COMBAT_CHAT_CHANCE;
public static int FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE;
public static int FAKE_PLAYER_AI_TICK;
public static boolean FAKE_PLAYER_PROGRESSION;
public static double FAKE_PLAYER_XP_RATE;
public static int FAKE_PLAYER_HEADLESS_COUNT;
public static boolean FAKE_PLAYER_TRADING;
public static int FAKE_PLAYER_TRADE_DURATION;
public static int FAKE_PLAYER_TRADE_COOLDOWN;
public static int FAKE_PLAYER_TRADE_SHOUT_INTERVAL;
public static int FAKE_PLAYER_TRADE_CHANCE;
public static void load() public static void load()
{ {
@@ -58,5 +75,22 @@ public class FakePlayersConfig
FAKE_PLAYER_AGGRO_FPC = config.getBoolean("FakePlayerAggroFPC", false); FAKE_PLAYER_AGGRO_FPC = config.getBoolean("FakePlayerAggroFPC", false);
FAKE_PLAYER_CAN_DROP_ITEMS = config.getBoolean("FakePlayerCanDropItems", false); FAKE_PLAYER_CAN_DROP_ITEMS = config.getBoolean("FakePlayerCanDropItems", false);
FAKE_PLAYER_CAN_PICKUP = config.getBoolean("FakePlayerCanPickup", false); FAKE_PLAYER_CAN_PICKUP = config.getBoolean("FakePlayerCanPickup", false);
FAKE_PLAYER_LLM_CHAT = config.getBoolean("FakePlayerLlmChat", false);
FAKE_PLAYER_LLM_URL = config.getString("FakePlayerLlmUrl", "http://127.0.0.1:11434/api/generate");
FAKE_PLAYER_LLM_MODEL = config.getString("FakePlayerLlmModel", "gemma3");
FAKE_PLAYER_LLM_LANGUAGE = config.getString("FakePlayerLlmLanguage", "русский");
FAKE_PLAYER_LLM_TIMEOUT = config.getInt("FakePlayerLlmTimeout", 15000);
FAKE_PLAYER_AMBIENT_CHAT_CHANCE = config.getInt("FakePlayerAmbientChatChance", 30);
FAKE_PLAYER_COMBAT_CHAT_CHANCE = config.getInt("FakePlayerCombatChatChance", 60);
FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE = config.getInt("FakePlayerLocalChatReplyChance", 40);
FAKE_PLAYER_AI_TICK = config.getInt("FakePlayerAiTick", 5);
FAKE_PLAYER_PROGRESSION = config.getBoolean("FakePlayerProgression", false);
FAKE_PLAYER_XP_RATE = config.getDouble("FakePlayerXpRate", 3);
FAKE_PLAYER_HEADLESS_COUNT = config.getInt("FakePlayerHeadlessCount", 20);
FAKE_PLAYER_TRADING = config.getBoolean("FakePlayerTrading", false);
FAKE_PLAYER_TRADE_DURATION = config.getInt("FakePlayerTradeDuration", 60);
FAKE_PLAYER_TRADE_COOLDOWN = config.getInt("FakePlayerTradeCooldown", 120);
FAKE_PLAYER_TRADE_SHOUT_INTERVAL = config.getInt("FakePlayerTradeShoutInterval", 180);
FAKE_PLAYER_TRADE_CHANCE = config.getInt("FakePlayerTradeChance", 30);
} }
} }
@@ -62,6 +62,7 @@ import org.l2jmobius.gameserver.entity.groups.Party;
import org.l2jmobius.gameserver.entity.item.ItemTemplate; import org.l2jmobius.gameserver.entity.item.ItemTemplate;
import org.l2jmobius.gameserver.entity.item.holders.ItemHolder; import org.l2jmobius.gameserver.entity.item.holders.ItemHolder;
import org.l2jmobius.gameserver.entity.item.instance.Item; import org.l2jmobius.gameserver.entity.item.instance.Item;
import org.l2jmobius.gameserver.managers.FakePlayerProgressionManager;
import org.l2jmobius.gameserver.managers.CursedWeaponsManager; import org.l2jmobius.gameserver.managers.CursedWeaponsManager;
import org.l2jmobius.gameserver.managers.EventDropManager; import org.l2jmobius.gameserver.managers.EventDropManager;
import org.l2jmobius.gameserver.managers.PcCafePointsManager; import org.l2jmobius.gameserver.managers.PcCafePointsManager;
@@ -318,6 +319,18 @@ public class Attackable extends Npc
return false; return false;
} }
// Fake player progression.
if ((killer != null) && killer.isFakePlayer() && !isFakePlayer() && (killer instanceof Npc fakeKiller))
{
FakePlayerProgressionManager.getInstance().onKill(fakeKiller, this);
}
// Fake player memory: remember the player who killed this bot.
if (isFakePlayer() && (killer != null) && (killer.asPlayer() != null))
{
FakePlayerProgressionManager.getInstance().onBotKilled(this, killer.asPlayer());
}
// Delayed notification. // Delayed notification.
if (killer != null) if (killer != null)
{ {
@@ -1866,7 +1866,28 @@ public class Npc extends Creature
@Override @Override
public String getName() public String getName()
{ {
return getTemplate().getName(); final String customName = super.getName();
return (customName != null) && !customName.isEmpty() ? customName : getTemplate().getName();
}
private int _fakePlayerClanId = 0;
/**
* @return per instance clan override for fake players, falling back to the template value.
*/
public int getFakePlayerClanId()
{
if (_fakePlayerClanId > 0)
{
return _fakePlayerClanId;
}
final FakePlayerHolder holder = getTemplate().getFakePlayerInfo();
return (holder != null) ? holder.getClanId() : 0;
}
public void setFakePlayerClanId(int clanId)
{
_fakePlayerClanId = clanId;
} }
@Override @Override
@@ -4300,6 +4300,18 @@ public class Player extends Playable
return getAllyId() == 0 ? 0 : _clan.getAllyCrestId(); return getAllyId() == 0 ? 0 : _clan.getAllyCrestId();
} }
private boolean _headlessBot = false;
public boolean isHeadlessBot()
{
return _headlessBot;
}
public void setHeadlessBot(boolean value)
{
_headlessBot = value;
}
@Override @Override
public void sendPacket(ServerPacket packet) public void sendPacket(ServerPacket packet)
{ {
@@ -5020,6 +5032,12 @@ public class Player extends Playable
return false; return false;
} }
// Headless bot memory: remember the player who killed it.
if (_headlessBot && (killer != null) && (killer.asPlayer() != null) && (killer != this))
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onBotKilled(this, killer.asPlayer());
}
if (isMounted()) if (isMounted())
{ {
stopFeed(); stopFeed();
@@ -0,0 +1,464 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.data.sql.ClanTable;
import org.l2jmobius.gameserver.entity.WorldObject;
import org.l2jmobius.gameserver.entity.clan.Clan;
import org.l2jmobius.gameserver.entity.actor.Attackable;
import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.actor.instance.Monster;
/**
* Periodic "brain" for fake players.
* Built-in AttackableAI already handles aggression (farm and gank) for aggressive
* fake player templates, while WalkingManager handles route movement. This task adds:
* <ul>
* <li>Helper personality: assist nearby real players against monsters.</li>
* <li>Combat taunts: one line when a fake player engages a real player.</li>
* <li>Ambient chatter when real players are around.</li>
* </ul>
* Personality is read from npc template parameter "fpcPersonality" (ganker, helper, neutral).
* @author Mobius SPP
*/
public class FakePlayerAiTaskManager
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerAiTaskManager.class.getName());
private static final int LIST_REFRESH_TICKS = 12;
private static final int PLAYER_NOTICE_RANGE = 900;
private static final int AMBIENT_RANGE = 700;
private static final long AMBIENT_COOLDOWN = 90000;
private static final long TAUNT_COOLDOWN = 20000;
private final List<Npc> _fakePlayers = new ArrayList<>();
private final Map<Integer, BotState> _states = new ConcurrentHashMap<>();
private int _tick = 0;
private static class BotState
{
long lastChat = 0;
long lastHelpCredit = 0;
long lastHelpCall = 0;
int lastPvpTargetId = 0;
int companionPlayerId = 0;
long companionUntil = 0;
int pendingInvitePlayerId = 0;
long pendingInviteUntil = 0;
}
protected FakePlayerAiTaskManager()
{
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED)
{
final long interval = Math.max(2, FakePlayersConfig.FAKE_PLAYER_AI_TICK) * 1000;
ThreadPool.scheduleAtFixedRate(this::tick, interval, interval);
LOGGER.info(getClass().getSimpleName() + ": Started with " + (interval / 1000) + " second tick.");
}
}
private void tick()
{
try
{
if ((_tick % LIST_REFRESH_TICKS) == 0)
{
refreshList();
}
_tick++;
synchronized (_fakePlayers)
{
for (Npc npc : _fakePlayers)
{
try
{
handleBot(npc);
}
catch (Exception e)
{
// Never let a single bot break the loop.
}
}
}
}
catch (Exception e)
{
// Task must survive.
}
}
private void refreshList()
{
synchronized (_fakePlayers)
{
_fakePlayers.clear();
for (WorldObject object : World.getVisibleObjects())
{
if ((object instanceof Npc npc) && npc.isFakePlayer())
{
_fakePlayers.add(npc);
}
}
}
}
private void handleBot(Npc npc)
{
if (npc.isDead() || !npc.isSpawned())
{
_states.remove(npc.getObjectId());
return;
}
final BotState state = _states.computeIfAbsent(npc.getObjectId(), key -> new BotState());
final long now = System.currentTimeMillis();
// Busy collecting loot - let core AI finish the pickup.
if (!npc.isInCombat() && !npc.getFakePlayerDrops().isEmpty())
{
return;
}
// Companion mode: escort the player, assist their targets.
if (state.companionPlayerId != 0)
{
if (now >= state.companionUntil)
{
state.companionPlayerId = 0;
FakePlayerChatManager.getInstance().manageEventChat(npc, "Ты пофармил с игроком в паре и теперь уходишь по своим делам. Попрощайся.", FakePlayerChatLines.getInstance().pick("companion_done", null), "companion_done");
}
else
{
final WorldObject companionObject = World.findObject(state.companionPlayerId);
if (!(companionObject instanceof Player companion) || companion.isDead() || (npc.calculateDistance2D(companion) > 4000))
{
state.companionPlayerId = 0;
}
else
{
if (!npc.isInCombat() && (npc instanceof Attackable attackableComp))
{
final WorldObject companionTarget = companion.getTarget();
if ((companionTarget instanceof Monster monster) && companion.isInCombat() && !monster.isDead())
{
attackableComp.addDamageHate(monster, 0, 300);
npc.setRunning();
npc.getAI().setIntentionAttack(monster);
}
else if (npc.calculateDistance2D(companion) > 250)
{
npc.setRunning();
npc.getAI().setIntentionFollow(companion);
}
}
return;
}
}
}
// Combat: taunt once per new player victim.
final WorldObject target = npc.getTarget();
// Credit players who help this bot kill its monster target.
if (npc.isInCombat() && (target instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000))
{
final Player helperPlayer = World.getNearestVisibleObjectInRange(npc, Player.class, 900, nearby -> !nearby.isDead() && nearby.isInCombat() && (nearby.getTarget() == monsterTarget));
if (helperPlayer != null)
{
state.lastHelpCredit = now;
FakePlayerMemoryManager.getInstance().onHelpedBy(FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId()), helperPlayer.getName());
}
}
if (npc.isInCombat() && (target instanceof Player victim))
{
// Losing the fight: call clanmates for help.
if ((npc.getCurrentHp() < (npc.getMaxHp() * 0.6)) && ((now - state.lastHelpCall) > 30000))
{
state.lastHelpCall = now;
FakePlayerChatManager.getInstance().broadcastShout(npc, FakePlayerChatLines.getInstance().pick("call_help", null));
final int clanId = npc.getFakePlayerClanId();
World.forEachVisibleObjectInRange(npc, Npc.class, 1500, ally ->
{
if (ally.isFakePlayer() && (ally != npc) && !ally.isDead() && !ally.isInCombat() && (ally.getFakePlayerClanId() == clanId) && (ally instanceof Attackable allyAttackable))
{
allyAttackable.addDamageHate(victim, 0, 400);
ally.setRunning();
ally.getAI().setIntentionAttack(victim);
}
});
}
if ((victim.getObjectId() != state.lastPvpTargetId) && ((now - state.lastChat) > TAUNT_COOLDOWN))
{
state.lastPvpTargetId = victim.getObjectId();
state.lastChat = now;
if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Ты только что напал на игрока по имени " + victim.getName() + ". Крикни ему что-нибудь дерзкое или насмешливое.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "gank_start", personalityOf(npc), "target", victim.getName()), "gank_start");
}
}
return;
}
state.lastPvpTargetId = 0;
// Clan war: any bot attacks members of clans at war with its own clan.
final int botClanId = npc.getFakePlayerClanId();
if ((botClanId > 0) && !npc.isInCombat() && (npc instanceof Attackable attackableWar))
{
final Clan botClan = ClanTable.getInstance().getClan(botClanId);
if (botClan != null)
{
final Player enemy = World.getNearestVisibleObjectInRange(npc, Player.class, PLAYER_NOTICE_RANGE, nearby -> !nearby.isDead() && !nearby.isInvisible() && (nearby.getClan() != null) && (botClan.isAtWarWith(nearby.getClanId()) || nearby.getClan().isAtWarWith(botClanId)));
if (enemy != null)
{
attackableWar.addDamageHate(enemy, 0, 300);
npc.setRunning();
npc.getAI().setIntentionAttack(enemy);
if ((now - state.lastChat) > TAUNT_COOLDOWN)
{
state.lastChat = now;
if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Клан игрока " + enemy.getName() + " в состоянии войны с твоим кланом. Ты его атакуешь. Крикни боевую фразу про клановую войну.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "war_start", personalityOf(npc), "target", enemy.getName()), "war_start");
}
}
return;
}
}
}
// Helper personality: assist nearby real players.
final String personality = npc.getTemplate().getParameters().getString("fpcPersonality", npc.isAggressive() ? "ganker" : "neutral");
if ("helper".equals(personality) && !npc.isInCombat() && (npc instanceof Attackable attackable))
{
final Monster assistTarget = findAssistTarget(npc);
if (assistTarget != null)
{
attackable.addDamageHate(assistTarget, 0, 100);
npc.setRunning();
npc.getAI().setIntentionAttack(assistTarget);
if ((now - state.lastChat) > TAUNT_COOLDOWN)
{
state.lastChat = now;
if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Ты помогаешь игроку убить монстра " + assistTarget.getName() + ". Скажи что-нибудь короткое и дружелюбное.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "assist", personalityOf(npc), "player", "друг"), "assist");
}
}
return;
}
}
// PKK personality: hunt flagged (purple) and red (karma) players.
if ("pkk".equals(personality) && !npc.isInCombat() && (npc instanceof Attackable attackablePkk))
{
final Player criminal = World.getNearestVisibleObjectInRange(npc, Player.class, PLAYER_NOTICE_RANGE, nearby -> !nearby.isDead() && !nearby.isInvisible() && ((nearby.getKarma() > 0) || (nearby.getPvpFlag() != 0)));
if (criminal != null)
{
attackablePkk.addDamageHate(criminal, 0, 200);
npc.setRunning();
npc.getAI().setIntentionAttack(criminal);
if ((now - state.lastChat) > TAUNT_COOLDOWN)
{
state.lastChat = now;
if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Ты охотник на ПК. Рядом " + ((criminal.getKarma() > 0) ? "красный ник (убийца) " : "игрок с PvP-флагом ") + criminal.getName() + " - ты его атакуешь. Крикни что-нибудь про правосудие.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "pkk_start", personalityOf(npc), "target", criminal.getName()), "pkk_start");
}
}
return;
}
}
// Non aggressive personalities farm monsters on their own (progression needs kills).
if (!"ganker".equals(personality) && !npc.isInCombat() && (npc instanceof Attackable attackableFarm) && (Rnd.get(100) < 60))
{
final Monster farmTarget = World.getNearestVisibleObjectInRange(npc, Monster.class, 600, monster -> !monster.isDead() && !monster.isFakePlayer() && !monster.isRaid());
if (farmTarget != null)
{
attackableFarm.addDamageHate(farmTarget, 0, 50);
npc.getAI().setIntentionAttack(farmTarget);
return;
}
}
// Gossip: idle bots exchange stories with distortion (rumor spread).
if (!npc.isInCombat() && (Rnd.get(100) < 3))
{
final Npc listener = World.getNearestVisibleObjectInRange(npc, Npc.class, 300, other -> other.isFakePlayer() && (other != npc) && !other.isDead());
if (listener != null)
{
final int tellerId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
final int listenerId = FakePlayerProgressionManager.getInstance().getCharacterId(listener.getObjectId());
final FakePlayerRumorManager.Story passed = FakePlayerRumorManager.getInstance().gossip(tellerId, listenerId);
if ((passed != null) && (World.getNearestVisibleObjectInRange(npc, Player.class, AMBIENT_RANGE, p -> !p.isDead()) != null))
{
// Player nearby overhears the gossip.
FakePlayerChatManager.getInstance().broadcastToNearby(npc, "слышал? " + FakePlayerRumorManager.getInstance().tell(passed).replace(" ты ", " " + passed.subject + " "));
}
}
}
// Ambient chatter when real players are around.
if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE))
{
final Player nearby = World.getNearestVisibleObjectInRange(npc, Player.class, AMBIENT_RANGE, player -> !player.isDead() && !player.isInvisible());
if (nearby != null)
{
state.lastChat = now;
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(botId, nearby.getName());
final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(botId, nearby.getName());
if ((rumor != null) && (Rnd.get(100) < 50) && ((memory == null) || ((memory.score > -2) && (memory.score < 2))))
{
// The bot heard things about this player and says so.
FakePlayerChatManager.getInstance().manageEventChat(npc, "Рядом игрок " + nearby.getName() + ". " + FakePlayerRumorManager.getInstance().describeFor(botId, nearby.getName()) + " Скажи ему об этом в своей манере.", FakePlayerRumorManager.getInstance().tell(rumor));
}
else if ((memory != null) && (memory.score <= -2))
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Рядом игрок " + nearby.getName() + ", который раньше убивал тебя " + memory.kills + " раз. Ты его узнал. Скажи что-нибудь злопамятное.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "revenge_meet", personalityOf(npc), "player", nearby.getName()), "revenge_meet");
}
else if ((memory != null) && (memory.score >= 2))
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Рядом игрок " + nearby.getName() + ", который раньше помогал тебе. Ты его узнал. Поприветствуй тепло.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "friendly_meet", personalityOf(npc), "player", nearby.getName()), "friendly_meet");
}
else if ((("helper".equals(personality)) || ("neutral".equals(personality))) && (state.companionPlayerId == 0) && (Rnd.get(100) < 25))
{
// Invite the player to team up; a "yes" within a minute starts companion mode.
state.pendingInvitePlayerId = nearby.getObjectId();
state.pendingInviteUntil = now + 60000;
FakePlayerChatManager.getInstance().manageEventChat(npc, "Рядом игрок " + nearby.getName() + ". Позови его в пати пофармить вместе.", FakePlayerChatLines.getInstance().pick("invite_player", "player", nearby.getName()), "invite_player");
}
else if (Rnd.get(100) < 12)
{
// Sometimes the "ambient" slot becomes a party search in the trade channel.
final int level = FakePlayerProgressionManager.getInstance().getLevel(npc.getObjectId());
final String zone = FakePlayerProgressionManager.getInstance().getZoneName(npc.getObjectId());
final java.util.Map<String, String> lfpSlots = new java.util.HashMap<>(2);
lfpSlots.put("level", String.valueOf((level > 0) ? level : npc.getTemplate().getLevel()));
if (!zone.isEmpty())
{
lfpSlots.put("zone", zone);
}
final String lfpLine = FakePlayerChatLines.getInstance().speak(botIdOf(npc), "lfp", null, lfpSlots, true, null);
if (lfpLine != null)
{
FakePlayerChatManager.getInstance().broadcastTrade(npc, lfpLine);
}
}
else
{
FakePlayerChatManager.getInstance().manageEventChat(npc, "Рядом с тобой игрок " + nearby.getName() + ". Скажи в чат что-нибудь бытовое про фарм, дроп, спот или сервер.", FakePlayerChatLines.getInstance().compose(botIdOf(npc), "ambient", personalityOf(npc), null), "ambient");
}
}
}
}
private Monster findAssistTarget(Npc npc)
{
final Player player = World.getNearestVisibleObjectInRange(npc, Player.class, PLAYER_NOTICE_RANGE, nearby -> !nearby.isDead() && nearby.isInCombat());
if (player == null)
{
return null;
}
// Player's current target.
final WorldObject playerTarget = player.getTarget();
if ((playerTarget instanceof Monster monster) && !monster.isDead() && !monster.isFakePlayer() && (npc.calculateDistance2D(monster) < 2000))
{
return monster;
}
// Monster attacking the player.
return World.getNearestVisibleObjectInRange(npc, Monster.class, PLAYER_NOTICE_RANGE, monster -> !monster.isDead() && !monster.isFakePlayer() && (monster.getTarget() == player));
}
/**
* Starts companion mode: the bot follows the player and assists their targets.
* @param npc the bot.
* @param player the player.
* @param minutes duration.
*/
public void startCompanion(Npc npc, Player player, int minutes)
{
final BotState state = _states.computeIfAbsent(npc.getObjectId(), key -> new BotState());
state.companionPlayerId = player.getObjectId();
state.companionUntil = System.currentTimeMillis() + (minutes * 60000L);
state.pendingInvitePlayerId = 0;
}
/**
* @param npc the bot.
* @param player the player.
* @return true when this bot recently invited this player and the invite is still valid.
*/
public boolean hasPendingInvite(Npc npc, Player player)
{
final BotState state = _states.get(npc.getObjectId());
return (state != null) && (state.pendingInvitePlayerId == player.getObjectId()) && (System.currentTimeMillis() < state.pendingInviteUntil);
}
/**
* @param npcObjectId bot object id.
* @return true when the bot is currently escorting a player.
*/
public boolean isCompanion(int npcObjectId)
{
final BotState state = _states.get(npcObjectId);
return (state != null) && (state.companionPlayerId != 0) && (System.currentTimeMillis() < state.companionUntil);
}
private static int botIdOf(Npc npc)
{
return FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
}
private static String personalityOf(Npc npc)
{
return npc.getTemplate().getParameters().getString("fpcPersonality", "neutral");
}
public static FakePlayerAiTaskManager getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerAiTaskManager INSTANCE = new FakePlayerAiTaskManager();
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* What a bot is doing right now and what recently happened to it - the facts
* that feed the seam rule lottery (plan=trade/raid/migrate, event=ganked/
* levelup/raid_finished/opened_store). Updated by one-line hooks at the
* places that already emit the corresponding chat events. In-memory only:
* after a restart bots simply have an empty short-term memory, which is fine.
* @author Mobius SPP
*/
public class FakePlayerBotContext
{
private static final long EVENT_TTL = 10 * 60 * 1000;
private static class Context
{
volatile String plan = "farm";
volatile long planUntil;
volatile String event = "";
volatile long eventUntil;
volatile String boss = "";
}
private static final Map<Integer, Context> CONTEXTS = new ConcurrentHashMap<>();
private FakePlayerBotContext()
{
}
private static Context contextOf(int botId)
{
return CONTEXTS.computeIfAbsent(botId, k -> new Context());
}
/**
* @param botId bot character id.
* @param plan farm / trade / raid / migrate / pvp / idle.
* @param durationMs how long the plan holds (afterwards it decays to "farm").
*/
public static void setPlan(int botId, String plan, long durationMs)
{
if (botId <= 0)
{
return;
}
final Context context = contextOf(botId);
context.plan = plan;
context.planUntil = System.currentTimeMillis() + durationMs;
}
public static void setEvent(int botId, String event)
{
if (botId <= 0)
{
return;
}
final Context context = contextOf(botId);
context.event = event;
context.eventUntil = System.currentTimeMillis() + EVENT_TTL;
}
/**
* @param botId bot character id.
* @param boss raid boss name for the {boss} slot while the raid plan holds.
*/
public static void setRaid(int botId, String boss, long durationMs)
{
if (botId <= 0)
{
return;
}
setPlan(botId, "raid", durationMs);
contextOf(botId).boss = boss;
}
/**
* @param botId bot character id.
* @return current facts: plan and (when fresh) event.
*/
public static Map<String, String> facts(int botId)
{
final Map<String, String> facts = new HashMap<>(2);
final Context context = (botId > 0) ? CONTEXTS.get(botId) : null;
if (context == null)
{
return facts;
}
final long now = System.currentTimeMillis();
facts.put("plan", (now < context.planUntil) ? context.plan : "farm");
if ((now < context.eventUntil) && !context.event.isEmpty())
{
facts.put("event", context.event);
}
return facts;
}
/**
* @param botId bot character id.
* @return grounding slots that follow from the context ({boss} during a raid).
*/
public static Map<String, String> slots(int botId)
{
final Map<String, String> slots = new HashMap<>(1);
final Context context = (botId > 0) ? CONTEXTS.get(botId) : null;
if ((context != null) && (System.currentTimeMillis() < context.planUntil) && "raid".equals(context.plan) && !context.boss.isEmpty())
{
slots.put("boss", context.boss);
}
return slots;
}
}
@@ -0,0 +1,348 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.util.List;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.WorldObject;
import org.l2jmobius.gameserver.entity.actor.Creature;
import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.actor.instance.Monster;
import org.l2jmobius.gameserver.entity.groups.Party;
import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType;
import org.l2jmobius.gameserver.managers.FakePlayerCombatData.BotSkill;
import org.l2jmobius.gameserver.managers.FakePlayerCombatData.Profile;
import org.l2jmobius.gameserver.mechanics.skill.Skill;
/**
* Game sense for headless bots: what to do right now.
* Priority order (first match wins), inspired by how real farming bots are built:
* survival, upkeep, party role, combat, loot, farming.
* @author Mobius SPP
*/
public class FakePlayerBrain
{
private static final int HEAL_POTION = 1061; // Healing Potion
private static final int MELEE_RANGE = 80;
private static final int BOW_RANGE = 500;
/**
* Runtime combat memory of a bot.
*/
public static class CombatState
{
public long nextSelfBuff = 0;
public long restingUntil = 0;
public long lastPotion = 0;
public long fleeingUntil = 0;
}
/**
* Tries to act. Returns true when the brain took control this tick.
* @param bot the headless bot.
* @param state its combat state.
* @param aggressive true when the bot may pick fights on its own.
* @return true when an action was taken.
*/
public static boolean act(Player bot, CombatState state, boolean aggressive)
{
final long now = System.currentTimeMillis();
final Profile profile = FakePlayerCombatData.getInstance().getProfile(bot.getPlayerClass().getId());
final int hpPercent = (int) ((bot.getCurrentHp() * 100) / Math.max(1, bot.getMaxHp()));
final int mpPercent = (int) ((bot.getCurrentMp() * 100) / Math.max(1, bot.getMaxMp()));
// 1. Survival: heal, drink, run away.
if (hpPercent < 55)
{
if (castHeal(bot, bot, profile, hpPercent))
{
return true;
}
if ((hpPercent < 40) && ((now - state.lastPotion) > 15000) && drinkPotion(bot))
{
state.lastPotion = now;
return true;
}
if (hpPercent < 25)
{
final Creature threat = nearestThreat(bot);
if (threat != null)
{
state.fleeingUntil = now + 12000;
flee(bot, threat);
return true;
}
}
}
if (now < state.fleeingUntil)
{
return true; // Keep running.
}
// 2. Upkeep: rest when safe and low, refresh self buffs.
if (state.restingUntil > now)
{
if ((hpPercent > 85) && (mpPercent > 75))
{
state.restingUntil = 0;
bot.standUp();
}
return true;
}
if (!bot.isInCombat() && (nearestThreat(bot) == null) && ((hpPercent < 45) || (mpPercent < 35)))
{
state.restingUntil = now + 120000;
bot.sitDown();
return true;
}
if ((now > state.nextSelfBuff) && !bot.isInCombat())
{
state.nextSelfBuff = now + 180000;
if (castSelfBuffs(bot, profile))
{
return true;
}
}
// 3. Party role: healers keep the party alive.
final Party party = bot.getParty();
if ((party != null) && "healer".equals(profile.role))
{
Player wounded = null;
int worst = 100;
for (Player member : party.getMembers())
{
final int memberHp = (int) ((member.getCurrentHp() * 100) / Math.max(1, member.getMaxHp()));
if (!member.isDead() && (memberHp < worst) && (bot.calculateDistance2D(member) < 900))
{
worst = memberHp;
wounded = member;
}
}
if ((wounded != null) && (worst < 75) && castHeal(bot, wounded, profile, worst))
{
return true;
}
}
// 4. Combat: use skills by condition, keep proper distance.
final WorldObject target = bot.getTarget();
if ((target instanceof Creature victim) && !victim.isDead() && bot.isInCombat())
{
final int targetHp = (int) ((victim.getCurrentHp() * 100) / Math.max(1, victim.getMaxHp()));
if (castOffensive(bot, victim, profile, targetHp, mpPercent))
{
return true;
}
keepDistance(bot, victim, profile);
return true;
}
// 5. Farming: pick a sensible monster.
if (aggressive && !bot.isInCombat())
{
final Monster prey = pickPrey(bot);
if (prey != null)
{
bot.setTarget(prey);
bot.getAI().setIntentionAttack(prey);
return true;
}
}
return false;
}
/**
* Monster worth attacking: close to our level, reachable, not a raid.
* @param bot the bot.
* @return the prey or null.
*/
private static Monster pickPrey(Player bot)
{
final int level = bot.getLevel();
return World.getNearestVisibleObjectInRange(bot, Monster.class, 1100, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() //
&& (Math.abs(monster.getLevel() - level) <= 8) // не бьём слишком сильных и не тратим время на слабых
&& (Math.abs(monster.getZ() - bot.getZ()) < 200) // не пытаемся достать тех, кто выше/ниже
&& ((monster.getTarget() == null) || (monster.getTarget() == bot))); // не воруем чужую цель
}
private static Creature nearestThreat(Player bot)
{
return World.getNearestVisibleObjectInRange(bot, Creature.class, 600, creature -> !creature.isDead() && (creature.getTarget() == bot) && (creature.isMonster() || creature.isPlayer()));
}
private static void flee(Player bot, Creature threat)
{
final int dx = bot.getX() - threat.getX();
final int dy = bot.getY() - threat.getY();
final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy)));
final int targetX = bot.getX() + (int) ((dx / length) * 900);
final int targetY = bot.getY() + (int) ((dy / length) * 900);
bot.setRunning();
bot.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(targetX, targetY, bot.getZ()));
}
private static boolean drinkPotion(Player bot)
{
if (bot.getInventory().getItemByItemId(HEAL_POTION) == null)
{
bot.addItem(ItemProcessType.NONE, HEAL_POTION, 20, bot, false);
}
final org.l2jmobius.gameserver.entity.item.instance.Item potion = bot.getInventory().getItemByItemId(HEAL_POTION);
if (potion == null)
{
return false;
}
bot.useEquippableItem(potion, false);
return true;
}
private static boolean castSelfBuffs(Player bot, Profile profile)
{
for (BotSkill entry : profile.self)
{
final Skill skill = bot.getKnownSkill(entry.id);
if ((skill == null) || bot.isSkillDisabled(skill) || (bot.getCurrentMp() < skill.getMpConsume()))
{
continue;
}
if (bot.isAffectedBySkill(entry.id))
{
continue;
}
bot.setTarget(bot);
return bot.useMagic(skill, true, false);
}
return false;
}
private static boolean castHeal(Player bot, Player patient, Profile profile, int patientHp)
{
for (BotSkill entry : profile.heal)
{
if (patientHp > entry.minHp)
{
continue;
}
final Skill skill = bot.getKnownSkill(entry.id);
if ((skill == null) || bot.isSkillDisabled(skill) || (bot.getCurrentMp() < skill.getMpConsume()))
{
continue;
}
bot.setTarget(patient);
return bot.useMagic(skill, true, false);
}
return false;
}
private static boolean castOffensive(Player bot, Creature victim, Profile profile, int targetHp, int mpPercent)
{
for (BotSkill entry : profile.offensive)
{
if ((targetHp > entry.maxTargetHp) || (mpPercent < entry.minMp))
{
continue;
}
final Skill skill = bot.getKnownSkill(entry.id);
if ((skill == null) || bot.isSkillDisabled(skill) || (bot.getCurrentMp() < skill.getMpConsume()))
{
continue;
}
if ((skill.getCastRange() > 0) && (bot.calculateDistance2D(victim) > skill.getCastRange()))
{
continue;
}
bot.setTarget(victim);
if (bot.useMagic(skill, true, false))
{
return true;
}
}
return false;
}
/**
* Archers and mages should not stand in melee; fighters should close in.
* @param bot the bot.
* @param victim its target.
* @param profile combat profile.
*/
private static void keepDistance(Player bot, Creature victim, Profile profile)
{
final boolean ranged = "archer".equals(profile.role) || "mage".equals(profile.role) || "healer".equals(profile.role);
final double distance = bot.calculateDistance2D(victim);
if (ranged)
{
if (distance < 250) // отходим, чтобы стрелять
{
final int dx = bot.getX() - victim.getX();
final int dy = bot.getY() - victim.getY();
final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy)));
bot.setRunning();
bot.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(bot.getX() + (int) ((dx / length) * 400), bot.getY() + (int) ((dy / length) * 400), bot.getZ()));
return;
}
if (distance > BOW_RANGE)
{
bot.getAI().setIntentionAttack(victim);
}
return;
}
if (distance > MELEE_RANGE)
{
bot.getAI().setIntentionAttack(victim);
}
}
/**
* @param bot the bot.
* @return short human readable description of what the brain would do now.
*/
public static String describe(Player bot)
{
final Profile profile = FakePlayerCombatData.getInstance().getProfile(bot.getPlayerClass().getId());
final int hp = (int) ((bot.getCurrentHp() * 100) / Math.max(1, bot.getMaxHp()));
final int mp = (int) ((bot.getCurrentMp() * 100) / Math.max(1, bot.getMaxMp()));
final StringBuilder sb = new StringBuilder();
sb.append(bot.getName()).append(" lvl").append(bot.getLevel()).append(' ').append(bot.getPlayerClass()).append('\n');
sb.append(" роль: ").append(profile.role).append(" | HP ").append(hp).append("% MP ").append(mp).append("%\n");
sb.append(" скиллы боя: ");
for (BotSkill entry : profile.offensive)
{
final Skill skill = bot.getKnownSkill(entry.id);
sb.append(entry.id).append(skill != null ? "+" : "-").append(' ');
}
sb.append("\n лечение: ");
for (BotSkill entry : profile.heal)
{
final Skill skill = bot.getKnownSkill(entry.id);
sb.append(entry.id).append(skill != null ? "+" : "-").append(' ');
}
sb.append("\n (+ есть у бота, - не выучен)");
return sb.toString();
}
private FakePlayerBrain()
{
}
}
@@ -0,0 +1,792 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.io.File;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
import org.w3c.dom.Document;
import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.util.StatSet;
/**
* Chat "seam" engine for fake players (Seams 2.0). One seam = one situation;
* a reply is chosen by a weighted rule lottery over context facts, assembled
* from morph templates ("[optional]", "(a|b)", "#pool#" recursion, trait
* variants), grounded through {slot} substitution, personalized by the bot's
* idiolect and guarded by a template-level anti-repeat with a global cooldown.
* Prototyped and tested in the sandbox artifact, ported 1:1.
* @author Mobius SPP
*/
public class FakePlayerChatLines implements IXmlReader
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerChatLines.class.getName());
private static final int TEMPLATE_COOLDOWN_MESSAGES = 25;
private static final int SAID_MEMORY = 12;
private static final int MORPH_DEPTH_LIMIT = 8;
private static final String[] TRAITS =
{
"mat",
"eng",
"old"
};
/** One line of a seam or pool: text plus an optional trait gate. */
private static class LineDef
{
final String text;
final String trait;
LineDef(String text, String trait)
{
this.text = text;
this.trait = ((trait == null) || trait.isEmpty()) ? null : trait;
}
}
/** A weighted context rule inside a seam. */
private static class Rule
{
final List<String[]> need = new ArrayList<>(); // Each: {fact, value} - all must hold.
final List<Object[]> like = new ArrayList<>(); // Each: {fact, value, weight} - matched ones add weight.
final List<LineDef> lines = new ArrayList<>();
String label = "";
}
private static class Seam
{
final List<LineDef> base = new ArrayList<>();
final List<Rule> rules = new ArrayList<>();
}
private final Map<String, Seam> _seams = new HashMap<>();
private final Map<String, List<LineDef>> _pools = new HashMap<>();
// Anti-repeat state: per bot said memory (template + surface), global template cooldown.
private final Map<Integer, ArrayDeque<String>> _said = new ConcurrentHashMap<>();
private final Map<String, Long> _lastTemplateUse = new ConcurrentHashMap<>();
private final AtomicLong _messageIndex = new AtomicLong();
protected FakePlayerChatLines()
{
load();
}
@Override
public void load()
{
_seams.clear();
_pools.clear();
parseDatapackFile("data/FakePlayerChatLines.xml");
parseDatapackFile("data/FakePlayerPools.xml");
int lineCount = 0;
for (Seam seam : _seams.values())
{
lineCount += seam.base.size();
for (Rule rule : seam.rules)
{
lineCount += rule.lines.size();
}
}
int poolLines = 0;
for (List<LineDef> lines : _pools.values())
{
poolLines += lines.size();
}
LOGGER.info(getClass().getSimpleName() + ": Loaded " + lineCount + " lines in " + _seams.size() + " seams, " + poolLines + " lines in " + _pools.size() + " pools.");
}
@Override
public void parseDocument(Document document, File file)
{
forEach(document, "pools", listNode -> forEach(listNode, "pool", poolNode ->
{
final StatSet set = new StatSet(parseAttributes(poolNode));
final List<LineDef> lines = _pools.computeIfAbsent(set.getString("key"), k -> new ArrayList<>());
forEach(poolNode, "line", lineNode ->
{
final StatSet lineSet = new StatSet(parseAttributes(lineNode));
lines.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
});
}));
forEach(document, "lines", listNode -> forEach(listNode, "seam", seamNode ->
{
final StatSet set = new StatSet(parseAttributes(seamNode));
final Seam seam = _seams.computeIfAbsent(set.getString("key"), k -> new Seam());
forEach(seamNode, "line", lineNode ->
{
final StatSet lineSet = new StatSet(parseAttributes(lineNode));
seam.base.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
});
forEach(seamNode, "rule", ruleNode ->
{
final StatSet ruleSet = new StatSet(parseAttributes(ruleNode));
final Rule rule = new Rule();
final String needString = ruleSet.getString("need", "");
final String likeString = ruleSet.getString("like", "");
rule.label = (needString.isEmpty() ? "" : ("need:" + needString + " ")) + (likeString.isEmpty() ? "" : ("like:" + likeString));
if (!needString.isEmpty())
{
for (String part : needString.split(","))
{
final String[] kv = part.trim().split("=");
if (kv.length == 2)
{
rule.need.add(kv);
}
}
}
if (!likeString.isEmpty())
{
for (String part : likeString.split(","))
{
final String[] cw = part.trim().split(":");
final String[] kv = cw[0].split("=");
if (kv.length == 2)
{
rule.like.add(new Object[]
{
kv[0],
kv[1],
(cw.length > 1) ? Integer.parseInt(cw[1]) : 1
});
}
}
}
forEach(ruleNode, "line", lineNode ->
{
final StatSet lineSet = new StatSet(parseAttributes(lineNode));
rule.lines.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
});
seam.rules.add(rule);
});
}));
}
// ======================== Public API ========================
/**
* Full pipeline: rule lottery over facts, morph, slots, idiolect style, anti-repeat.
* @param botId bot character id (0 = generic style, no repeat tracking).
* @param key seam key.
* @param facts context facts (personality, asker, plan, event...; may be null).
* @param slots slot values; missing slots drop the line (may be null).
* @param optional true for unsolicited background chatter: a repeat is never
* accepted and null is returned instead (silence beats repetition).
* @param mirror incoming message analysis for style mirroring (may be null).
* @return the styled line, or null.
*/
public String speak(int botId, String key, Map<String, String> facts, Map<String, String> slots, boolean optional, FakePlayerIdiolect.Mirror mirror)
{
return speak(botId, key, facts, slots, optional, mirror, false);
}
/**
* Private-channel variant (whisper): the global template cooldown models
* "it was just said in the air nearby", which whispers are not part of -
* so it is neither checked nor written. Per-bot anti-repeat still applies.
*/
public String speakPrivate(int botId, String key, Map<String, String> facts, Map<String, String> slots, FakePlayerIdiolect.Mirror mirror)
{
return speak(botId, key, facts, slots, false, mirror, true);
}
private String speak(int botId, String key, Map<String, String> facts, Map<String, String> slots, boolean optional, FakePlayerIdiolect.Mirror mirror, boolean privateReply)
{
final Seam seam = _seams.get(key);
if (seam == null)
{
return null;
}
final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
final Map<String, String> effectiveFacts;
if (botId > 0)
{
// The bot's own situation (plan, recent event) feeds the rule lottery automatically.
effectiveFacts = FakePlayerBotContext.facts(botId);
if (facts != null)
{
effectiveFacts.putAll(facts);
}
}
else
{
effectiveFacts = (facts == null) ? new HashMap<>(1) : facts;
}
effectiveFacts.putIfAbsent("daytime", daytimeNow());
final Map<String, String> effectiveSlots = withDefaults(slots, idiolect, effectiveFacts);
List<LineDef> group = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
if (group.isEmpty())
{
group = filterTraits(seam.base, idiolect);
if (group.isEmpty())
{
return null;
}
}
double chattiness = idiolect.chattiness;
if ((mirror != null) && mirror.shortMessage)
{
chattiness *= 0.4;
}
for (int attempt = 0; attempt < 14; attempt++)
{
final LineDef raw = group.get(Rnd.get(group.size()));
final String morphed = morph(raw.text, idiolect, chattiness, 0);
final String filled = fillSlots(morphed, effectiveSlots);
if (filled == null)
{
if (attempt == 6)
{
final List<LineDef> reroll = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
if (!reroll.isEmpty())
{
group = reroll;
}
}
continue;
}
if (filled.matches("[\\d\\s]+"))
{
continue; // Degenerate line: all words were optional, only a number left.
}
// Pool-reference templates are identified by the expanded string, plain ones by the raw template.
final String template = key + "|" + (raw.text.indexOf('#') >= 0 ? morphed : raw.text);
final String repeat = isRepeat(botId, template, filled, privateReply);
if (repeat != null)
{
if (optional || (attempt < 10))
{
if ((attempt == 4) || (attempt == 8))
{
final List<LineDef> reroll = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
if (!reroll.isEmpty())
{
group = reroll;
}
}
continue;
}
}
remember(botId, template, filled, privateReply);
return idiolect.stylePass(filled, mirror);
}
return null;
}
// --- Compatibility wrappers (existing call sites keep working unchanged) ---
public String pick(String key, Map<String, String> slots)
{
return speak(0, key, null, slots, false, null);
}
public String pick(String key, String slotName, String slotValue)
{
return speak(0, key, null, singleSlot(slotName, slotValue), false, null);
}
public String pickFor(int botId, String key, Map<String, String> slots)
{
return speak(botId, key, null, slots, false, null);
}
public String pickFor(int botId, String key, String slotName, String slotValue)
{
return speak(botId, key, null, singleSlot(slotName, slotValue), false, null);
}
public String compose(int botId, String key, String personality, Map<String, String> slots)
{
final Map<String, String> facts = new HashMap<>(1);
facts.put("personality", (personality == null) || personality.isEmpty() ? "neutral" : personality);
return speak(botId, key, facts, slots, false, null);
}
public String compose(int botId, String key, String personality, String slotName, String slotValue)
{
return compose(botId, key, personality, singleSlot(slotName, slotValue));
}
/**
* @return seam key - base line texts view (dashboard).
*/
public Map<String, List<String>> seams()
{
final Map<String, List<String>> view = new LinkedHashMap<>();
for (Map.Entry<String, Seam> entry : _seams.entrySet())
{
final List<String> texts = new ArrayList<>(entry.getValue().base.size());
for (LineDef line : entry.getValue().base)
{
texts.add(line.text);
}
view.put(entry.getKey(), texts);
}
return Collections.unmodifiableMap(view);
}
// ======================== Rule lottery ========================
private List<LineDef> chooseGroup(Seam seam, Map<String, String> facts)
{
final List<Object[]> groups = new ArrayList<>(); // {score, lines}
if (!seam.base.isEmpty())
{
groups.add(new Object[]
{
1,
seam.base
});
}
for (Rule rule : seam.rules)
{
boolean needFailed = false;
for (String[] condition : rule.need)
{
if (!condition[1].equals(facts.get(condition[0])))
{
needFailed = true;
break;
}
}
if (needFailed)
{
continue;
}
int likeHits = 0;
int score = rule.need.isEmpty() ? 1 : 2;
for (Object[] condition : rule.like)
{
if (condition[1].equals(facts.get(condition[0])))
{
likeHits++;
score += (Integer) condition[2];
}
}
if (!rule.like.isEmpty() && rule.need.isEmpty() && (likeHits == 0))
{
continue; // A like-only rule needs at least one matching fact.
}
if (!rule.need.isEmpty() && rule.like.isEmpty())
{
score = 100; // A pure need rule overrides the base.
}
groups.add(new Object[]
{
score,
rule.lines
});
}
if (groups.isEmpty())
{
return seam.base;
}
// A matched pure-need rule is absolute: the lottery runs only among such rules.
final List<Object[]> needOnly = new ArrayList<>(1);
for (Object[] group : groups)
{
if (((Integer) group[0]) >= 100)
{
needOnly.add(group);
}
}
final List<Object[]> pool = needOnly.isEmpty() ? groups : needOnly;
int total = 0;
for (Object[] group : pool)
{
total += (Integer) group[0];
}
int roll = Rnd.get(total);
for (Object[] group : pool)
{
roll -= (Integer) group[0];
if (roll < 0)
{
@SuppressWarnings("unchecked")
final List<LineDef> lines = (List<LineDef>) group[1];
return lines;
}
}
@SuppressWarnings("unchecked")
final List<LineDef> lines = (List<LineDef>) pool.get(pool.size() - 1)[1];
return lines;
}
private static List<LineDef> filterTraits(List<LineDef> lines, FakePlayerIdiolect idiolect)
{
final List<LineDef> result = new ArrayList<>(lines.size());
for (LineDef line : lines)
{
if ((line.trait == null) || idiolect.hasTrait(line.trait))
{
result.add(line);
}
}
return result;
}
// ======================== Morph engine ========================
/**
* Expands a morph template: "[optional]" / "[30:optional]" chunks,
* "(a|b|mat:c)" alternations and "#pool#" recursion.
*/
private String morph(String template, FakePlayerIdiolect idiolect, double chattiness, int depth)
{
if (depth > MORPH_DEPTH_LIMIT)
{
return "";
}
final StringBuilder out = new StringBuilder(template.length());
int i = 0;
while (i < template.length())
{
final char ch = template.charAt(i);
if (ch == '[')
{
final int close = findClose(template, i + 1, '[', ']');
if (close < 0)
{
out.append(ch);
i++;
continue;
}
String inner = template.substring(i + 1, close);
int chance = 50;
final int colon = inner.indexOf(':');
if ((colon > 0) && inner.substring(0, colon).chars().allMatch(Character::isDigit))
{
chance = Integer.parseInt(inner.substring(0, colon));
inner = inner.substring(colon + 1);
}
final double probability = Math.min(0.95, (chance / 100.0) * chattiness);
if (Rnd.get(1000) < (probability * 1000))
{
out.append(morph(inner, idiolect, chattiness, depth + 1));
}
i = close + 1;
}
else if (ch == '(')
{
final int close = findClose(template, i + 1, '(', ')');
if (close < 0)
{
out.append(ch);
i++;
continue;
}
final List<String> alternatives = new ArrayList<>();
for (String alternative : splitTop(template.substring(i + 1, close)))
{
final int prefixEnd = alternative.indexOf(':');
if (prefixEnd > 0)
{
final String prefix = alternative.substring(0, prefixEnd);
if (isTrait(prefix))
{
if (idiolect.hasTrait(prefix))
{
alternatives.add(alternative.substring(prefixEnd + 1));
}
continue;
}
}
alternatives.add(alternative);
}
if (!alternatives.isEmpty())
{
out.append(morph(alternatives.get(Rnd.get(alternatives.size())), idiolect, chattiness, depth + 1));
}
i = close + 1;
}
else if (ch == '#')
{
final int close = template.indexOf('#', i + 1);
if (close < 0)
{
out.append(ch);
i++;
continue;
}
final String poolKey = template.substring(i + 1, close);
final String expanded = expandPool(poolKey, idiolect, chattiness, depth);
if (expanded != null)
{
out.append(expanded);
}
i = close + 1;
}
else
{
out.append(ch);
i++;
}
}
return out.toString();
}
private String expandPool(String poolKey, FakePlayerIdiolect idiolect, double chattiness, int depth)
{
// A pool with the personality suffix would go here; personality is a fact,
// kept simple: direct key then vocabulary partition + trait filter.
final List<LineDef> pool = _pools.get(poolKey);
if ((pool == null) || pool.isEmpty())
{
return null;
}
final List<LineDef> owned = new ArrayList<>(pool.size());
for (int index = 0; index < pool.size(); index++)
{
final LineDef line = pool.get(index);
if ((line.trait != null) && !idiolect.hasTrait(line.trait))
{
continue;
}
if (!idiolect.ownsPoolLine(poolKey, pool.size(), index))
{
continue;
}
owned.add(line);
}
if (owned.isEmpty())
{
return null;
}
return morph(owned.get(Rnd.get(owned.size())).text, idiolect, chattiness, depth + 1);
}
private static boolean isTrait(String prefix)
{
for (String trait : TRAITS)
{
if (trait.equals(prefix))
{
return true;
}
}
return false;
}
private static int findClose(String text, int from, char open, char close)
{
int depth = 1;
for (int i = from; i < text.length(); i++)
{
final char ch = text.charAt(i);
if (ch == open)
{
depth++;
}
else if (ch == close)
{
depth--;
if (depth == 0)
{
return i;
}
}
}
return -1;
}
private static List<String> splitTop(String text)
{
final List<String> parts = new ArrayList<>();
int depth = 0;
final StringBuilder current = new StringBuilder();
for (int i = 0; i < text.length(); i++)
{
final char ch = text.charAt(i);
if ((ch == '(') || (ch == '['))
{
depth++;
}
else if ((ch == ')') || (ch == ']'))
{
depth--;
}
if ((ch == '|') && (depth == 0))
{
parts.add(current.toString());
current.setLength(0);
}
else
{
current.append(ch);
}
}
parts.add(current.toString());
return parts;
}
// ======================== Slots ========================
private static Map<String, String> withDefaults(Map<String, String> slots, FakePlayerIdiolect idiolect, Map<String, String> facts)
{
final Map<String, String> result = (slots == null) ? new HashMap<>(4) : new HashMap<>(slots);
// The bot's own shop assortment grounds trade lines when the caller has nothing better.
if (idiolect.charId > 0)
{
result.putIfAbsent("item", idiolect.storeItem);
result.putIfAbsent("price", idiolect.storePrice);
for (Map.Entry<String, String> slot : FakePlayerBotContext.slots(idiolect.charId).entrySet())
{
result.putIfAbsent(slot.getKey(), slot.getValue());
}
}
result.putIfAbsent("daytime", facts.getOrDefault("daytime", daytimeNow()));
// The idiolect decides how the bot addresses people.
final String player = result.get("player");
if (player != null)
{
result.put("player", idiolect.addressForm(player));
}
final String target = result.get("target");
if (target != null)
{
result.put("target", idiolect.addressForm(target));
}
return result;
}
public static String daytimeNow()
{
final int hour = java.time.LocalTime.now().getHour();
if (hour < 6)
{
return "ночь";
}
if (hour < 12)
{
return "утро";
}
if (hour < 18)
{
return "день";
}
return "вечер";
}
private static String fillSlots(String line, Map<String, String> slots)
{
String result = line;
for (Map.Entry<String, String> slot : slots.entrySet())
{
result = result.replace("{" + slot.getKey() + "}", slot.getValue());
}
return (result.indexOf('{') >= 0) ? null : result;
}
private static Map<String, String> singleSlot(String name, String value)
{
final Map<String, String> slots = new HashMap<>(1);
if ((name != null) && (value != null))
{
slots.put(name, value);
}
return slots;
}
// ======================== Anti-repeat ========================
private String isRepeat(int botId, String template, String surface, boolean privateReply)
{
if (botId > 0)
{
final ArrayDeque<String> said = _said.get(botId);
if (said != null)
{
synchronized (said)
{
if (said.contains(template) || said.contains(surface))
{
return "self";
}
}
}
}
if (!privateReply)
{
final Long lastUse = _lastTemplateUse.get(template);
if ((lastUse != null) && ((_messageIndex.get() - lastUse) < TEMPLATE_COOLDOWN_MESSAGES))
{
return "cooldown";
}
}
return null;
}
private void remember(int botId, String template, String surface, boolean privateReply)
{
if (botId > 0)
{
final ArrayDeque<String> said = _said.computeIfAbsent(botId, k -> new ArrayDeque<>());
synchronized (said)
{
said.addLast(template);
said.addLast(surface);
while (said.size() > (SAID_MEMORY * 2))
{
said.removeFirst();
}
}
}
if (privateReply)
{
return;
}
_lastTemplateUse.put(template, _messageIndex.getAndIncrement());
if (_lastTemplateUse.size() > 4000)
{
// Rare cleanup: drop entries far outside the cooldown window.
final long now = _messageIndex.get();
_lastTemplateUse.entrySet().removeIf(entry -> (now - entry.getValue()) > (TEMPLATE_COOLDOWN_MESSAGES * 4));
}
}
public static FakePlayerChatLines getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerChatLines INSTANCE = new FakePlayerChatLines();
}
}
@@ -22,7 +22,9 @@ package org.l2jmobius.gameserver.managers;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.w3c.dom.Document; import org.w3c.dom.Document;
@@ -34,7 +36,9 @@ import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.data.SpawnTable; import org.l2jmobius.gameserver.data.SpawnTable;
import org.l2jmobius.gameserver.data.holders.FakePlayerChatHolder; import org.l2jmobius.gameserver.data.holders.FakePlayerChatHolder;
import org.l2jmobius.gameserver.data.xml.FakePlayerData; import org.l2jmobius.gameserver.data.xml.FakePlayerData;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.actor.Npc; import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.entity.actor.holders.npc.FakePlayerHolder;
import org.l2jmobius.gameserver.entity.actor.Player; import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.spawns.Spawn; import org.l2jmobius.gameserver.entity.spawns.Spawn;
import org.l2jmobius.gameserver.geoengine.GeoEngine; import org.l2jmobius.gameserver.geoengine.GeoEngine;
@@ -52,6 +56,7 @@ public class FakePlayerChatManager implements IXmlReader
private static final List<FakePlayerChatHolder> MESSAGES = new ArrayList<>(); private static final List<FakePlayerChatHolder> MESSAGES = new ArrayList<>();
private static final int MIN_DELAY = 5000; private static final int MIN_DELAY = 5000;
private static final int MAX_DELAY = 15000; private static final int MAX_DELAY = 15000;
private static final int LOCAL_CHAT_RADIUS = 1250;
protected FakePlayerChatManager() protected FakePlayerChatManager()
{ {
@@ -69,6 +74,10 @@ public class FakePlayerChatManager implements IXmlReader
MESSAGES.clear(); MESSAGES.clear();
parseDatapackFile("data/FakePlayerChatData.xml"); parseDatapackFile("data/FakePlayerChatData.xml");
LOGGER.info(getClass().getSimpleName() + ": Loaded " + MESSAGES.size() + " chat templates."); LOGGER.info(getClass().getSimpleName() + ": Loaded " + MESSAGES.size() + " chat templates.");
if (FakePlayersConfig.FAKE_PLAYER_LLM_CHAT)
{
LOGGER.info(getClass().getSimpleName() + ": LLM chat enabled (" + FakePlayersConfig.FAKE_PLAYER_LLM_MODEL + " at " + FakePlayersConfig.FAKE_PLAYER_LLM_URL + ").");
}
} }
} }
} }
@@ -103,6 +112,50 @@ public class FakePlayerChatManager implements IXmlReader
return; return;
} }
final Npc fpcNpc = findNpc(fpcName);
final Understanding understanding = (fpcNpc != null) ? understand(player, fpcNpc, message) : null;
if ((understanding != null) && understanding.silent)
{
return; // The bot ignores the stranger, like real players sometimes do.
}
if ((fpcNpc != null) && FakePlayerLlmService.getInstance().isEnabled())
{
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(fpcNpc.getObjectId());
final String hint = ((understanding != null) && !understanding.llmHint.isEmpty()) ? " (суть его сообщения: " + understanding.llmHint + ")" : "";
FakePlayerLlmService.getInstance().generate(buildPersonaFor(fpcNpc, player.getName()), "Игрок " + player.getName() + " написал тебе в личные сообщения: \"" + message + "\"." + hint, reply ->
{
sendChat(player, fpcName, reply);
FakePlayerMemoryManager.getInstance().addNote(botId, player.getName(), "он писал: " + message + " / я ответил: " + reply);
}, () ->
{
if ((understanding != null) && (understanding.responseLine != null))
{
sendChat(player, fpcName, understanding.responseLine);
}
else
{
managePatternResponce(player, fpcName, message);
}
});
return;
}
if ((understanding != null) && (understanding.responseLine != null))
{
sendChat(player, fpcName, understanding.responseLine);
return;
}
managePatternResponce(player, fpcName, message);
}
private void managePatternResponce(Player player, String fpcName, String message)
{
if (player == null)
{
return;
}
final String text = message.toLowerCase(); final String text = message.toLowerCase();
// tricky question // tricky question
@@ -134,6 +187,162 @@ public class FakePlayerChatManager implements IXmlReader
} }
} }
final String answer = findPatternAnswer(fpcName, message);
if (answer != null)
{
sendChat(player, fpcName, answer);
return;
}
// No pattern matched: generic whisper answer seam.
final String fallback = FakePlayerChatLines.getInstance().pick("whisper_default", null);
if (fallback != null)
{
sendChat(player, fpcName, fallback);
}
}
/**
* Reaction of a fake player to a general chat message said nearby.
* @param player the player who spoke.
* @param npc the fake player npc that will answer.
* @param message the said text.
*/
public void manageLocalChat(Player player, Npc npc, String message)
{
if ((player == null) || (npc == null))
{
return;
}
final Understanding understanding = understand(player, npc, message);
if ((understanding != null) && understanding.silent)
{
return;
}
final Runnable patternFallback = () ->
{
if ((understanding != null) && (understanding.responseLine != null))
{
broadcastToNearby(npc, understanding.responseLine);
return;
}
final String answer = findPatternAnswer(npc.getName(), message);
if (answer != null)
{
broadcastToNearby(npc, answer);
}
};
if (FakePlayerLlmService.getInstance().isEnabled())
{
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
final String hint = ((understanding != null) && !understanding.llmHint.isEmpty()) ? " (суть: " + understanding.llmHint + ")" : "";
ThreadPool.schedule(() -> FakePlayerLlmService.getInstance().generate(buildPersonaFor(npc, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой в общий чат: \"" + message + "\"." + hint, reply ->
{
broadcastToNearby(npc, reply);
FakePlayerMemoryManager.getInstance().addNote(botId, player.getName(), "он сказал: " + message + " / я: " + reply);
}, patternFallback), Rnd.get(4000, 10000));
}
else
{
ThreadPool.schedule(patternFallback, Rnd.get(3000, 8000));
}
}
/**
* Makes a fake player comment an event in local chat. LLM only, silent when unavailable.
* @param npc the fake player npc.
* @param instruction event description for the LLM.
*/
public void manageEventChat(Npc npc, String instruction)
{
manageEventChat(npc, instruction, (String) null);
}
/**
* Makes a fake player comment an event in local chat.
* Uses the LLM when available, otherwise falls back to one of the given template lines.
* @param npc the fake player npc.
* @param instruction event description for the LLM.
* @param fallbackLines template lines used when the LLM is unavailable (may be null).
*/
public void manageEventChat(Npc npc, String instruction, String[] fallbackLines)
{
manageEventChat(npc, instruction, ((fallbackLines == null) || (fallbackLines.length == 0)) ? null : fallbackLines[Rnd.get(fallbackLines.length)]);
}
/**
* Event comment with a single prepared fallback line (usually from FakePlayerChatLines).
* @param npc the fake player npc.
* @param instruction LLM instruction.
* @param fallbackLine line to say when the LLM is unavailable (may be null).
*/
public void manageEventChat(Npc npc, String instruction, String fallbackLine)
{
manageEventChat(npc, instruction, fallbackLine, "");
}
/**
* Event comment with a known seam - the seam travels with the line so the
* heard system can classify it without guessing.
* @param npc the fake player npc.
* @param instruction LLM instruction.
* @param fallbackLine line to say when the LLM is unavailable (may be null).
* @param seam the seam the fallback line came from.
*/
public void manageEventChat(Npc npc, String instruction, String fallbackLine, String seam)
{
if ((npc == null) || !FakePlayersConfig.FAKE_PLAYER_CHAT)
{
return;
}
final Runnable fallback = (fallbackLine == null) ? null : () -> broadcastToNearby(npc, fallbackLine, seam);
if (FakePlayerLlmService.getInstance().isEnabled())
{
FakePlayerLlmService.getInstance().generate(buildPersona(npc), instruction, reply -> broadcastToNearby(npc, reply, seam), fallback);
}
else if (fallback != null)
{
// A human needs a moment to type even a battle cry.
ThreadPool.schedule(fallback, Rnd.get(800, 2500));
}
}
/**
* Persona including the bot's memory about a specific player.
* @param npc the bot.
* @param playerName the player it interacts with.
* @return persona prompt.
*/
public String buildPersonaFor(Npc npc, String playerName)
{
final String base = buildPersona(npc);
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
final String memory = FakePlayerMemoryManager.getInstance().describe(botId, playerName);
final String rumors = FakePlayerRumorManager.getInstance().describeFor(botId, playerName);
final StringBuilder sb = new StringBuilder(base);
if (!memory.isEmpty())
{
sb.append(' ').append(memory);
}
if (!rumors.isEmpty())
{
sb.append(' ').append(rumors);
}
return sb.toString();
}
/**
* Finds a template answer for the given text using FakePlayerChatData patterns.
* @param fpcName fake player name (or template name).
* @param message the text said to the bot.
* @return an answer or null when no pattern matches.
*/
public String findPatternAnswer(String fpcName, String message)
{
final String text = message.toLowerCase();
for (FakePlayerChatHolder chatHolder : MESSAGES) for (FakePlayerChatHolder chatHolder : MESSAGES)
{ {
if (!chatHolder.getFpcName().equals(fpcName) && !chatHolder.getFpcName().equals("ALL")) if (!chatHolder.getFpcName().equals(fpcName) && !chatHolder.getFpcName().equals("ALL"))
@@ -147,7 +356,7 @@ public class FakePlayerChatManager implements IXmlReader
{ {
if (text.equals(chatHolder.getSearchText().get(0))) if (text.equals(chatHolder.getSearchText().get(0)))
{ {
sendChat(player, fpcName, chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()))); return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
} }
break; break;
} }
@@ -155,7 +364,7 @@ public class FakePlayerChatManager implements IXmlReader
{ {
if (text.startsWith(chatHolder.getSearchText().get(0))) if (text.startsWith(chatHolder.getSearchText().get(0)))
{ {
sendChat(player, fpcName, chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()))); return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
} }
break; break;
} }
@@ -169,15 +378,391 @@ public class FakePlayerChatManager implements IXmlReader
allFound = false; allFound = false;
} }
} }
if (allFound) if (allFound)
{ {
sendChat(player, fpcName, chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()))); return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
} }
break; break;
} }
} }
} }
return null;
}
public void broadcastToNearby(Npc npc, String message)
{
broadcastToNearby(npc, message, "");
}
public void broadcastToNearby(Npc npc, String message, String seam)
{
if ((npc == null) || npc.isDead() || !npc.isSpawned())
{
return;
}
World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
FakePlayerHeardManager.getInstance().onSpeech(npc, message, seam);
}
/**
* Broadcast without the heard hook - used for pickup replies (depth 1).
* @param npc the bot.
* @param message the line.
*/
/**
* A dead bot's chat line (players type after dying - the corpse is still
* there and the chat works). No isDead guard, delivered while the corpse
* has not decayed yet.
* @param npc the dead bot.
* @param message the line.
* @param seam seam hint for the heard system.
*/
public void broadcastDeathCry(Npc npc, String message, String seam)
{
if ((npc == null) || (message == null) || message.isEmpty())
{
return;
}
World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
FakePlayerHeardManager.getInstance().onSpeech(npc, message, seam);
}
public void broadcastRaw(Npc npc, String message)
{
if ((npc == null) || npc.isDead() || !npc.isSpawned())
{
return;
}
World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
}
/**
* Region-wide shout (call for help and similar).
* @param npc the bot.
* @param message the line.
*/
public void broadcastShout(Npc npc, String message)
{
if ((npc == null) || (message == null) || npc.isDead() || !npc.isSpawned())
{
return;
}
World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.SHOUT, npc.getName(), message), 6000);
}
/**
* Trade channel line (party search, sales).
* @param npc the bot.
* @param message the line.
*/
public void broadcastTrade(Npc npc, String message)
{
if ((npc == null) || (message == null) || npc.isDead() || !npc.isSpawned())
{
return;
}
World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.TRADE, npc.getName(), message), 9000);
}
public String buildPersona(Npc npc)
{
final StringBuilder sb = new StringBuilder(256);
sb.append("Ты — ");
sb.append(npc.getName());
sb.append(", обычный игрок на сервере Lineage 2 Interlude");
final FakePlayerHolder info = npc.getTemplate().getFakePlayerInfo();
if (info != null)
{
sb.append(", класс: ");
sb.append(info.getPlayerClass());
sb.append(", уровень ");
sb.append(npc.getTemplate().getLevel());
}
sb.append(". Сейчас ты фармишь мобов в поле.");
final String extra = npc.getTemplate().getParameters().getString("fpcPersona", "");
if (!extra.isEmpty())
{
sb.append(' ');
sb.append(extra);
}
return sb.toString();
}
/**
* Result of understanding an incoming message.
*/
public static class Understanding
{
public String intentKey;
public String responseLine;
public String llmHint;
public boolean silent; // The bot decided to ignore the message.
}
public static String intentHint(String key)
{
return INTENT_HINTS.getOrDefault(key, "");
}
private static final Map<String, String> INTENT_HINTS = Map.ofEntries(
Map.entry("greeting", "он поздоровался"),
Map.entry("bye", "он прощается"),
Map.entry("insult", "он оскорбил тебя"),
Map.entry("thanks", "он поблагодарил тебя"),
Map.entry("botcheck", "он спрашивает, не бот ли ты - отнекивайся"),
Map.entry("duel", "он вызывает тебя на дуэль"),
Map.entry("ask_help", "он просит помощи"),
Map.entry("ask_party", "он зовет тебя в пати"),
Map.entry("ask_level", "он спрашивает твой уровень"),
Map.entry("ask_price", "он спрашивает про цены или торговлю"),
Map.entry("ask_rumors", "он спрашивает что нового"),
Map.entry("ask_where", "он спрашивает где ты или куда идти"),
Map.entry("yes_ok", "он согласился"),
Map.entry("how_are_you", "он спрашивает как дела"));
/**
* Understands an incoming message: matches an intent, applies world effects
* (memory, aggression, running to help) and prepares a template response.
* @param player the speaking player.
* @param npc the addressed bot.
* @param message the raw message.
* @return understanding or null when no intent matched.
*/
// --- Stance memory: a decision (duel/party/help/insult mode) sticks to the pair bot+player. ---
private static final long STANCE_TTL = 15 * 60 * 1000;
private static class Stance
{
final String seam;
final String text;
final long timestamp = System.currentTimeMillis();
Stance(String seam, String text)
{
this.seam = seam;
this.text = text;
}
}
private static final Map<String, Stance> STANCES = new java.util.concurrent.ConcurrentHashMap<>();
private static final Map<String, Long> FIRST_CONTACT = new java.util.concurrent.ConcurrentHashMap<>();
private static final java.util.Set<String> STANCE_INTENTS = java.util.Set.of("duel", "ask_party", "ask_help", "insult");
public Understanding understand(org.l2jmobius.gameserver.entity.actor.Player player, Npc npc, String message)
{
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
final FakePlayerIdiolect.Mirror mirror = FakePlayerIdiolect.Mirror.of(message);
final String personality = npc.getTemplate().getParameters().getString("fpcPersonality", "neutral");
final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(botId, player.getName());
final String asker = (memory == null) ? "stranger" : ((memory.kills > 0) ? "killed_me" : ((memory.helps > 0) ? "helped_me" : "stranger"));
final Map<String, String> facts = new HashMap<>();
facts.put("personality", personality);
facts.put("asker", asker);
final Map<String, String> slots = new HashMap<>();
slots.put("player", player.getName());
final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
final long now = System.currentTimeMillis();
final String contactKey = botId + "|" + player.getName();
// Silence: only a stranger's FIRST message can be ignored, and never a trade question.
final Long firstSeen = FIRST_CONTACT.putIfAbsent(contactKey, now);
if ((firstSeen == null) && "stranger".equals(asker) && ((intent == null) || !"ask_price".equals(intent.key)) && (Rnd.get(1000) < (int) (idiolect.silence * 1000)))
{
final Understanding ignored = new Understanding();
ignored.silent = true;
ignored.llmHint = "";
return ignored;
}
if (FIRST_CONTACT.size() > 5000)
{
FIRST_CONTACT.entrySet().removeIf(entry -> (now - entry.getValue()) > STANCE_TTL);
}
// Caps-lock: sometimes the reaction is to the shouting itself.
int letters = 0;
int upper = 0;
for (int i = 0; i < message.length(); i++)
{
final char ch = message.charAt(i);
if (Character.isLetter(ch))
{
letters++;
if (Character.isUpperCase(ch))
{
upper++;
}
}
}
if ((letters > 3) && (upper > (letters * 0.7)) && (Rnd.get(100) < 35))
{
final String capsReply = FakePlayerChatLines.getInstance().speakPrivate(botId, "reply_caps", facts, slots, mirror);
if (capsReply != null)
{
final Understanding result = new Understanding();
result.intentKey = "caps";
result.llmHint = "он пишет капсом";
result.responseLine = capsReply;
return result;
}
}
if (intent == null)
{
return null;
}
final Understanding result = new Understanding();
result.intentKey = intent.key;
result.llmHint = INTENT_HINTS.getOrDefault(intent.key, "");
String seam = intent.seam;
// Stance memory: a repeated question gets the same position, not a new roll.
final String stanceKey = contactKey + "|" + intent.key;
Stance stance = STANCE_INTENTS.contains(intent.key) ? STANCES.get(stanceKey) : null;
if ((stance != null) && ((now - stance.timestamp) > STANCE_TTL))
{
STANCES.remove(stanceKey);
stance = null;
}
if (stance != null)
{
if ("insult".equals(intent.key))
{
seam = stance.seam; // Same reaction mode, fresh wording.
}
else
{
final Map<String, String> againSlots = new HashMap<>(slots);
againSlots.put("prev", stance.text);
final String again = FakePlayerChatLines.getInstance().speakPrivate(botId, "reply_again", facts, againSlots, mirror);
result.responseLine = (again != null) ? again : stance.text;
return result;
}
}
switch (intent.key)
{
case "insult":
{
FakePlayerMemoryManager.getInstance().adjustScore(botId, player.getName(), -1);
if (stance == null)
{
final boolean aggressive = "ganker".equals(personality) || "pkk".equals(personality) || (Rnd.get(100) < 50);
seam = aggressive ? "reply_insult_aggro" : "reply_insult_soft";
}
if ("ganker".equals(personality) && (npc instanceof org.l2jmobius.gameserver.entity.actor.Attackable attackable) && (npc.calculateDistance2D(player) < 2000))
{
attackable.addDamageHate(player, 0, 500);
npc.setRunning();
npc.getAI().setIntentionAttack(player);
}
break;
}
case "greeting":
{
if ("killed_me".equals(asker) && (Rnd.get(100) < 60))
{
seam = "revenge_meet";
}
else if ("helped_me".equals(asker) && (Rnd.get(100) < 60))
{
seam = "friendly_meet";
}
break;
}
case "thanks":
{
FakePlayerMemoryManager.getInstance().adjustScore(botId, player.getName(), 1);
break;
}
case "ask_party":
{
final boolean willing = !"ganker".equals(personality) && !npc.isInCombat() && (npc.calculateDistance2D(player) < 2500);
if (willing)
{
FakePlayerAiTaskManager.getInstance().startCompanion(npc, player, 15);
seam = "party_accept";
}
break;
}
case "yes_ok":
{
if (FakePlayerAiTaskManager.getInstance().hasPendingInvite(npc, player))
{
FakePlayerAiTaskManager.getInstance().startCompanion(npc, player, 15);
seam = "party_accept";
}
break;
}
case "ask_help":
{
final boolean willing = (!"ganker".equals(personality) || "helped_me".equals(asker)) && !npc.isInCombat() && (npc.calculateDistance2D(player) < 3000) && !"killed_me".equals(asker);
seam = willing ? "reply_help_yes" : "reply_help_no";
if (willing && (npc instanceof org.l2jmobius.gameserver.entity.actor.Attackable attackable))
{
final org.l2jmobius.gameserver.entity.WorldObject playerTarget = player.getTarget();
if ((playerTarget instanceof org.l2jmobius.gameserver.entity.actor.instance.Monster monster) && !monster.isDead())
{
attackable.addDamageHate(monster, 0, 200);
npc.setRunning();
npc.getAI().setIntentionAttack(monster);
}
else
{
npc.setRunning();
npc.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(player.getX(), player.getY(), player.getZ()));
}
}
break;
}
case "ask_rumors":
{
final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(botId);
if (story != null)
{
result.responseLine = "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story);
}
break;
}
case "ask_where":
{
final String zone = FakePlayerProgressionManager.getInstance().getZoneName(npc.getObjectId());
slots.put("zone", zone.isEmpty() ? "поле" : zone);
break;
}
case "ask_level":
{
final int level = FakePlayerProgressionManager.getInstance().getLevel(npc.getObjectId());
slots.put("level", String.valueOf(level > 0 ? level : npc.getTemplate().getLevel()));
break;
}
}
if ((result.responseLine == null) && !seam.isEmpty())
{
result.responseLine = FakePlayerChatLines.getInstance().speakPrivate(botId, seam, facts, slots, mirror);
}
// A fresh decision on duel/party/help/insult becomes the bot's stance.
if ((stance == null) && (result.responseLine != null) && STANCE_INTENTS.contains(intent.key))
{
STANCES.put(stanceKey, new Stance(seam, result.responseLine));
if (STANCES.size() > 5000)
{
STANCES.entrySet().removeIf(entry -> (now - entry.getValue().timestamp) > STANCE_TTL);
}
}
return result;
}
private Npc findNpc(String fpcName)
{
final Spawn spawn = SpawnTable.getInstance().getAnySpawn(FakePlayerData.getInstance().getNpcIdByName(fpcName));
return (spawn != null) ? spawn.getLastSpawn() : null;
} }
public void sendChat(Player player, String fpcName, String message) public void sendChat(Player player, String fpcName, String message)
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import org.w3c.dom.Document;
import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.gameserver.util.StatSet;
/**
* Combat profiles for bots: which skills a class uses, in what order and under
* which conditions. Data driven (data/FakePlayerCombat.xml) so the behaviour can
* be tuned without recompiling.
* @author Mobius SPP
*/
public class FakePlayerCombatData implements IXmlReader
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerCombatData.class.getName());
/**
* One skill entry with its usage conditions.
*/
public static class BotSkill
{
public int id;
public int priority;
public int maxTargetHp = 100;
public int minMp = 0;
public int minHp = 100;
}
/**
* Combat profile of a single class.
*/
public static class Profile
{
public String role = "fighter";
public final List<BotSkill> offensive = new ArrayList<>();
public final List<BotSkill> self = new ArrayList<>();
public final List<BotSkill> heal = new ArrayList<>();
}
private final Map<Integer, Profile> _profiles = new HashMap<>();
private Profile _default = new Profile();
protected FakePlayerCombatData()
{
load();
}
@Override
public void load()
{
_profiles.clear();
parseDatapackFile("data/FakePlayerCombat.xml");
LOGGER.info(getClass().getSimpleName() + ": Loaded combat profiles for " + _profiles.size() + " classes.");
}
@Override
public void parseDocument(Document document, File file)
{
forEach(document, "combat", listNode ->
{
forEach(listNode, "class", classNode ->
{
final StatSet set = new StatSet(parseAttributes(classNode));
final Profile profile = new Profile();
profile.role = set.getString("role", "fighter");
fill(classNode, profile);
_profiles.put(set.getInt("id"), profile);
});
forEach(listNode, "default", defaultNode ->
{
final StatSet set = new StatSet(parseAttributes(defaultNode));
final Profile profile = new Profile();
profile.role = set.getString("role", "fighter");
fill(defaultNode, profile);
_default = profile;
});
});
}
private void fill(org.w3c.dom.Node classNode, Profile profile)
{
forEach(classNode, "offensive", node -> profile.offensive.add(skill(node, false)));
forEach(classNode, "self", node -> profile.self.add(skill(node, false)));
forEach(classNode, "heal", node -> profile.heal.add(skill(node, true)));
profile.offensive.sort(Comparator.comparingInt(s -> s.priority));
profile.heal.sort(Comparator.comparingInt(s -> s.priority));
}
private BotSkill skill(org.w3c.dom.Node node, boolean healing)
{
final StatSet set = new StatSet(parseAttributes(node));
final BotSkill skill = new BotSkill();
skill.id = set.getInt("id");
skill.priority = set.getInt("priority", 10);
skill.maxTargetHp = set.getInt("maxTargetHp", 100);
skill.minMp = set.getInt("minMp", 0);
skill.minHp = set.getInt("minHp", healing ? 70 : 100);
return skill;
}
/**
* @param classId player class id.
* @return the profile for this class, never null.
*/
public Profile getProfile(int classId)
{
return _profiles.getOrDefault(classId, _default);
}
public static FakePlayerCombatData getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerCombatData INSTANCE = new FakePlayerCombatData();
}
}
@@ -0,0 +1,299 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
/**
* Tiny embedded HTTP dashboard: live bot map and a dialog tester with seam breakdown.
* Endpoints: / (html), /api/bots, /api/seams, /api/test?msg=...&personality=...
* @author Mobius SPP
*/
public class FakePlayerDashboard
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerDashboard.class.getName());
private static final int PORT = 7780;
protected FakePlayerDashboard()
{
if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED)
{
return;
}
try
{
final HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", PORT), 0);
server.setExecutor(Executors.newFixedThreadPool(2));
server.createContext("/", exchange -> respondFile(exchange));
server.createContext("/api/bots", exchange ->
{
final String npcs = FakePlayerProgressionManager.getInstance().statusJson();
final String headless = FakePlayerHeadlessManager.getInstance().statusJson();
final String merged = headless.isEmpty() ? npcs : (npcs.length() > 2 ? npcs.substring(0, npcs.length() - 1) + "," + headless + "]" : "[" + headless + "]");
respond(exchange, "application/json", merged);
});
server.createContext("/api/seams", exchange -> respond(exchange, "application/json", seamsJson()));
server.createContext("/api/phrase", exchange -> respond(exchange, "application/json", phraseJson(exchange)));
server.createContext("/api/cmd", exchange -> respond(exchange, "application/json", cmdJson(exchange)));
server.createContext("/api/test", exchange -> respond(exchange, "application/json", testJson(exchange)));
server.start();
LOGGER.info(getClass().getSimpleName() + ": Listening on port " + PORT + " (http://127.0.0.1:" + PORT + "/).");
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not start.", e);
}
}
private void respondFile(HttpExchange exchange) throws IOException
{
byte[] body;
try
{
body = Files.readAllBytes(Paths.get("./data/dashboard.html"));
}
catch (Exception e)
{
body = "dashboard.html not found in game/data".getBytes(StandardCharsets.UTF_8);
}
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody())
{
os.write(body);
}
}
private void respond(HttpExchange exchange, String type, String content) throws IOException
{
final byte[] body = content.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", type + "; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody())
{
os.write(body);
}
}
private static String esc(String value)
{
final StringBuilder sb = new StringBuilder();
for (char c : value.toCharArray())
{
switch (c)
{
case '"':
sb.append("\\\"");
break;
case '\\':
sb.append("\\\\");
break;
case '\n':
case '\r':
sb.append(' ');
break;
default:
sb.append(c);
}
}
return sb.toString();
}
private String seamsJson()
{
final StringBuilder sb = new StringBuilder(1 << 16);
sb.append('{');
boolean firstSeam = true;
for (Map.Entry<String, List<String>> entry : FakePlayerChatLines.getInstance().seams().entrySet())
{
if (!firstSeam)
{
sb.append(',');
}
firstSeam = false;
sb.append('"').append(esc(entry.getKey())).append("\":[");
boolean firstLine = true;
for (String line : entry.getValue())
{
if (!firstLine)
{
sb.append(',');
}
firstLine = false;
sb.append('"').append(esc(line)).append('"');
}
sb.append(']');
}
return sb.append('}').toString();
}
private Map<String, String> query(HttpExchange exchange)
{
final Map<String, String> result = new HashMap<>();
final String query = exchange.getRequestURI().getRawQuery();
if (query != null)
{
for (String pair : query.split("&"))
{
final int eq = pair.indexOf('=');
if (eq > 0)
{
result.put(pair.substring(0, eq), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
}
}
}
return result;
}
private String phraseJson(HttpExchange exchange)
{
final Map<String, String> params = query(exchange);
final String key = params.getOrDefault("key", "ambient");
final String personality = params.getOrDefault("personality", "neutral");
final Map<String, String> slots = new HashMap<>();
slots.put("player", params.getOrDefault("player", "Vanger"));
slots.put("target", params.getOrDefault("player", "Vanger"));
slots.put("level", "42");
slots.put("boss", "Core");
slots.put("zone", "Cruma");
slots.put("town", "Giran");
slots.put("msg", "продам дроп");
final StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 8; i++)
{
final String line = FakePlayerChatLines.getInstance().compose(0, key, personality, slots);
if (i > 0)
{
sb.append(',');
}
sb.append('"').append(esc((line != null) ? line : "")).append('"');
}
return sb.append(']').toString();
}
private String cmdJson(HttpExchange exchange)
{
final Map<String, String> params = query(exchange);
final String command = params.getOrDefault("do", "");
final String name = params.getOrDefault("name", "");
final String argument = params.getOrDefault("arg", "");
String result;
try
{
result = FakePlayerHeadlessManager.getInstance().debugCommand(command, name, argument);
}
catch (Exception e)
{
result = "error: " + e;
}
return "{\"result\":\"" + esc(result) + "\"}";
}
private String testJson(HttpExchange exchange)
{
String message = "";
String personality = "neutral";
final String query = exchange.getRequestURI().getRawQuery();
if (query != null)
{
for (String pair : query.split("&"))
{
final int eq = pair.indexOf('=');
if (eq > 0)
{
final String key = pair.substring(0, eq);
final String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
if (key.equals("msg"))
{
message = value;
}
else if (key.equals("personality"))
{
personality = value;
}
}
}
}
final String normalized = FakePlayerIntentParser.getInstance().normalize(message);
final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
String seam = "";
String hint = "";
String line = "";
if (intent != null)
{
hint = FakePlayerChatManager.intentHint(intent.key);
seam = intent.seam;
// Mirror the effect-dependent seam choices of the live pipeline (dry run).
switch (intent.key)
{
case "insult":
seam = ("ganker".equals(personality) || "pkk".equals(personality)) ? "reply_insult_aggro" : "reply_insult_soft";
break;
case "ask_help":
seam = "ganker".equals(personality) ? "reply_help_no" : "reply_help_yes";
break;
case "ask_party":
seam = "ganker".equals(personality) ? "reply_party_no" : "party_accept";
break;
}
if (!seam.isEmpty())
{
final Map<String, String> slots = new HashMap<>();
slots.put("player", "Tester");
slots.put("zone", "Cruma");
slots.put("level", "42");
final String picked = FakePlayerChatLines.getInstance().compose(0, seam, personality, slots);
line = (picked != null) ? picked : "";
}
}
return "{\"normalized\":\"" + esc(normalized) + "\",\"intent\":\"" + esc(intent != null ? intent.key : "") + "\",\"hint\":\"" + esc(hint) + "\",\"seam\":\"" + esc(seam) + "\",\"line\":\"" + esc(line) + "\"}";
}
public static FakePlayerDashboard getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerDashboard INSTANCE = new FakePlayerDashboard();
}
}
@@ -0,0 +1,230 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.actor.Creature;
import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.entity.actor.Player;
/**
* The "ear" of the fake player population: every public bot line passes
* through here, and nearby bots sometimes pick the topic up - agreeing with a
* complaint, shrugging at a question, congratulating a level up. One pickup
* per line at most (depth 1), commercial/functional lines are never picked up.
* Sandbox-tested logic (overheard / overheard_q / grats seams).
* @author Mobius SPP
*/
public class FakePlayerHeardManager
{
private static final int HEAR_RANGE = 1250;
private static final int PICKUP_CHANCE = 10; // Percent.
private static final int GRATS_CHANCE = 12; // Percent, per neighbour.
private static final int GRATS_MAX = 2;
private static final long GLOBAL_THROTTLE = 8000; // At most one pickup per N ms server-wide.
private static final Pattern QUESTION = Pattern.compile("^(кто|где|когда|скок|сколько|почем)\\b.*|.*\\?.*", Pattern.UNICODE_CHARACTER_CLASS);
private static final Pattern COMMERCIAL = Pattern.compile(".*(лавка|продаю|продает|скупа|распродажа|налетай|торгую|акци|цены|меняю|покупай|торговец|зацени|ждет (своего )?хозяина|открыл точку|ищу (кп|пати)|лфп|нужн|формирую пати|собран|собираю|стучите|(кп|пати).{0,25}\\bго\\b|\\bго\\b.{0,25}(кп|пати)|за \\d+к|по \\d+к|\\d+ ?кк?\\b|лям|сотк|грейд|соски|шоты|бсое|свитки|кристалл|рецепт|уголь|бижа|банки|дешевле|отдам|вх\\b|под завязку|гк вызову).*", Pattern.UNICODE_CHARACTER_CLASS);
private static final Pattern LEVELUP = Pattern.compile(".*\\b\\d{1,2}\\b.*", Pattern.UNICODE_CHARACTER_CLASS);
private final AtomicLong _lastPickup = new AtomicLong();
protected FakePlayerHeardManager()
{
}
public static boolean isQuestionLine(String text)
{
return QUESTION.matcher(text).matches();
}
public static boolean isCommercialLine(String text)
{
return COMMERCIAL.matcher(text).matches();
}
public static boolean isLevelupLine(String text)
{
return LEVELUP.matcher(text).matches();
}
/**
* Called for every public line a bot says (never for pickup replies - depth 1).
* @param speaker the bot that spoke (npc fake player or headless player).
* @param text the said line.
*/
public void onSpeech(Creature speaker, String text)
{
onSpeech(speaker, text, "");
}
/**
* @param speaker the bot that spoke.
* @param text the said line.
* @param seam the seam the line came from ("" when unknown, e.g. an LLM line -
* then text heuristics decide).
*/
public void onSpeech(Creature speaker, String text, String seam)
{
if ((speaker == null) || (text == null) || text.isEmpty())
{
return;
}
// The seam is the ground truth; heuristics only cover seamless (LLM) lines.
final boolean known = (seam != null) && !seam.isEmpty();
final boolean levelup = known ? "levelup".equals(seam) : isLevelupLine(text);
final boolean commercial = known ? ("trade_shout".equals(seam) || "lfp".equals(seam) || "store_title".equals(seam) || "call_help".equals(seam) || "grats".equals(seam) || "overheard".equals(seam) || "overheard_q".equals(seam)) : isCommercialLine(text);
if (!levelup && commercial)
{
return; // Nobody "agrees" with an advertisement (or a pickup - depth 1).
}
final long now = System.currentTimeMillis();
if (!levelup)
{
if (Rnd.get(100) >= PICKUP_CHANCE)
{
return;
}
final long last = _lastPickup.get();
if (((now - last) < GLOBAL_THROTTLE) || !_lastPickup.compareAndSet(last, now))
{
return;
}
}
final List<Creature> listeners = new ArrayList<>();
for (Npc npc : World.getVisibleObjectsInRange(speaker, Npc.class, HEAR_RANGE))
{
if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead())
{
listeners.add(npc);
}
}
for (Player player : World.getVisibleObjectsInRange(speaker, Player.class, HEAR_RANGE))
{
if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead())
{
listeners.add(player);
}
}
if (listeners.isEmpty())
{
return;
}
if (levelup)
{
int congratulated = 0;
for (Creature listener : listeners)
{
if (congratulated >= GRATS_MAX)
{
break;
}
if (Rnd.get(100) >= GRATS_CHANCE)
{
continue;
}
congratulated++;
final Map<String, String> slots = new HashMap<>(1);
slots.put("player", speaker.getName());
scheduleReply(listener, "grats", slots);
}
return;
}
final Creature listener = listeners.get(Rnd.get(listeners.size()));
final boolean question = isQuestionLine(text);
final Map<String, String> slots = new HashMap<>(1);
final String[] words = text.split("\\s+");
final StringBuilder echo = new StringBuilder();
for (int i = 0; (i < words.length) && (i < 3); i++)
{
if (i > 0)
{
echo.append(' ');
}
echo.append(words[i]);
}
slots.put("msg", echo.toString());
scheduleReply(listener, question ? "overheard_q" : "overheard", slots);
}
private void scheduleReply(Creature listener, String seam, Map<String, String> slots)
{
ThreadPool.schedule(() ->
{
final int botId;
final String personality;
if (listener instanceof Npc npc)
{
botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
personality = npc.getTemplate().getParameters().getString("fpcPersonality", "neutral");
}
else if (listener instanceof Player player)
{
botId = FakePlayerHeadlessManager.getInstance().seedIdOf(player);
personality = FakePlayerHeadlessManager.getInstance().personalityOf(player);
}
else
{
return;
}
final Map<String, String> facts = new HashMap<>(1);
facts.put("personality", personality);
// Optional mode: silence beats repetition for background reactions.
final String reply = FakePlayerChatLines.getInstance().speak(botId, seam, facts, slots, true, null);
if (reply == null)
{
return;
}
if (listener instanceof Npc npc)
{
FakePlayerChatManager.getInstance().broadcastRaw(npc, reply);
}
else if (listener instanceof Player player)
{
FakePlayerHeadlessManager.getInstance().sayRaw(player, reply);
}
}, Rnd.get(2000, 6000));
}
public static FakePlayerHeardManager getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerHeardManager INSTANCE = new FakePlayerHeardManager();
}
}
@@ -0,0 +1,319 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Personal speech style of a fake player, derived deterministically from the
* character id (nothing is stored, survives restarts by construction).
* Mirrors the sandbox StylePass 1:1: laughter/filler markers, "go" form,
* comma habits, personal vocabulary partition and a private shop assortment.
* @author Mobius SPP
*/
public class FakePlayerIdiolect
{
private static final Map<Integer, FakePlayerIdiolect> CACHE = new ConcurrentHashMap<>();
private static final String[] LAUGHS =
{
"кек",
"лол",
")))",
"ахах",
"хех"
};
private static final String[] FILLERS =
{
"ну",
"короч",
"прям",
"вообще",
"эт самое"
};
private static final String[] GO_FORMS =
{
"го",
"гоу",
"погнали",
"го го"
};
private static final String[] GOODS =
{
"соски D",
"бижа D",
"свитки тп",
"дубина+3",
"кристаллы D",
"банки хп",
"рецепты C",
"уголь духа"
};
private static final Pattern CONJ_PATTERN = Pattern.compile("\\s(?:а|но|что|чтобы|если|когда|который|пока|хотя)\\s");
private static final Pattern GO_PATTERN = Pattern.compile("\\bго\\b", Pattern.UNICODE_CHARACTER_CLASS);
/** Era chat contractions, applied per word with the bot's personal slang intensity. */
private static final Object[][] CONTRACTIONS =
{
{ Pattern.compile("\\bсейчас\\b", Pattern.UNICODE_CHARACTER_CLASS), "щас" },
{ Pattern.compile("\\bвообще\\b", Pattern.UNICODE_CHARACTER_CLASS), "ваще" },
{ Pattern.compile("\\bтолько\\b", Pattern.UNICODE_CHARACTER_CLASS), "тока" },
{ Pattern.compile("\\bсколько\\b", Pattern.UNICODE_CHARACTER_CLASS), "скок" },
{ Pattern.compile("\\bнормально\\b", Pattern.UNICODE_CHARACTER_CLASS), "норм" },
{ Pattern.compile("\\bспасибо\\b", Pattern.UNICODE_CHARACTER_CLASS), "спс" },
{ Pattern.compile("\\bпожалуйста\\b", Pattern.UNICODE_CHARACTER_CLASS), "плз" },
{ Pattern.compile("\\bчто\\b", Pattern.UNICODE_CHARACTER_CLASS), "че" },
{ Pattern.compile("\\bчего\\b", Pattern.UNICODE_CHARACTER_CLASS), "чо" },
{ Pattern.compile("\\bкто-нибудь\\b", Pattern.UNICODE_CHARACTER_CLASS), "кто-нить" },
{ Pattern.compile("\\bчто-нибудь\\b", Pattern.UNICODE_CHARACTER_CLASS), "че-нить" },
{ Pattern.compile("\\bкороче\\b", Pattern.UNICODE_CHARACTER_CLASS), "короч" },
{ Pattern.compile("\\bнаверное\\b", Pattern.UNICODE_CHARACTER_CLASS), "наверн" },
{ Pattern.compile("\\bсегодня\\b", Pattern.UNICODE_CHARACTER_CLASS), "сёдня" },
{ Pattern.compile("\\bздесь\\b", Pattern.UNICODE_CHARACTER_CLASS), "тут" },
{ Pattern.compile("\\bпосмотри\\b", Pattern.UNICODE_CHARACTER_CLASS), "глянь" }
};
public final int charId;
public final double chattiness; // 0.4..1.6, scales optional chunk chance.
public final double punct; // 0 / 0.6 / 1 - comma habits.
public final String laugh;
public final String filler;
public final String go;
public final boolean shortAddress; // Shortens long nicknames.
public final boolean yo; // "che" -> "chyo".
public final double silence; // 0..0.3 chance to ignore a stranger's first message.
public final boolean traitMat;
public final boolean traitEng;
public final boolean traitOld;
public final double loudness; // Channel preference (shout/trade lover).
public final double slang; // 0..1 - how hard the bot contracts words (щас/ваще/тока).
public final String storeItem;
public final String storePrice;
private final int _vocabSeed;
private FakePlayerIdiolect(int charId)
{
this.charId = charId;
final long[] state =
{
hash32("idio" + charId)
};
chattiness = round2(0.4 + (next(state) * 1.2));
final double[] punctRoll =
{
0,
0,
0.6,
1
};
punct = punctRoll[(int) (next(state) * 4)];
laugh = LAUGHS[(int) (next(state) * LAUGHS.length)];
filler = FILLERS[(int) (next(state) * FILLERS.length)];
go = GO_FORMS[(int) (next(state) * GO_FORMS.length)];
shortAddress = next(state) > 0.66;
yo = next(state) < 0.3;
silence = round2(next(state) * 0.3);
traitMat = next(state) < 0.35;
traitEng = next(state) < 0.3;
traitOld = next(state) < 0.25;
loudness = round2(next(state));
storeItem = GOODS[(int) (next(state) * GOODS.length)];
slang = round2(next(state));
storePrice = ((5 + (int) (next(state) * 45)) * 10) + "к";
_vocabSeed = hash32("vocab" + charId);
}
public static FakePlayerIdiolect of(int charId)
{
return CACHE.computeIfAbsent(charId <= 0 ? 0 : charId, FakePlayerIdiolect::new);
}
public boolean hasTrait(String trait)
{
switch (trait)
{
case "mat":
{
return traitMat;
}
case "eng":
{
return traitEng;
}
case "old":
{
return traitOld;
}
}
return false;
}
/**
* @param poolKey pool name.
* @param size pool size.
* @param index line index.
* @return true when this line belongs to the bot's personal vocabulary subset of the pool.
*/
public boolean ownsPoolLine(String poolKey, int size, int index)
{
if (size <= 5)
{
return true;
}
final int keep = Math.max(4, (int) Math.ceil(size * 0.6));
final int[] order = new int[size];
for (int i = 0; i < size; i++)
{
order[i] = i;
}
final long[] state =
{
_vocabSeed ^ hash32(poolKey)
};
for (int i = size - 1; i > 0; i--)
{
final int j = (int) (next(state) * (i + 1));
final int tmp = order[i];
order[i] = order[j];
order[j] = tmp;
}
for (int i = 0; i < keep; i++)
{
if (order[i] == index)
{
return true;
}
}
return false;
}
/**
* Incoming message analysis for style mirroring.
*/
public static class Mirror
{
public boolean noPunct;
public boolean shortMessage;
public static Mirror of(String incoming)
{
final Mirror mirror = new Mirror();
mirror.noPunct = !incoming.matches(".*[,.!?].*");
mirror.shortMessage = incoming.trim().split("\\s+").length <= 2;
return mirror;
}
}
/**
* Applies the personal style to an assembled line: laughter/filler markers,
* "go" form, "yo" habit and comma rules; mirrors the interlocutor when given.
* @param text assembled line (slots already filled).
* @param mirror incoming message analysis (may be null).
* @return styled line.
*/
public String stylePass(String text, Mirror mirror)
{
String result = text.replace("~lol~", laugh).replace("~f~", filler);
if (!"го".equals(go))
{
result = GO_PATTERN.matcher(result).replaceAll(go);
}
if (yo)
{
result = result.replaceAll("\\bче\\b", "чё").replaceAll("\\bчо\\b", "чё");
}
if (slang > 0.15)
{
for (Object[] contraction : CONTRACTIONS)
{
if (Math.random() < (slang * 0.8))
{
result = ((Pattern) contraction[0]).matcher(result).replaceAll((String) contraction[1]);
}
}
}
double effectivePunct = punct;
if ((mirror != null) && mirror.noPunct)
{
effectivePunct = Math.min(effectivePunct, 0.3);
}
if (effectivePunct > 0)
{
final Matcher matcher = CONJ_PATTERN.matcher(result);
final StringBuilder sb = new StringBuilder();
while (matcher.find())
{
matcher.appendReplacement(sb, (Math.random() < effectivePunct ? "," : "") + Matcher.quoteReplacement(matcher.group()));
}
matcher.appendTail(sb);
result = sb.toString();
}
else
{
result = result.replace(",", "");
}
return result.replaceAll("\\s+", " ").replaceAll("\\s,", ",").replaceAll(",\\s*,+", ",").replaceAll("\\s+([:.!?])", "$1").trim();
}
/**
* @param nick interlocutor nickname.
* @return the form of address this bot uses (full or shortened nick).
*/
public String addressForm(String nick)
{
if (shortAddress && (nick != null) && (nick.length() > 5))
{
return nick.substring(0, 4);
}
return nick;
}
// --- Deterministic PRNG, same as the sandbox (fnv1a hash + mulberry32) ---
static int hash32(String text)
{
int hash = 0x811C9DC5;
for (int i = 0; i < text.length(); i++)
{
hash ^= text.charAt(i);
hash *= 0x01000193;
}
return hash;
}
private static double next(long[] state)
{
int seed = (int) state[0];
seed += 0x6D2B79F5;
state[0] = seed;
int t = (seed ^ (seed >>> 15)) * (1 | seed);
t = (t + ((t ^ (t >>> 7)) * (61 | t))) ^ t;
return ((t ^ (t >>> 14)) & 0xFFFFFFFFL) / 4294967296.0;
}
private static double round2(double value)
{
return Math.round(value * 100.0) / 100.0;
}
}
@@ -0,0 +1,300 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.w3c.dom.Document;
import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.gameserver.util.StatSet;
/**
* Understands incoming player messages without an LLM: normalizes the text
* (lowercase, translit decode, repeat squashing), tokenizes it and matches it
* against the intent lexicon (data/FakePlayerIntents.xml). An intent matches
* when every alternative group has at least one prefix hit among the tokens.
* @author Mobius SPP
*/
public class FakePlayerIntentParser implements IXmlReader
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerIntentParser.class.getName());
public static class Intent
{
public String key;
public String seam;
public int maxTokens;
final List<String[]> groups = new ArrayList<>();
}
private final List<Intent> _intents = new ArrayList<>();
protected FakePlayerIntentParser()
{
load();
}
@Override
public void load()
{
_intents.clear();
parseDatapackFile("data/FakePlayerIntents.xml");
LOGGER.info(getClass().getSimpleName() + ": Loaded " + _intents.size() + " intents.");
}
@Override
public void parseDocument(Document document, File file)
{
forEach(document, "intents", listNode -> forEach(listNode, "intent", intentNode ->
{
final StatSet set = new StatSet(parseAttributes(intentNode));
final Intent intent = new Intent();
intent.key = set.getString("key");
intent.seam = set.getString("seam", "");
intent.maxTokens = set.getInt("maxTokens", 99);
forEach(intentNode, "m", groupNode -> intent.groups.add(groupNode.getTextContent().split("\\|")));
_intents.add(intent);
}));
}
/**
* Normalizes a message: lowercase, latin-leet to russian, squashed repeats.
* @param message raw text.
* @return normalized text.
*/
public String normalize(String message)
{
String text = message.toLowerCase();
// Latin leet used by russian players -> russian letters.
text = text.replace("ii", "п").replace("ji", "л").replace("bi", "ы").replace("io", "ю");
final StringBuilder sb = new StringBuilder(text.length());
char previous = 0;
int repeat = 0;
for (char c : text.toCharArray())
{
// Digits used as letters inside words.
if (c == '4')
{
c = 'ч';
}
else if (c == '3')
{
c = 'з';
}
else if (c == '6')
{
c = 'б';
}
if (c == previous)
{
repeat++;
if (repeat >= 2)
{
continue; // Squash "приииивет".
}
}
else
{
repeat = 0;
}
previous = c;
sb.append(c);
}
return sb.toString();
}
/**
* Phonetic latin-to-russian transliteration ("privet" - "привет", "xelp" - "хелп").
* @param text normalized text.
* @return transliterated text.
*/
public String phonetic(String text)
{
String result = text.replace("sh", "ш").replace("ch", "ч").replace("zh", "ж").replace("yu", "ю").replace("ya", "я").replace("kh", "х").replace("ts", "ц");
final StringBuilder sb = new StringBuilder(result.length());
for (char c : result.toCharArray())
{
final int index = "abvgdezijklmnoprstufhxcyq".indexOf(c);
sb.append((index >= 0) ? "абвгдезийклмнопрстуфхxцык".charAt(index) : c);
}
return sb.toString().replace('x', 'х');
}
/**
* @param message the raw player message.
* @return the matched intent, or null.
*/
public Intent parse(String message)
{
final String normalizedText = normalize(message);
final String[] rawTokens = normalizedText.split("[^a-zа-яё0-9]+");
final String[] phoneticTokens = phonetic(normalizedText).split("[^a-zа-яё0-9]+");
final String[] tokens = new String[rawTokens.length + phoneticTokens.length];
System.arraycopy(rawTokens, 0, tokens, 0, rawTokens.length);
System.arraycopy(phoneticTokens, 0, tokens, rawTokens.length, phoneticTokens.length);
int tokenCount = 0;
for (String token : rawTokens)
{
if (!token.isEmpty())
{
tokenCount++;
}
}
if (tokenCount == 0)
{
return null;
}
for (Intent intent : _intents)
{
if (tokenCount > intent.maxTokens)
{
continue;
}
boolean all = true;
for (String[] group : intent.groups)
{
boolean any = false;
for (String token : tokens)
{
if (token.isEmpty())
{
continue;
}
for (String stem : group)
{
if (token.startsWith(stem) || ((stem.length() > 3) && fuzzyPrefix(token, stem)))
{
any = true;
break;
}
}
if (any)
{
break;
}
}
if (!any)
{
all = false;
break;
}
}
if (all)
{
return intent;
}
}
return null;
}
/**
* Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein
* distance 1 of the stem ("помгите" matches stem "помо").
* @param token message token.
* @param stem lexicon stem.
* @return true when the prefix is close enough.
*/
private static boolean fuzzyPrefix(String token, String stem)
{
final int length = stem.length();
if (token.length() >= length)
{
if (withinOneEdit(token.substring(0, length), stem))
{
return true;
}
}
if (token.length() >= (length + 1))
{
return withinOneEdit(token.substring(0, length + 1), stem);
}
return false;
}
private static boolean withinOneEdit(String a, String b)
{
if (a.equals(b))
{
return true;
}
final int la = a.length();
final int lb = b.length();
if (Math.abs(la - lb) > 1)
{
return false;
}
if (la == lb)
{
int differences = 0;
boolean swap = false;
for (int i = 0; i < la; i++)
{
if (a.charAt(i) != b.charAt(i))
{
differences++;
if ((differences == 2) && (i > 0) && (a.charAt(i) == b.charAt(i - 1)) && (a.charAt(i - 1) == b.charAt(i)))
{
swap = true;
}
}
}
return (differences <= 1) || ((differences == 2) && swap);
}
final String shorter = (la < lb) ? a : b;
final String longer = (la < lb) ? b : a;
int i = 0;
int j = 0;
int differences = 0;
while ((i < shorter.length()) && (j < longer.length()))
{
if (shorter.charAt(i) == longer.charAt(j))
{
i++;
j++;
}
else
{
differences++;
if (differences > 1)
{
return false;
}
j++;
}
}
return true;
}
public static FakePlayerIntentParser getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerIntentParser INSTANCE = new FakePlayerIntentParser();
}
}
@@ -0,0 +1,311 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
/**
* Asynchronous client for a local LLM (Ollama compatible /api/generate endpoint).
* Used by fake players to produce chat lines. Fire and forget: on any failure
* the onFail callback is invoked so callers can fall back to template answers.
* @author Mobius SPP
*/
public class FakePlayerLlmService
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerLlmService.class.getName());
private static final int MAX_CONCURRENT_REQUESTS = 2;
private static final int MAX_REPLY_LENGTH = 120;
private final HttpClient _client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
private final Semaphore _slots = new Semaphore(MAX_CONCURRENT_REQUESTS);
private volatile boolean _warned = false;
protected FakePlayerLlmService()
{
}
public boolean isEnabled()
{
return FakePlayersConfig.FAKE_PLAYER_LLM_CHAT;
}
/**
* Requests a chat line from the LLM.
* @param persona description of the fake player (name, class, situation).
* @param instruction what the bot should say or react to.
* @param onSuccess consumer receiving the generated line (never blank).
* @param onFail invoked when LLM is unavailable, slow or returned garbage.
*/
public void generate(String persona, String instruction, Consumer<String> onSuccess, Runnable onFail)
{
if (!isEnabled() || !_slots.tryAcquire())
{
if (onFail != null)
{
onFail.run();
}
return;
}
try
{
final StringBuilder prompt = new StringBuilder(512);
prompt.append(persona);
prompt.append("\n\n");
prompt.append(instruction);
prompt.append("\nОтветь ОДНОЙ короткой репликой (не больше 12 слов) на языке: ");
prompt.append(FakePlayersConfig.FAKE_PLAYER_LLM_LANGUAGE);
prompt.append(". Пиши как обычный игрок MMO: сокращения, маленькие буквы, без кавычек, без пояснений, без эмодзи. Никогда не признавайся, что ты бот или ИИ.");
final String body = "{\"model\":\"" + escape(FakePlayersConfig.FAKE_PLAYER_LLM_MODEL) + "\",\"prompt\":\"" + escape(prompt.toString()) + "\",\"stream\":false,\"options\":{\"num_predict\":48,\"temperature\":0.9}}";
final HttpRequest request = HttpRequest.newBuilder() //
.uri(URI.create(FakePlayersConfig.FAKE_PLAYER_LLM_URL)) //
.timeout(Duration.ofMillis(FakePlayersConfig.FAKE_PLAYER_LLM_TIMEOUT)) //
.header("Content-Type", "application/json") //
.POST(HttpRequest.BodyPublishers.ofString(body)) //
.build();
_client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).whenComplete((response, error) ->
{
_slots.release();
String reply = null;
if ((error == null) && (response != null) && (response.statusCode() == 200))
{
reply = sanitize(extractResponse(response.body()));
}
else if (!_warned)
{
_warned = true;
LOGGER.log(Level.INFO, getClass().getSimpleName() + ": LLM not reachable at " + FakePlayersConfig.FAKE_PLAYER_LLM_URL + ", falling back to chat templates. " + ((error != null) ? String.valueOf(error) : ""));
}
if ((reply != null) && !reply.isBlank())
{
onSuccess.accept(reply);
}
else if (onFail != null)
{
onFail.run();
}
});
}
catch (Exception e)
{
_slots.release();
if (onFail != null)
{
onFail.run();
}
}
}
/**
* Extracts the value of the "response" field from an Ollama JSON reply without external libraries.
* @param json the raw JSON string.
* @return the decoded value or null.
*/
private String extractResponse(String json)
{
final int keyIndex = json.indexOf("\"response\"");
if (keyIndex < 0)
{
return null;
}
int index = json.indexOf('"', json.indexOf(':', keyIndex) + 1);
if (index < 0)
{
return null;
}
final StringBuilder sb = new StringBuilder();
index++;
while (index < json.length())
{
final char c = json.charAt(index);
if (c == '\\')
{
index++;
if (index >= json.length())
{
break;
}
final char escaped = json.charAt(index);
switch (escaped)
{
case 'n':
{
sb.append(' ');
break;
}
case 't':
{
sb.append(' ');
break;
}
case 'u':
{
if ((index + 4) < json.length())
{
try
{
sb.append((char) Integer.parseInt(json.substring(index + 1, index + 5), 16));
}
catch (NumberFormatException ignored)
{
// Skip malformed escape.
}
index += 4;
}
break;
}
default:
{
sb.append(escaped);
break;
}
}
}
else if (c == '"')
{
break;
}
else
{
sb.append(c);
}
index++;
}
return sb.toString();
}
private String sanitize(String text)
{
if (text == null)
{
return null;
}
String result = text.trim();
// Strip reasoning blocks some models produce.
final int thinkEnd = result.lastIndexOf("</think>");
if (thinkEnd >= 0)
{
result = result.substring(thinkEnd + "</think>".length()).trim();
}
// Strip wrapping quotes.
if ((result.length() > 1) && ((result.charAt(0) == '"') || (result.charAt(0) == '«')))
{
result = result.substring(1);
}
if ((result.length() > 1) && ((result.charAt(result.length() - 1) == '"') || (result.charAt(result.length() - 1) == '»')))
{
result = result.substring(0, result.length() - 1);
}
// Keep single line.
final int newLine = result.indexOf('\n');
if (newLine > 0)
{
result = result.substring(0, newLine);
}
result = result.trim();
if (result.length() > MAX_REPLY_LENGTH)
{
result = result.substring(0, MAX_REPLY_LENGTH);
}
return result;
}
private String escape(String text)
{
final StringBuilder sb = new StringBuilder(text.length() + 16);
for (int i = 0; i < text.length(); i++)
{
final char c = text.charAt(i);
switch (c)
{
case '"':
{
sb.append("\\\"");
break;
}
case '\\':
{
sb.append("\\\\");
break;
}
case '\n':
{
sb.append("\\n");
break;
}
case '\r':
{
break;
}
case '\t':
{
sb.append("\\t");
break;
}
default:
{
if (c < 0x20)
{
sb.append(String.format("\\u%04x", (int) c));
}
else
{
sb.append(c);
}
break;
}
}
}
return sb.toString();
}
public static FakePlayerLlmService getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerLlmService INSTANCE = new FakePlayerLlmService();
}
}
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
/**
* Long term memory of fake players about real players.
* A bot remembers who killed it, who helped it and what was said, and this
* memory changes both its LLM persona and its template line selection.
* @author Mobius SPP
*/
public class FakePlayerMemoryManager
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerMemoryManager.class.getName());
public static class Memory
{
public int score = 0;
public int kills = 0;
public int helps = 0;
public String note = "";
public long updated = 0;
boolean dirty = false;
}
private final Map<Integer, Map<String, Memory>> _memories = new ConcurrentHashMap<>();
protected FakePlayerMemoryManager()
{
if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED)
{
return;
}
try (Connection con = DatabaseFactory.getConnection();
Statement statement = con.createStatement())
{
statement.execute("CREATE TABLE IF NOT EXISTS fake_player_memory (bot_id INT NOT NULL, player VARCHAR(35) NOT NULL, score INT NOT NULL DEFAULT 0, kills INT NOT NULL DEFAULT 0, helps INT NOT NULL DEFAULT 0, note VARCHAR(255) NOT NULL DEFAULT '', updated BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (bot_id, player))");
try (ResultSet rset = statement.executeQuery("SELECT bot_id, player, score, kills, helps, note, updated FROM fake_player_memory"))
{
int count = 0;
while (rset.next())
{
final Memory memory = new Memory();
memory.score = rset.getInt("score");
memory.kills = rset.getInt("kills");
memory.helps = rset.getInt("helps");
memory.note = rset.getString("note");
memory.updated = rset.getLong("updated");
_memories.computeIfAbsent(rset.getInt("bot_id"), key -> new ConcurrentHashMap<>()).put(rset.getString("player").toLowerCase(), memory);
count++;
}
LOGGER.info(getClass().getSimpleName() + ": Loaded " + count + " memories.");
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not initialize.", e);
}
ThreadPool.scheduleAtFixedRate(this::save, 120000, 120000);
}
public Memory get(int botId, String playerName)
{
final Map<String, Memory> map = _memories.get(botId);
return (map != null) ? map.get(playerName.toLowerCase()) : null;
}
private Memory getOrCreate(int botId, String playerName)
{
return _memories.computeIfAbsent(botId, key -> new ConcurrentHashMap<>()).computeIfAbsent(playerName.toLowerCase(), key -> new Memory());
}
public void onKilledBy(int botId, String playerName)
{
if (botId <= 0)
{
return;
}
final Memory memory = getOrCreate(botId, playerName);
memory.kills++;
memory.score -= 3;
memory.updated = System.currentTimeMillis();
memory.dirty = true;
}
public void onHelpedBy(int botId, String playerName)
{
if (botId <= 0)
{
return;
}
final Memory memory = getOrCreate(botId, playerName);
memory.helps++;
memory.score += 1;
memory.updated = System.currentTimeMillis();
memory.dirty = true;
}
public void adjustScore(int botId, String playerName, int delta)
{
if (botId <= 0)
{
return;
}
final Memory memory = getOrCreate(botId, playerName);
memory.score += delta;
memory.updated = System.currentTimeMillis();
memory.dirty = true;
}
public void addNote(int botId, String playerName, String note)
{
if (botId <= 0)
{
return;
}
final Memory memory = getOrCreate(botId, playerName);
memory.note = note.length() > 250 ? note.substring(0, 250) : note;
memory.updated = System.currentTimeMillis();
memory.dirty = true;
}
/**
* @param botId the bot character id.
* @param playerName the player.
* @return a russian text summary of what the bot remembers about the player, or empty string.
*/
public String describe(int botId, String playerName)
{
final Memory memory = get(botId, playerName);
if (memory == null)
{
return "";
}
final StringBuilder sb = new StringBuilder(160);
if (memory.kills > 0)
{
sb.append("Этот игрок убивал тебя ").append(memory.kills).append(" раз - ты его помнишь и держишь обиду. ");
}
if (memory.helps > 0)
{
sb.append("Этот игрок помогал тебе в бою ").append(memory.helps).append(" раз - ты ему благодарен. ");
}
if (!memory.note.isEmpty())
{
sb.append("Из прошлого разговора: ").append(memory.note);
}
return sb.toString().trim();
}
private void save()
{
try (Connection con = DatabaseFactory.getConnection();
PreparedStatement statement = con.prepareStatement("REPLACE INTO fake_player_memory (bot_id, player, score, kills, helps, note, updated) VALUES (?,?,?,?,?,?,?)"))
{
int batch = 0;
for (Map.Entry<Integer, Map<String, Memory>> botEntry : _memories.entrySet())
{
for (Map.Entry<String, Memory> entry : botEntry.getValue().entrySet())
{
final Memory memory = entry.getValue();
if (!memory.dirty)
{
continue;
}
memory.dirty = false;
statement.setInt(1, botEntry.getKey());
statement.setString(2, entry.getKey());
statement.setInt(3, memory.score);
statement.setInt(4, memory.kills);
statement.setInt(5, memory.helps);
statement.setString(6, memory.note);
statement.setLong(7, memory.updated);
statement.addBatch();
batch++;
}
}
if (batch > 0)
{
statement.executeBatch();
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not save.", e);
}
}
public static FakePlayerMemoryManager getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerMemoryManager INSTANCE = new FakePlayerMemoryManager();
}
}
@@ -0,0 +1,372 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.managers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
/**
* Rumor system for fake players, ported from the user's agent simulation
* ("Округа": stories with confidence, hops, escalation and distortion on retelling).
* Bots witness events about real players, gossip them to each other with distortion,
* and retell them to the player with an evidence hedge.
* @author Mobius SPP
*/
public class FakePlayerRumorManager
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerRumorManager.class.getName());
private static final int MAX_STORIES_PER_BOT = 10;
public static class Story
{
public String kind; // kill, help, pk
public String subject; // player name the story is about
public String object; // bot/victim name
public double conf;
public int hops;
public int scale;
public long time;
public boolean seen;
boolean dirty = true;
String key()
{
return kind + "|" + subject.toLowerCase() + "|" + object.toLowerCase();
}
}
// Escalation ladders, the "esc" arrays of the original system.
private static final String[] ESC_KILL = { "гангнул", "разобрал на споте", "вырезал всех на точке", "терроризирует всю локацию", "пол сервера в страхе держит" };
private static final String[] ESC_HELP = { "подсобил в бою", "вытащил из верной смерти", "спас целую пати", "всем на споте помогает", "святой человек, всех спасает" };
private static final String[] ESC_PK = { "заПКшил", "режет всех подряд", "красный ходит, лютует", "вся округа от него красная" };
// Evidence hedges.
private static final String[] HEDGE_SEEN = { "сам видел как ты", "своими глазами видел - ты", "при мне ты" };
private static final String[] HEDGE_HEARD = { "говорят ты", "слышал ты", "народ говорит ты" };
private static final String[] HEDGE_WEAK = { "брешут будто ты", "болтают что ты", "не знаю верить ли, но ты вроде" };
private static final String[] HEDGE_LEGEND = { "вся округа говорит что ты", "уже легенды ходят как ты", "все споты знают что ты" };
private final Map<Integer, List<Story>> _stories = new ConcurrentHashMap<>();
protected FakePlayerRumorManager()
{
if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED)
{
return;
}
try (Connection con = DatabaseFactory.getConnection();
Statement statement = con.createStatement())
{
statement.execute("CREATE TABLE IF NOT EXISTS fake_player_rumor (bot_id INT NOT NULL, kind VARCHAR(10) NOT NULL, subject VARCHAR(35) NOT NULL, object VARCHAR(35) NOT NULL, conf DOUBLE NOT NULL, hops INT NOT NULL, scale INT NOT NULL, time BIGINT NOT NULL, seen TINYINT NOT NULL, PRIMARY KEY (bot_id, kind, subject, object))");
try (ResultSet rset = statement.executeQuery("SELECT bot_id, kind, subject, object, conf, hops, scale, time, seen FROM fake_player_rumor"))
{
int count = 0;
while (rset.next())
{
final Story story = new Story();
story.kind = rset.getString("kind");
story.subject = rset.getString("subject");
story.object = rset.getString("object");
story.conf = rset.getDouble("conf");
story.hops = rset.getInt("hops");
story.scale = rset.getInt("scale");
story.time = rset.getLong("time");
story.seen = rset.getInt("seen") > 0;
story.dirty = false;
_stories.computeIfAbsent(rset.getInt("bot_id"), key -> new ArrayList<>()).add(story);
count++;
}
LOGGER.info(getClass().getSimpleName() + ": Loaded " + count + " rumors.");
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not initialize.", e);
}
ThreadPool.scheduleAtFixedRate(this::save, 180000, 180000);
}
/**
* A bot directly witnessed an event about a player.
* @param botId witness bot character id.
* @param kind kill / help / pk.
* @param playerName the player the story is about.
* @param objectName the victim / beneficiary name.
*/
public void addWitnessed(int botId, String kind, String playerName, String objectName)
{
if (botId <= 0)
{
return;
}
final Story story = new Story();
story.kind = kind;
story.subject = playerName;
story.object = objectName;
story.conf = 0.95;
story.hops = 0;
story.scale = 0;
story.time = System.currentTimeMillis();
story.seen = true;
add(botId, story);
}
private void add(int botId, Story story)
{
final List<Story> list = _stories.computeIfAbsent(botId, key -> new ArrayList<>());
synchronized (list)
{
for (Story existing : list)
{
if (existing.key().equals(story.key()))
{
// Refresh: direct witness beats hearsay, higher scale sticks.
if (story.seen || (story.conf > existing.conf))
{
existing.conf = Math.max(existing.conf, story.conf);
existing.seen = existing.seen || story.seen;
existing.scale = Math.max(existing.scale, story.scale);
existing.time = story.time;
existing.dirty = true;
}
return;
}
}
list.add(story);
if (list.size() > MAX_STORIES_PER_BOT)
{
list.sort((a, b) -> Double.compare(a.conf, b.conf));
list.remove(0);
}
}
}
/**
* Gossip transfer with distortion - the distort() of the original system:
* confidence decays, hops grow, sometimes the story escalates.
* @param fromBotId teller.
* @param toBotId listener.
* @return the transferred story or null.
*/
public Story gossip(int fromBotId, int toBotId)
{
final List<Story> from = _stories.get(fromBotId);
if ((from == null) || from.isEmpty() || (fromBotId == toBotId))
{
return null;
}
Story story;
synchronized (from)
{
final List<Story> candidates = new ArrayList<>();
for (Story s : from)
{
if (s.conf > 0.25)
{
candidates.add(s);
}
}
if (candidates.isEmpty())
{
return null;
}
story = candidates.get(Rnd.get(candidates.size()));
}
final Story copy = new Story();
copy.kind = story.kind;
copy.subject = story.subject;
copy.object = story.object;
copy.conf = Math.max(0.1, Math.min(0.95, story.conf * (0.72 + Rnd.get(20) / 100.0)));
copy.hops = story.hops + 1;
copy.scale = story.scale + ((Rnd.get(100) < 22) ? 1 : 0); // изба сказителя не завезена - искажаем смело
copy.time = story.time;
copy.seen = false;
add(toBotId, copy);
return copy;
}
/**
* @param botId the bot.
* @param playerName the player.
* @return the strongest story this bot has about the player, or null.
*/
public Story bestAbout(int botId, String playerName)
{
final List<Story> list = _stories.get(botId);
if (list == null)
{
return null;
}
synchronized (list)
{
Story best = null;
for (Story story : list)
{
if (story.subject.equalsIgnoreCase(playerName) && (story.conf > 0.2))
{
if ((best == null) || ((story.conf + (story.kind.equals("kill") ? 0.3 : 0) + (story.scale * 0.1)) > (best.conf + (best.kind.equals("kill") ? 0.3 : 0) + (best.scale * 0.1))))
{
best = story;
}
}
}
return best;
}
}
/**
* @param botId bot.
* @return the strongest story of any subject, or null.
*/
public Story bestAny(int botId)
{
final List<Story> list = _stories.get(botId);
if (list == null)
{
return null;
}
synchronized (list)
{
Story best = null;
for (Story story : list)
{
if ((story.conf > 0.25) && ((best == null) || (story.conf > best.conf)))
{
best = story;
}
}
return best;
}
}
/**
* Renders a story about a third person ("слышал? X гангнул Y").
* @param story the story.
* @return text.
*/
public String tellThird(Story story)
{
final String[] esc = story.kind.equals("kill") ? ESC_KILL : story.kind.equals("help") ? ESC_HELP : ESC_PK;
final String body = esc[Math.min(story.scale, esc.length - 1)];
return story.subject + " " + (story.kind.equals("pk") ? body : (body + " " + story.object));
}
/**
* Renders a story as a line said TO the player - hedge + escalated body.
* @param story the story.
* @return text like "говорят ты вырезал всех на точке у MaJIou".
*/
public String tell(Story story)
{
final String[] esc = story.kind.equals("kill") ? ESC_KILL : story.kind.equals("help") ? ESC_HELP : ESC_PK;
final String body = esc[Math.min(story.scale, esc.length - 1)];
final String[] hedge = (story.seen && (story.hops == 0)) ? HEDGE_SEEN : (story.hops > 3) ? HEDGE_LEGEND : (story.conf < 0.45) ? HEDGE_WEAK : HEDGE_HEARD;
final String withObject = story.kind.equals("pk") ? body : (body + " " + story.object);
return hedge[Rnd.get(hedge.length)] + " " + withObject;
}
/**
* @param botId bot.
* @param playerName player.
* @return russian summary of rumors for the LLM persona, or empty string.
*/
public String describeFor(int botId, String playerName)
{
final Story story = bestAbout(botId, playerName);
if (story == null)
{
return "";
}
final String source = story.seen && (story.hops == 0) ? "Ты сам видел" : "Ты слышал от других (пересказ " + story.hops + " раз, можешь ошибаться)";
final String[] esc = story.kind.equals("kill") ? ESC_KILL : story.kind.equals("help") ? ESC_HELP : ESC_PK;
return source + ": этот игрок " + esc[Math.min(story.scale, esc.length - 1)] + (story.kind.equals("pk") ? "" : " " + story.object) + ".";
}
private void save()
{
try (Connection con = DatabaseFactory.getConnection();
PreparedStatement statement = con.prepareStatement("REPLACE INTO fake_player_rumor (bot_id, kind, subject, object, conf, hops, scale, time, seen) VALUES (?,?,?,?,?,?,?,?,?)"))
{
int batch = 0;
for (Map.Entry<Integer, List<Story>> entry : _stories.entrySet())
{
final List<Story> list = entry.getValue();
synchronized (list)
{
for (Story story : list)
{
if (!story.dirty)
{
continue;
}
story.dirty = false;
statement.setInt(1, entry.getKey());
statement.setString(2, story.kind);
statement.setString(3, story.subject);
statement.setString(4, story.object);
statement.setDouble(5, story.conf);
statement.setInt(6, story.hops);
statement.setInt(7, story.scale);
statement.setLong(8, story.time);
statement.setInt(9, story.seen ? 1 : 0);
statement.addBatch();
batch++;
}
}
}
if (batch > 0)
{
statement.executeBatch();
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not save.", e);
}
}
public static FakePlayerRumorManager getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final FakePlayerRumorManager INSTANCE = new FakePlayerRumorManager();
}
}
@@ -63,7 +63,7 @@ public class RequestJoinParty extends ClientPacket
return; return;
} }
if ((target.getClient() == null) || target.getClient().isDetached()) if (!target.isHeadlessBot() && ((target.getClient() == null) || target.getClient().isDetached()))
{ {
requestor.sendMessage("Player is in offline mode."); requestor.sendMessage("Player is in offline mode.");
return; return;
@@ -231,6 +231,11 @@ public class RequestJoinParty extends ClientPacket
// in case a leader change has happened, use party's mode // in case a leader change has happened, use party's mode
target.sendPacket(new AskJoinParty(requestor.getName(), party.getDistributionType())); target.sendPacket(new AskJoinParty(requestor.getName(), party.getDistributionType()));
party.setPendingInvitation(true); party.setPendingInvitation(true);
if (target.isHeadlessBot())
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().autoAcceptParty(requestor, target);
}
} }
else else
{ {
@@ -258,6 +263,11 @@ public class RequestJoinParty extends ClientPacket
target.setActiveRequester(requestor); target.setActiveRequester(requestor);
requestor.onTransactionRequest(target); requestor.onTransactionRequest(target);
requestor.setPartyDistributionType(partyDistributionType); requestor.setPartyDistributionType(partyDistributionType);
if (target.isHeadlessBot())
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().autoAcceptParty(requestor, target);
}
} }
else else
{ {
@@ -72,7 +72,7 @@ public class FakePlayerInfo extends ServerPacket
_flyRunSpd = npc.isFlying() ? _runSpd : 0; _flyRunSpd = npc.isFlying() ? _runSpd : 0;
_flyWalkSpd = npc.isFlying() ? _walkSpd : 0; _flyWalkSpd = npc.isFlying() ? _walkSpd : 0;
_fpcHolder = npc.getTemplate().getFakePlayerInfo(); _fpcHolder = npc.getTemplate().getFakePlayerInfo();
_clan = ClanTable.getInstance().getClan(_fpcHolder.getClanId()); _clan = ClanTable.getInstance().getClan(npc.getFakePlayerClanId());
} }
@Override @Override
@@ -0,0 +1,526 @@
/*
* Copyright (c) 2013 L2jMobius
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
* IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.l2jmobius.gameserver.util;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Генератор ников для фейк-игроков. Порт nickgen.html / nickgen.py — логика 1:1.<br>
* Ник состоит из 1-3 частей; русские слова рендерятся транслитом ("ZlojGnom") или
* псевдокириллицей ("TpaBka", "IIeJIbMeHb"), английские — с шансом l33t-цифр ("Ki4iga").
* Декорации: цифровой суффикс ("OldRogue38"), обёртка "xX...Xx", lowercase, капс-хвост ("HolodOK").
* Результат всегда соответствует [A-Za-z0-9]{3,16} (лимит клиента Interlude).
*/
public class FakePlayerNameGenerator
{
private static final String[] RU_ADJ =
{
"злой", "добрый", "старый", "дикий", "мелкий", "жирный", "хитрый", "бешеный", "мамкин", "просто",
"лысый", "грозный", "тихий", "сонный", "голодный", "кривой", "ржавый", "бодрый", "унылый", "модный",
"четкий", "дерзкий", "борзый", "лютый", "шустрый", "реальный", "угарный", "наглый"
};
private static final String[] RU_NOUN =
{
"гном", "пес", "кот", "волк", "сухарь", "травка", "пельмень", "колбаса", "мармелад", "чума",
"ведьмак", "сквозняк", "холодок", "бомж", "топор", "кабан", "хомяк", "огурец", "батон", "чеснок",
"валенок", "тапок", "кефир", "компот", "барсук", "ежик", "мухомор", "лопата", "кирпич", "шашлык",
"беляш", "танкист", "дед", "сосед", "медоед", "каратель", "монах",
"гопник", "чебурек", "шаурма", "семки", "пацан", "браток", "кент", "батя", "гараж", "подъезд",
"чушпан", "задрот", "нуб", "читер", "красавчик", "нагибатор", "убиватор", "терминатор"
};
private static final String[] RU_NAME =
{
"олег", "вася", "дашка", "ленка", "федор", "санек", "толик", "серега", "витек", "петруха",
"колян", "димон", "настя", "машка", "гриша", "степан", "юрец", "макс",
"жека", "вован", "толян", "саня", "диман", "леха", "андрюха", "кирюха", "паша", "стас",
"руслан", "тимур", "натаха", "светка", "танюха", "ирка", "вовчик", "петрович", "палыч"
};
private static final String[] RU_SURNAME =
{
"пупкин", "сидоров", "петров", "иванов", "смирнов", "кузнецов", "козлов", "волков", "зайцев", "медведев"
};
private static final String[] PREFIXES =
{
"MC", "Dj", "Mr"
};
private static final String[] EN_ADJ =
{
"Dark", "Shadow", "Storm", "Night", "Blood", "Ghost", "Crazy", "Old", "Swift", "Silent",
"Grim", "Mad", "Evil", "Frost", "Iron", "Black", "Wild", "Lone", "Sweet", "True"
};
private static final String[] EN_NOUN =
{
"Slayer", "Reaper", "Hunter", "Killer", "Knight", "Rogue", "Monk", "Rider", "Arrow", "Blade",
"Master", "Lord", "King", "Sniper", "Mage", "Angel", "Demon", "Raven", "Wolf", "Dragon",
"Fire", "Ice", "Death", "Star", "Dream", "Soul", "Fang", "Claw", "Bishop", "Titan"
};
private static final String RU_LETTERS = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
/** Транслит: кириллица -> латиница. */
private static final String[] TRANSLIT =
{
"a", "b", "v", "g", "d", "e", "e", "zh", "z", "i", "j", "k", "l", "m", "n", "o", "p", "r",
"s", "t", "u", "f", "h", "c", "ch", "sh", "sch", "", "y", "", "e", "yu", "ya"
};
/** Псевдокириллица: латинские двойники (null = нет похожего, берётся транслит). */
private static final String[] FAKE_CYR =
{
"a", "6", "B", "r", null, "e", "e", null, "3", "u", "u", "K", "JI", "M", "H", "o", "II", "p",
"c", "T", "y", null, "x", null, "4", "LLI", null, "", "bI", "b", "3", "IO", "9I"
};
private static final Map<Character, String> TRANSLIT_MAP = new HashMap<>();
private static final Map<Character, String> FAKE_CYR_MAP = new HashMap<>();
private static final Map<Character, Character> LEET_MAP = new HashMap<>();
private static final Pattern VALID = Pattern.compile("[A-Za-z0-9]{3,16}");
static
{
for (int i = 0; i < RU_LETTERS.length(); i++)
{
TRANSLIT_MAP.put(RU_LETTERS.charAt(i), TRANSLIT[i]);
FAKE_CYR_MAP.put(RU_LETTERS.charAt(i), FAKE_CYR[i]);
}
LEET_MAP.put('e', '3');
LEET_MAP.put('o', '0');
LEET_MAP.put('a', '4');
LEET_MAP.put('i', '1');
LEET_MAP.put('s', '5');
}
private final Random _rnd;
private final boolean _translit;
private final boolean _fakeCyr;
private final boolean _leet;
private final boolean _digits;
private final boolean _wrap;
private final int[] _weights;
public FakePlayerNameGenerator()
{
this(new Random());
}
public FakePlayerNameGenerator(long seed)
{
this(new Random(seed));
}
public FakePlayerNameGenerator(Random rnd)
{
this(rnd, true, true, true, true, true, new int[]
{
25, 55, 20
});
}
/**
* @param rnd источник случайности
* @param translit разрешить транслит русских слов
* @param fakeCyr разрешить псевдокириллицу
* @param leet разрешить l33t-цифры в английских словах
* @param digits разрешить цифровой суффикс
* @param wrap разрешить обёртку xX...Xx
* @param weights веса числа частей ника {1 часть, 2 части, 3 части}
*/
public FakePlayerNameGenerator(Random rnd, boolean translit, boolean fakeCyr, boolean leet, boolean digits, boolean wrap, int[] weights)
{
_rnd = rnd;
_translit = translit;
_fakeCyr = fakeCyr;
_leet = leet;
_digits = digits;
_wrap = wrap;
_weights = weights;
}
private boolean chance(int pct)
{
return _rnd.nextInt(100) < pct;
}
private String pick(String[] arr)
{
return arr[_rnd.nextInt(arr.length)];
}
private String translitWord(String word)
{
final StringBuilder sb = new StringBuilder();
for (int i = 0; i < word.length(); i++)
{
final char ch = word.charAt(i);
String t = TRANSLIT_MAP.getOrDefault(ch, String.valueOf(ch));
if ((ch == 'й') && chance(50))
{
t = "y";
}
if ((ch == 'в') && chance(15))
{
t = "w"; // Wowan-стиль
}
if (ch == 'х')
{
t = chance(40) ? "x" : (chance(30) ? "kh" : "h"); // Xyligan-стиль
}
sb.append(t);
}
if (sb.length() > 0)
{
sb.setCharAt(0, Character.toUpperCase(sb.charAt(0)));
}
return sb.toString();
}
private String fakeCyrWord(String word)
{
final StringBuilder sb = new StringBuilder();
for (int i = 0; i < word.length(); i++)
{
final char ch = word.charAt(i);
final String f = FAKE_CYR_MAP.get(ch);
if (f != null)
{
sb.append(f);
}
else if (FAKE_CYR_MAP.containsKey(ch))
{
sb.append(TRANSLIT_MAP.get(ch)); // нет двойника — транслит буквы
}
else
{
sb.append(ch);
}
}
return sb.toString();
}
private String leetWord(String word)
{
final StringBuilder sb = new StringBuilder();
int used = 0;
for (int i = 0; i < word.length(); i++)
{
final char ch = word.charAt(i);
final Character l = LEET_MAP.get(Character.toLowerCase(ch));
if ((l != null) && (used < 2) && chance(45))
{
sb.append(l);
used++;
}
else
{
sb.append(ch);
}
}
return sb.toString();
}
private boolean isRussian(String word)
{
for (int i = 0; i < word.length(); i++)
{
if (RU_LETTERS.indexOf(word.charAt(i)) >= 0)
{
return true;
}
}
return false;
}
/** Часть ника: слово + флаг "это фамилия" (для декорации -ov -> -OFF). */
private static final class Part
{
private final String _word;
private final boolean _surname;
Part(String word, boolean surname)
{
_word = word;
_surname = surname;
}
String word()
{
return _word;
}
boolean surname()
{
return _surname;
}
}
private List<Part> buildParts(boolean russian, int n)
{
final List<Part> parts = new ArrayList<>(3);
if (russian)
{
switch (n)
{
case 1:
{
final int v = _rnd.nextInt(100);
if (v < 45)
{
parts.add(new Part(pick(RU_NOUN), false));
}
else if (v < 80)
{
parts.add(new Part(pick(RU_NAME), false));
}
else
{
parts.add(new Part(pick(RU_SURNAME), true));
}
break;
}
case 2:
{
final int v = _rnd.nextInt(4);
if (v == 0)
{
parts.add(new Part(pick(RU_ADJ), false));
parts.add(new Part(pick(RU_NOUN), false));
}
else if (v == 1)
{
parts.add(new Part(pick(RU_ADJ), false));
parts.add(new Part(pick(RU_NAME), false));
}
else if (v == 2)
{
parts.add(new Part(pick(RU_NOUN), false));
parts.add(new Part(pick(RU_NOUN), false));
}
else
{
// VasyaPupkin
parts.add(new Part(pick(RU_NAME), false));
parts.add(new Part(pick(RU_SURNAME), true));
}
break;
}
default:
{
parts.add(new Part(pick(RU_ADJ), false));
if (chance(50))
{
parts.add(new Part(pick(RU_NOUN), false));
parts.add(new Part(pick(RU_NOUN), false));
}
else
{
parts.add(new Part(pick(RU_NAME), false));
parts.add(new Part(pick(RU_SURNAME), true));
}
break;
}
}
}
else
{
switch (n)
{
case 1:
{
parts.add(new Part(pick(EN_NOUN), false));
break;
}
case 2:
{
parts.add(new Part(pick(EN_ADJ), false));
parts.add(new Part(pick(EN_NOUN), false));
break;
}
default:
{
final String first = pick(EN_ADJ);
String second;
if (chance(50))
{
do
{
second = pick(EN_ADJ);
}
while (second.equals(first));
}
else
{
second = pick(EN_NOUN);
}
parts.add(new Part(first, false));
parts.add(new Part(second, false));
parts.add(new Part(pick(EN_NOUN), false));
break;
}
}
}
return parts;
}
/**
* @return один ник в стиле сида (валидность НЕ гарантируется — используй {@link #generateBatch(int, Set)}).
*/
public String generateOne()
{
// Число частей по весам.
final int total = Math.max(_weights[0] + _weights[1] + _weights[2], 1);
final int roll = _rnd.nextInt(total);
final int n = (roll < _weights[0]) ? 1 : (roll < (_weights[0] + _weights[1])) ? 2 : 3;
final boolean russian = chance(70);
final List<Part> parts = buildParts(russian, n);
// Редкая смесь языков в 2-3-частных никах.
if ((n > 1) && chance(15))
{
parts.set(parts.size() - 1, new Part(russian ? pick(EN_NOUN) : translitWord(pick(RU_NOUN)), false));
}
// Режим рендера русских слов — один на весь ник.
final List<String> modes = new ArrayList<>(2);
if (_translit)
{
modes.add("translit");
}
if (_fakeCyr)
{
modes.add("fakeCyr");
}
final String ruMode = modes.isEmpty() ? "translit" : modes.get(_rnd.nextInt(modes.size()));
final StringBuilder sb = new StringBuilder();
for (Part part : parts)
{
final String word = part.word();
if (isRussian(word))
{
if ("fakeCyr".equals(ruMode))
{
sb.append(fakeCyrWord(word));
}
else
{
String t = translitWord(word);
// Фамилии на -ov: SmirnOFF / Smirnoff.
if (part.surname() && t.toLowerCase().endsWith("ov") && chance(50))
{
t = t.substring(0, t.length() - 2) + (chance(40) ? "OFF" : "off");
}
sb.append(t);
}
}
else
{
sb.append((_leet && chance(20)) ? leetWord(word) : word);
}
}
String nick = sb.toString();
// Декорации (стиль сида: dashka, ZloyPES, HolodOK, OldRogue38, MCVovan95, xXDarkSlayerXx).
if ("translit".equals(ruMode))
{
if (chance(12))
{
nick = nick.toLowerCase();
}
else if (chance(12) && (nick.length() > 4))
{
final int k = 2 + _rnd.nextInt(2);
nick = nick.substring(0, nick.length() - k) + nick.substring(nick.length() - k).toUpperCase();
}
}
if (_digits && chance(28) && (nick.length() <= 14))
{
final int v = _rnd.nextInt(100);
final String suf;
if (v < 45)
{
suf = chance(50) ? String.valueOf(1 + _rnd.nextInt(9)) : String.valueOf(10 + _rnd.nextInt(90));
}
else if (v < 80)
{
suf = String.format("%02d", (85 + _rnd.nextInt(23)) % 100); // год рождения 85..07
}
else
{
suf = String.valueOf(1985 + _rnd.nextInt(23)); // 1985..2007
}
if ((nick.length() + suf.length()) <= 16)
{
nick += suf;
}
}
if (chance(4) && (nick.length() <= 13))
{
nick += "RUS";
}
boolean prefixed = false;
if (chance(6) && (nick.length() <= 14))
{
nick = pick(PREFIXES) + nick; // MCVovan
prefixed = true;
}
if (_wrap && !prefixed && chance(4) && (nick.length() <= 12))
{
nick = "xX" + nick + "Xx";
}
return nick;
}
/**
* @param count сколько ников сгенерировать
* @param taken занятые имена в lowercase (пополняется); null — только уникальность внутри батча
* @return count уникальных ников, каждый соответствует [A-Za-z0-9]{3,16}
*/
public List<String> generateBatch(int count, Set<String> taken)
{
final Set<String> used = (taken != null) ? taken : new HashSet<>();
final List<String> out = new ArrayList<>(count);
int guard = count * 200;
while ((out.size() < count) && (guard-- > 0))
{
final String nick = generateOne();
if (VALID.matcher(nick).matches() && !used.contains(nick.toLowerCase()))
{
used.add(nick.toLowerCase());
out.add(nick);
}
}
return out;
}
public static void main(String[] args)
{
final int count = (args.length > 0) ? Integer.parseInt(args[0]) : 30;
final FakePlayerNameGenerator gen = (args.length > 1) ? new FakePlayerNameGenerator(Long.parseLong(args[1])) : new FakePlayerNameGenerator();
for (String nick : gen.generateBatch(count, null))
{
System.out.println(nick);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
# L2 Interlude SPP - запуск одной командой (Docker-вариант).
# Использование:
# powershell -ExecutionPolicy Bypass -File start.ps1 # поднять всё
# powershell -ExecutionPolicy Bypass -File start.ps1 -Down # остановить
# powershell -ExecutionPolicy Bypass -File start.ps1 -Reset # остановить и стереть базу (полный сброс)
# powershell -ExecutionPolicy Bypass -File start.ps1 -Client "D:\Games\L2Interlude" # + запустить клиент
param(
[switch]$Down,
[switch]$Reset,
[string]$Client = ""
)
$ErrorActionPreference = "Stop"
$Base = $PSScriptRoot
$ServerDir = Join-Path $Base "server"
$Zip = Join-Path $Base "server_interlude.zip"
$Compose = Join-Path $ServerDir "docker\docker-compose.yml"
function Compose { docker compose -f $Compose @args }
# --- Docker есть? ---
try { docker info *>$null } catch {
Write-Host "Docker не запущен или не установлен. Поставь Docker Desktop и запусти его." -ForegroundColor Red
exit 1
}
# --- Остановка / сброс ---
if ($Down -or $Reset) {
if (Test-Path $Compose) {
if ($Reset) {
Compose down -v
Write-Host "Остановлено, база стёрта. Следующий запуск начнёт с чистой базы (боты - со стартовых уровней)." -ForegroundColor Yellow
} else {
Compose down
Write-Host "Остановлено. Прогресс ботов и персонажи сохранены." -ForegroundColor Green
}
} else {
Write-Host "Сервер ещё не разворачивался, останавливать нечего."
}
exit 0
}
# --- Распаковка сервера при первом запуске ---
if (-not (Test-Path (Join-Path $ServerDir "game"))) {
if (-not (Test-Path $Zip)) {
Write-Host "Не найден $Zip" -ForegroundColor Red
exit 1
}
Write-Host "Распаковываю сервер (~700 МБ, минуту-другую)..."
New-Item -ItemType Directory -Force -Path $ServerDir | Out-Null
Expand-Archive -Path $Zip -DestinationPath $Base -Force
# в архиве корневая папка server_interlude - переименуем в server
if (Test-Path (Join-Path $Base "server_interlude\game")) {
if (Test-Path $ServerDir) { Remove-Item $ServerDir -Recurse -Force }
Rename-Item (Join-Path $Base "server_interlude") $ServerDir
}
}
# --- Docker-файлы внутрь папки сервера ---
if (-not (Test-Path $Compose)) {
Copy-Item -Recurse -Force (Join-Path $Base "docker") (Join-Path $ServerDir "docker")
}
# --- Поднимаем ---
Write-Host "Собираю и запускаю контейнеры (первый раз - несколько минут)..."
Compose up --build -d
# --- Ждём готовности гейм-сервера ---
Write-Host "Жду старта гейм-сервера (загрузка геодаты ~1 мин)..."
$deadline = (Get-Date).AddMinutes(10)
$ready = $false
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
$logs = Compose logs game --tail 100 2>$null | Out-String
if ($logs -match "Registered on login") { $ready = $true; break }
if ($logs -match "OutOfMemoryError") {
Write-Host "Гейм-серверу не хватило памяти. Docker Desktop -> Settings -> Resources: дай WSL2 минимум 6 ГБ RAM." -ForegroundColor Red
exit 1
}
}
if ($ready) {
Write-Host ""
Write-Host "=== Сервер готов ===" -ForegroundColor Green
Write-Host "Логин-сервер: 127.0.0.1:2106"
Write-Host "В клиенте: system\l2.ini -> ServerAddr=127.0.0.1, запускать system\l2.exe"
Write-Host "Аккаунт: любые логин/пароль на экране входа - создастся сам."
Write-Host ""
Write-Host "Админка (после первого входа в игру):"
Write-Host " docker compose -f `"$Compose`" exec db mariadb -uroot -pl2j l2jmobiusinterlude -e `"UPDATE accounts SET accessLevel=100 WHERE login='ЛОГИН'`"" -ForegroundColor DarkGray
Write-Host "Логи: docker compose -f `"$Compose`" logs -f game"
Write-Host "Стоп: powershell -File start.ps1 -Down"
} else {
Write-Host "Гейм-сервер не отрапортовал готовность за 10 минут. Смотри логи:" -ForegroundColor Red
Write-Host " docker compose -f `"$Compose`" logs game --tail 50"
exit 1
}
# --- Клиент ---
if ($Client -ne "") {
$l2exe = Join-Path $Client "system\l2.exe"
if (Test-Path $l2exe) {
Write-Host "Запускаю клиент..."
Start-Process -FilePath $l2exe -WorkingDirectory (Join-Path $Client "system")
} else {
Write-Host "Не нашёл $l2exe - проверь путь к клиенту." -ForegroundColor Yellow
}
}