From 165820c041c5c6cb05a2561dc82fd2aa27789c24 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 3 Sep 2026 20:39:50 +0200 Subject: [PATCH] Bots really play: shared understanding for headless bots, social layer (bot-to-bot talk, squads, invites), castle sieges, economy (sell/buy/professions/jewelry/buffs), telemetry, brain (loot, fight back, leash, spoil/sweep), 43 combat profiles, zones rebuilt from real spawns, tools (harness, data checks, zone fixer, build/deploy) --- build.ps1 | 46 + .../dist/game/config/Custom/FakePlayers.ini | 32 +- .../dist/game/data/FakePlayerChatLines.xml | 227 ++ .../dist/game/data/FakePlayerCombat.xml | 365 +- .../dist/game/data/FakePlayerIntents.xml | 17 +- .../dist/game/data/FakePlayerPhrases.xml | 2 +- .../dist/game/data/FakePlayerPools.xml | 2 +- .../dist/game/data/FakePlayerProgression.xml | 3399 +++++++++-------- .../handlers/chat/channels/ChatGeneral.java | 10 +- .../dist/game/data/xsd/FakePlayerChatData.xsd | 20 + .../dist/game/data/xsd/FakePlayerCombat.xsd | 12 + .../dist/game/data/xsd/FakePlayerIntents.xsd | 12 + .../dist/game/data/xsd/FakePlayerPhrases.xsd | 12 + .../dist/game/data/xsd/FakePlayerPools.xsd | 12 + .../game/data/xsd/FakePlayerProgression.xsd | 12 + .../org/l2jmobius/gameserver/GameServer.java | 4 + .../config/custom/FakePlayersConfig.java | 18 + .../gameserver/entity/actor/Attackable.java | 10 + .../gameserver/entity/actor/Player.java | 6 +- .../managers/FakePlayerBotContext.java | 46 +- .../gameserver/managers/FakePlayerBotRef.java | 263 ++ .../managers/FakePlayerBotState.java | 116 + .../gameserver/managers/FakePlayerBrain.java | 324 +- .../managers/FakePlayerChatManager.java | 239 +- .../managers/FakePlayerDashboard.java | 2 + .../managers/FakePlayerEconomy.java | 478 +++ .../managers/FakePlayerHeadlessManager.java | 1751 +++++---- .../managers/FakePlayerHeardManager.java | 37 +- .../managers/FakePlayerIntentParser.java | 36 +- .../FakePlayerProgressionManager.java | 20 + .../managers/FakePlayerSiegeManager.java | 732 ++++ .../gameserver/managers/FakePlayerSocial.java | 751 ++++ .../managers/FakePlayerTelemetry.java | 252 ++ .../managers/FakePlayerUnderstanding.java | 411 ++ tools/build.sh | 8 + tools/check_data.py | 184 + tools/deploy.sh | 26 + tools/fix_zones.py | 194 + tools/harness/ChatHarness.java | 221 ++ tools/run_harness.sh | 24 + 40 files changed, 7634 insertions(+), 2699 deletions(-) create mode 100644 build.ps1 create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerChatData.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerCombat.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerIntents.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPhrases.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPools.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerProgression.xsd create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotRef.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotState.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerEconomy.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java create mode 100644 src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java create mode 100644 tools/build.sh create mode 100644 tools/check_data.py create mode 100644 tools/deploy.sh create mode 100644 tools/fix_zones.py create mode 100644 tools/harness/ChatHarness.java create mode 100644 tools/run_harness.sh diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..11cf632 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,46 @@ +# Сборка и деплой SPP-сервера одной командой (Windows). +# powershell -ExecutionPolicy Bypass -File build.ps1 # ant jar + деплой в server\ + restart game +# powershell -ExecutionPolicy Bypass -File build.ps1 -NoRestart # только собрать и разложить файлы +# powershell -ExecutionPolicy Bypass -File build.ps1 -DeployOnly # без сборки: только данные/конфиги/скрипты +# Нужны JDK 25 (JAVA_HOME) и Apache Ant в PATH. +param( + [switch]$NoRestart, + [switch]$DeployOnly +) +$ErrorActionPreference = "Stop" +$Base = $PSScriptRoot +$Src = Join-Path $Base "src_mobius\mobius\L2J_Mobius_CT_0_Interlude" +$Build = Join-Path $Base "src_mobius\mobius\build\dist\libs" +$Server = Join-Path $Base "server" +$Compose = Join-Path $Server "docker\docker-compose.yml" + +if (-not $DeployOnly) { + Push-Location $Src + try { + Write-Host "ant jar..." -ForegroundColor Cyan + ant -q jar + if ($LASTEXITCODE -ne 0) { throw "ant failed" } + } finally { Pop-Location } + Copy-Item (Join-Path $Build "GameServer.jar") (Join-Path $Server "libs\GameServer.jar") -Force + Copy-Item (Join-Path $Build "LoginServer.jar") (Join-Path $Server "libs\LoginServer.jar") -Force + Write-Host "jar -> server\libs" -ForegroundColor Green +} + +# Оверлей датапака: данные ботов, конфиги, чат-хендлеры, SQL-сиды. libs не копируем (это зависимости сборки). +$Dist = Join-Path $Src "dist" +Get-ChildItem -Path $Dist -Recurse -File | Where-Object { $_.FullName -notlike "*\dist\libs\*" } | ForEach-Object { + $rel = $_.FullName.Substring($Dist.Length + 1) + $dst = Join-Path $Server $rel + New-Item -ItemType Directory -Force -Path (Split-Path $dst) | Out-Null + Copy-Item $_.FullName $dst -Force +} +Write-Host "оверлей -> server\ (data, config, scripts, sql)" -ForegroundColor Green + +if (-not $NoRestart) { + if (Test-Path $Compose) { + docker compose -f $Compose restart game + Write-Host "game перезапущен. Лог: docker compose -f server\docker\docker-compose.yml logs -f game" -ForegroundColor Green + } else { + Write-Host "docker-compose не найден, перезапусти сервер вручную." -ForegroundColor Yellow + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/config/Custom/FakePlayers.ini b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/config/Custom/FakePlayers.ini index 9116092..80c4f19 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/config/Custom/FakePlayers.ini +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/config/Custom/FakePlayers.ini @@ -99,5 +99,35 @@ FakePlayerTradeChance = 30 # # 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. +# 0 = disabled. FakePlayerHeadlessCount = 500 + +# Who answers first when a player talks to a bot: "seams" - the template engine +# (idiolect, memory, stance; the LLM is used only for messages no intent matched), +# "llm" - the LLM answers everything, templates are the fallback. +FakePlayerLlmPriority = seams + +# Bots talk to each other (greetings, small talk, gossip) when a real player can see it. +FakePlayerBotDialogues = True + +# Real economy: sell junk to the merchant, buy shots / arrows / potions with own adena. +FakePlayerEconomy = True + +# Bots take their first / second / third profession at 20 / 40 / 76 like players. +FakePlayerProfessionChange = True + +# Bot clans register for castle sieges and fight them (attackers break the gates, +# the clan leader engraves the artifact; defenders hold it). +FakePlayerSieges = True + +# How many bot clans attack each castle siege. +FakePlayerSiegeAttackerClans = 2 + +# Also besiege castles owned by real players' clans. +FakePlayerSiegeAttackPlayerCastles = False + +# Print bot activity totals (kills, exp, loot, trades, chat...) to the log every N minutes. 0 = off. +FakePlayerStatsLogMinutes = 10 + +# Write every line the bots say (channel, name, seam, text) to the server log. +FakePlayerChatLog = False diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerChatLines.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerChatLines.xml index 3a67f2d..52defdd 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerChatLines.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerChatLines.xml @@ -1157,6 +1157,20 @@ {player}, гг тогда было + + осада [идет], не до фарма + на осаде [сейчас], потом [поболтаем] + {castle} [сегодня] берем[, весь клан там] + + + [только] с осады[, устал] + осада была [жесть][, еле выжил] + после осады [опять] на спот[, скучно] + + + профу [только] взял[, привыкаю к скиллам] + новые скиллы [после профы] [прям] огонь + #smalltalk# #grounded_talk# кто спот застолбил тут @@ -1580,6 +1594,219 @@ все на сегодня, бывай бывай, братва, го дальше + + + {player} как фарм[, идет]? + {player} [ты] давно тут [стоишь]? + {player} го (в пати|вместе)[, быстрее будет] + {player} дроп есть [какой-нибудь]? + {player} какой лвл [у тебя]? + {player} где рб видел[, не знаешь]? + слышь {player}, [а] ты (соло|один) [качаешься]? + {player} как дела[, что нового]? + {player} почем [сейчас] соски [берешь]? + {player} [ты] откуда [сам]? + {player} не видел [тут] пкшеров? + {player} сколько [еще] фармить будешь? + {player} где качаться [дальше] думаешь? + {player} #ask_back# + эй {player}, скучно [тут] одному[, поговорим] + {player} бля, как оно [вообще]? + {player} хай, как фарм гоес? + {player} здарова, как жизнь молодая? + + {player} я {level} взял [только что][, кайф] + {player} видел? {level} [лвл] уже + + + {player} тут пк ходит[, осторожно] + {player} меня [только что] гангнули[, видел кто]? + + + {player} [а] ты не продаешь [ничего]? + {player} у меня лавка [стоит], зацени + + + + [пока] пусто[, {player}] + {drop} [упал] [только что] + дроп так себе[, {drop} и все] + с {mob} ничего [толком] не падает + норм капает[, {drop} вот] + ничего [особенного], мусор [один] + {drop} только[, и то один] + ресы [в основном][, на продажу] + [да] ничего[, ты как]? + дроп есть, [но] не скажу [какой] ~lol~ + хрен [там] а не дроп + + + {clan} [идет] на {castle}[, всем сбор] + осада {castle}, [ну] погнали[, {clan}] + {castle} [сегодня] наш [будет] + {clan} у ворот {castle}[, готовьте печать] + на {castle}[, все] за мной + ворота {castle} [сейчас] ломаем + {castle}, [мы] пришли за тобой + {clan} на осаде[, го го] + кто держит {castle}, выходите [драться] + осада началась, {clan} [в деле] + ну что, {castle}, [сейчас] посмотрим кто сильнее + {castle} наш, [нахрен] всех у ворот + гоу гоу {castle}, {clan} атакует + за {clan}, братва, {castle} берем + + + {castle} [никому] не отдадим + {clan} держит {castle}[, подходите] + защита {castle}[, все] к артефакту + ворота [пока] стоят, {castle} наш + кто [там] лезет на {castle}[, идите домой] + {clan} на стенах[, ждем гостей] + {castle} держим до конца + [все] к печати, [никого] не пускаем + осада {castle}, защищаем [свое] + {castle} хрен [вам] а не замок + за {clan}, [пацаны], держим {castle} + + + ворота [почти] лежат[, го дальше] + бьем ворота [все вместе] + ворота {castle} [сейчас] упадут + [все] по воротам, [не] отвлекаемся + дожимаем ворота[, потом печать] + ворота [еще] стоят[, бьем] + {clan} [все] на ворота + ломаем [и] заходим + ворота [бля] крепкие, бьем [дальше] + + + печать [пошла], прикройте [меня] + кастую печать, [никого] не пускать + три минуты, держите [артефакт] + печать [на артефакте], {castle} [почти] наш + лидер кастует[, все] вокруг него + прикрываем печать[, {castle} наш] + + + {castle} наш[, гг] + {clan} взял {castle}[, красавцы] + гг [всем], {castle} [теперь] наш + [ну все], {castle} у {clan} + отстояли {castle}[, гг] + {castle} остается [за нами][, гг] + победа[, {castle}] + гг, [хорошая] осада + {castle} наш [нахрен], гг + изи, {castle} out[, гг] + {castle} наш, братва[, гг] + + + [ну] не вышло [с {castle}] + {castle} не взяли[, в следующий раз] + гг, [слили] осаду + проиграли {castle}[, обидно] + [все], {castle} [пока] не наш + отдали {castle}[, позор] + гг [хоть] подрались + {castle} слили[, через две недели вернемся] + {castle} [бля] слили[, гг] + гг вп, {castle} лост + + + [все], профу взял[, теперь {class}] + {class} [теперь][, кайф] + профу [наконец] сделал + [ееее] я {class}[, го дальше] + квест на профу [наконец] закрыт + [ну вот], {class}[, теперь] качаемся дальше + профа [есть], скиллы новые[, зацените] + с профой [меня][, {class}] + [бля] наконец профа, {class} + гц ми, {class} нау + + + [ну и] {mob} [меня] сложил + помер [от] {mob}[, позор] + {mob} [это] что-то [с чем-то] + слился [от] {mob}[, не заметил] + {mob} [меня] вынес[, хилок не было] + [все], [я] труп[, {mob} сильный] + {mob} [нафармил] меня ~lol~ + рес есть [у кого]? {mob} [меня] убил + {mob} [сука] убил [меня] + + + {drop} [упал][, зацените] + о, {drop}[, наконец] + {drop} [с {mob}][, повезло] + [ееее] {drop} + {drop} выбил[, продам] + {drop} [бля] выпал[, наконец] + + + [да], {clan}[, а что] + {clan} [у меня][, норм клан] + в {clan} состою[, {player}] + {clan}, [мы] тут все [стоим] + [ага], {clan}[, зайди к нам] + клан есть, {clan}[, а ты]? + без клана [пока][, ищу] + [пока] нет [клана][, зовут - не иду] + ищу клан[, если что] + {clan} [бля], лучший клан + + ~lol~ [ты чего], мы ж в одном [клане] + {player}, [ты] в нашем клане [вообще-то] + + + + соло[, да][, {player}] + [пока] один[, пати не нашел] + соло фармлю[, так быстрее] + один[, а что][, го вместе]? + с кланом [обычно], сейчас [вот] один + соло, [тут] пати не нужна + [ну] один, [и] что? + одному [тут] норм[, никто не мешает] + в пати [сейчас][, но народ афк] + один [бля], [все] разбежались + + + [уже] пару часов [тут] + с утра [стою][, {player}] + [да] только пришел + [минут] сорок [примерно] + давно[, {level} тут взял] + с {level} [лвла] тут [фармлю] + [ну] час где-то + [уже] не помню[, долго] + недавно[, а что]? + [еще] пару лвлов [и] уйду [отсюда] + + + [пока] тихо[, никого] + не видел [сегодня][, вроде чисто] + [был] один [красный], ушел [в сторону {zone}] + ходит [тут] кто-то[, осторожнее] + тут [всегда] спокойно[, фармь] + [вроде] чисто[, но ты] смотри [по сторонам] + пкшеры [обычно] вечером [приходят] + если что, [я] рядом[, кричи] + + [только что] был[, меня и убил] + да[, меня] гангнули [только что][, {player}] + есть один [урод][, ищу его] + + + [если] увидишь - скажи[, я их ловлю] + [я] за этим и стою [тут] + + + [а] что, боишься? ~lol~ + [тут] только я ~lol~ + + #store_goods#[85:, #store_price_word#][45:, #store_mood#] diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerCombat.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerCombat.xml index 2c41108..16fdbf3 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerCombat.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerCombat.xml @@ -2,95 +2,342 @@ - - - + + + - - - - - - - + + + + - - - - + + + + - - - - + + + - - - - + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + - - - - - - - - - - - - + + + + + + + + - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + - - - - - - - + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerIntents.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerIntents.xml index bbd20f9..ba36dc4 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerIntents.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerIntents.xml @@ -2,19 +2,24 @@ - + бот|боты|скрипт|макрос|программ лох|лош|нуб|дурак|дура|дебил|идиот|туп|мусор|чмо|клоун|днище|бомж|крыса|слаб|отстой|пшел|пшла|заткни спс|спасиб|пасиб|благодар|сенкс|thx|ty|респект|красав - дуэль|дюль|1в1|1x1|1х1|соло|выйдем|стыкнемся|пвп - помо|хелп|помощ|хиль|отхил|хил|рес|ресни|спаси|выруч|бафни|баф|подсоб + дуэль|дюль|1в1|1x1|1х1|выйдем|стыкнемся|=пвп|=дуель + помо|помг|помаг|памаг|хелп|хэлп|help|помощ|хиль|отхил|=хил|=рес|ресни|спаси|выруч|бафни|=баф|подсоб пати|парти|групп|пл|инвайт|прими|возьми какой|скок|сколько|че|чолвл|левел|уровен|лева - почем|цена|цену|купи|продай|продаш|скупа|стоит|аден - что|чо|че|какие|слышнов|слыш|происход|творится|интересн + почем|цена|цену|купи|продай|продаш|скупа|=стоит|=стоят|аден|=цен + что|чо|че|какие|слыхал|слышал|слышно|=есть=нов|слух|происход|творится|интересн|расскаж|=новост + дроп|падает|капает|выпал|выбил|лут где|куда|далеко + клан|кланы|=кп|гильд + =соло|один|одна|=сам|=сама|одиноч + давно|=долго|сколько=тут|=здесь|играешь|стоишь|фармишь|=на серв|качаешь + =пк|пкшер|ганк|гангер|=красн|убийц|опасно пока|бб|бай|досвид|бывай|споки|удачи как|че|чо|чтодела|сам|оно|как|жизнь|делаешь|поживаешь|фарм|настрой - привет|прив|хай|здаров|здоров|ку|йо|хелло|дратути|салют|даров + привет|превет|прив|хай|здаров|здоров|здраст|=ку|=йо|хелло|дратути|салют|даров|дароу|=хи|hi|hello да|ага|ок|окей|норм|пон|ясн|угу|лан|ладно diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPhrases.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPhrases.xml index 3ee83cc..0717ade 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPhrases.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPhrases.xml @@ -6,7 +6,7 @@ Pool lookup tries "pool.personality" first, then "pool". When a phrase key is missing here, the flat seam with the same key is used. --> - + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPools.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPools.xml index 1a7ff83..7e7e8a1 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPools.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerPools.xml @@ -1,5 +1,5 @@ - + #threat_verb#[ #threat_when#] #threat_verb#[, #threat_when#] diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerProgression.xml b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerProgression.xml index abd16d0..265651f 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerProgression.xml +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/FakePlayerProgression.xml @@ -1,5 +1,5 @@ - + @@ -78,617 +78,605 @@ - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -699,10 +687,8 @@ - - @@ -714,17 +700,11 @@ - - - - - - @@ -738,7 +718,6 @@ - @@ -747,15 +726,12 @@ - - - @@ -763,9 +739,7 @@ - - @@ -775,7 +749,6 @@ - @@ -787,11 +760,9 @@ - - @@ -810,29 +781,42 @@ - + + + + + + + + + + + + + + + + + + + + + - - - - - - - @@ -845,7 +829,6 @@ - @@ -854,16 +837,13 @@ - - - @@ -877,7 +857,6 @@ - @@ -893,7 +872,6 @@ - @@ -901,11 +879,9 @@ - - @@ -915,14 +891,11 @@ - - - @@ -932,130 +905,144 @@ - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1065,12 +1052,9 @@ - - - @@ -1094,42 +1078,31 @@ - - - - - - - - - - - @@ -1138,7 +1111,6 @@ - @@ -1147,28 +1119,21 @@ - - - - - - - @@ -1176,36 +1141,29 @@ - + - - - - - - - @@ -1213,7 +1171,6 @@ - @@ -1221,14 +1178,12 @@ - - @@ -1238,18 +1193,14 @@ - - - - @@ -1257,11 +1208,9 @@ - - @@ -1270,16 +1219,13 @@ - - - @@ -1287,52 +1233,58 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - @@ -1343,16 +1295,13 @@ - - - @@ -1361,10 +1310,8 @@ - - @@ -1373,7 +1320,6 @@ - @@ -1382,13 +1328,10 @@ - - - @@ -1405,9 +1348,6 @@ - - - @@ -1415,58 +1355,93 @@ - - - - - + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - @@ -1474,169 +1449,158 @@ - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - @@ -1647,24 +1611,18 @@ - - - - - - - + @@ -1699,7 +1657,6 @@ - @@ -1786,187 +1743,209 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - @@ -1978,62 +1957,43 @@ - - - - - - - - - - - - - - - - - - - + - @@ -2041,18 +2001,14 @@ - - - - @@ -2069,19 +2025,15 @@ - - - - @@ -2110,7 +2062,6 @@ - @@ -2122,7 +2073,6 @@ - @@ -2137,7 +2087,6 @@ - @@ -2152,213 +2101,228 @@ - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2367,46 +2331,89 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - @@ -2414,77 +2421,51 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2495,142 +2476,108 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2638,105 +2585,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2747,25 +2723,41 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -2781,15 +2773,12 @@ - - - @@ -2808,18 +2797,14 @@ - - - - @@ -2835,28 +2820,17 @@ - - - - - - - - - - - @@ -2868,13 +2842,11 @@ - - @@ -2884,495 +2856,492 @@ - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -3381,102 +3350,61 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3486,15 +3414,50 @@ - - + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -3616,17 +3579,13 @@ - + - - - - @@ -3642,35 +3601,21 @@ - - - - - - - - - - - - - - @@ -3679,22 +3624,18 @@ - - - - @@ -3703,69 +3644,97 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - - + + + + + + + + + + + + + - - - - @@ -3773,125 +3742,58 @@ - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3900,7 +3802,71 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4022,7 +3988,7 @@ - + @@ -4144,6 +4110,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/scripts/handlers/chat/channels/ChatGeneral.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/scripts/handlers/chat/channels/ChatGeneral.java index 4c6006b..32375a4 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/scripts/handlers/chat/channels/ChatGeneral.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/scripts/handlers/chat/channels/ChatGeneral.java @@ -124,14 +124,10 @@ public class ChatGeneral implements IChatHandler 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)) + // Headless bots nearby may react to general chat (one addressed by name always does). + if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && !activeChar.isHeadlessBot()) { - 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); - } + org.l2jmobius.gameserver.managers.FakePlayerSocial.getInstance().onPlayerSaid(activeChar, text); } // Fake players may react to nearby general chat. diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerChatData.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerChatData.xsd new file mode 100644 index 0000000..2b2c94e --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerChatData.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerCombat.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerCombat.xsd new file mode 100644 index 0000000..e065cb4 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerCombat.xsd @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerIntents.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerIntents.xsd new file mode 100644 index 0000000..9d78e79 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerIntents.xsd @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPhrases.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPhrases.xsd new file mode 100644 index 0000000..c2888a8 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPhrases.xsd @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPools.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPools.xsd new file mode 100644 index 0000000..82e6a47 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerPools.xsd @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerProgression.xsd b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerProgression.xsd new file mode 100644 index 0000000..fcffabd --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/dist/game/data/xsd/FakePlayerProgression.xsd @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/GameServer.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/GameServer.java index b1dca78..c0bde99 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/GameServer.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/GameServer.java @@ -130,6 +130,8 @@ 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.FakePlayerSiegeManager; +import org.l2jmobius.gameserver.managers.FakePlayerTelemetry; import org.l2jmobius.gameserver.managers.FakePlayerProgressionManager; import org.l2jmobius.gameserver.managers.FishingChampionshipManager; import org.l2jmobius.gameserver.managers.GlobalVariablesManager; @@ -355,6 +357,8 @@ public class GameServer SpawnData.getInstance(); FakePlayerProgressionManager.getInstance(); FakePlayerHeadlessManager.getInstance(); + FakePlayerSiegeManager.getInstance(); + FakePlayerTelemetry.getInstance(); DayNightSpawnManager.getInstance().trim().notifyChangeMode(); DimensionalRiftManager.getInstance(); RaidBossSpawnManager.getInstance(); diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/config/custom/FakePlayersConfig.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/config/custom/FakePlayersConfig.java index d57a317..5117a8a 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/config/custom/FakePlayersConfig.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/config/custom/FakePlayersConfig.java @@ -60,6 +60,15 @@ public class FakePlayersConfig public static int FAKE_PLAYER_TRADE_COOLDOWN; public static int FAKE_PLAYER_TRADE_SHOUT_INTERVAL; public static int FAKE_PLAYER_TRADE_CHANCE; + public static String FAKE_PLAYER_LLM_PRIORITY; + public static boolean FAKE_PLAYER_BOT_DIALOGUES; + public static boolean FAKE_PLAYER_ECONOMY; + public static boolean FAKE_PLAYER_PROFESSION_CHANGE; + public static boolean FAKE_PLAYER_SIEGES; + public static int FAKE_PLAYER_SIEGE_ATTACKER_CLANS; + public static boolean FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES; + public static int FAKE_PLAYER_STATS_LOG_MINUTES; + public static boolean FAKE_PLAYER_CHAT_LOG; public static void load() { @@ -92,5 +101,14 @@ public class FakePlayersConfig 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); + FAKE_PLAYER_LLM_PRIORITY = config.getString("FakePlayerLlmPriority", "seams").trim().toLowerCase(); + FAKE_PLAYER_BOT_DIALOGUES = config.getBoolean("FakePlayerBotDialogues", true); + FAKE_PLAYER_ECONOMY = config.getBoolean("FakePlayerEconomy", true); + FAKE_PLAYER_PROFESSION_CHANGE = config.getBoolean("FakePlayerProfessionChange", true); + FAKE_PLAYER_SIEGES = config.getBoolean("FakePlayerSieges", true); + FAKE_PLAYER_SIEGE_ATTACKER_CLANS = config.getInt("FakePlayerSiegeAttackerClans", 2); + FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = config.getBoolean("FakePlayerSiegeAttackPlayerCastles", false); + FAKE_PLAYER_STATS_LOG_MINUTES = config.getInt("FakePlayerStatsLogMinutes", 10); + FAKE_PLAYER_CHAT_LOG = config.getBoolean("FakePlayerChatLog", false); } } diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Attackable.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Attackable.java index 8d7ba11..c7ed21c 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Attackable.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Attackable.java @@ -331,6 +331,16 @@ public class Attackable extends Npc FakePlayerProgressionManager.getInstance().onBotKilled(this, killer.asPlayer()); } + // Headless bot kill counter (proof that bots really farm). + if ((killer != null) && !isFakePlayer()) + { + final Player headless = killer.asPlayer(); + if ((headless != null) && headless.isHeadlessBot()) + { + org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onKilledMonster(headless, this); + } + } + // Delayed notification. if (killer != null) { diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Player.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Player.java index 1f9d064..33e27b4 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Player.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/entity/actor/Player.java @@ -5032,10 +5032,10 @@ public class Player extends Playable return false; } - // Headless bot memory: remember the player who killed it. - if (_headlessBot && (killer != null) && (killer.asPlayer() != null) && (killer != this)) + // Headless bot: memory of the killer, death cry, rumor seed, telemetry. + if (_headlessBot && (killer != this)) { - org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onBotKilled(this, killer.asPlayer()); + org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onBotDied(this, killer); } if (isMounted()) diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotContext.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotContext.java index f90441f..c2f8ad7 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotContext.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotContext.java @@ -43,6 +43,7 @@ public class FakePlayerBotContext volatile String event = ""; volatile long eventUntil; volatile String boss = ""; + final Map grounding = new ConcurrentHashMap<>(); } private static final Map CONTEXTS = new ConcurrentHashMap<>(); @@ -119,14 +120,53 @@ public class FakePlayerBotContext } /** + * Sets a grounding slot of the bot ({zone}, {level}, {mob}, {town}, {crowd}, {drop}...) + * so any line can use it without the caller knowing about it. * @param botId bot character id. - * @return grounding slots that follow from the context ({boss} during a raid). + * @param key slot name. + * @param value slot value (null or empty removes the slot). + */ + public static void setSlot(int botId, String key, String value) + { + if (botId <= 0) + { + return; + } + final Context context = contextOf(botId); + if ((value == null) || value.isEmpty()) + { + context.grounding.remove(key); + } + else + { + context.grounding.put(key, value); + } + } + + /** + * @param botId bot character id. + * @return the bot's current plan ("farm" when none holds). + */ + public static String planOf(int botId) + { + final Context context = (botId > 0) ? CONTEXTS.get(botId) : null; + return ((context != null) && (System.currentTimeMillis() < context.planUntil)) ? context.plan : "farm"; + } + + /** + * @param botId bot character id. + * @return grounding slots that follow from the context ({boss} during a raid, registered slots). */ public static Map slots(int botId) { - final Map slots = new HashMap<>(1); + final Map slots = new HashMap<>(4); final Context context = (botId > 0) ? CONTEXTS.get(botId) : null; - if ((context != null) && (System.currentTimeMillis() < context.planUntil) && "raid".equals(context.plan) && !context.boss.isEmpty()) + if (context == null) + { + return slots; + } + slots.putAll(context.grounding); + if ((System.currentTimeMillis() < context.planUntil) && "raid".equals(context.plan) && !context.boss.isEmpty()) { slots.put("boss", context.boss); } diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotRef.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotRef.java new file mode 100644 index 0000000..141a2d5 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotRef.java @@ -0,0 +1,263 @@ +/* + * 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 org.l2jmobius.gameserver.entity.Location; +import org.l2jmobius.gameserver.entity.WorldObject; +import org.l2jmobius.gameserver.entity.actor.Attackable; +import org.l2jmobius.gameserver.entity.actor.Creature; +import org.l2jmobius.gameserver.entity.actor.Npc; +import org.l2jmobius.gameserver.entity.actor.Player; +import org.l2jmobius.gameserver.entity.actor.instance.Monster; +import org.l2jmobius.gameserver.entity.clan.Clan; + +/** + * One view over the two kinds of bots (NPC fake players and headless + * players) for the chat understanding layer: identity, situation and the few + * world actions a conversation can trigger (attack the insulter, run to help, + * team up). Everything the seam engine needs comes from here, so both kinds of + * bots talk with the same brain. + * @author Mobius SPP + */ +public class FakePlayerBotRef +{ + public final int botId; + public final String personality; + public final Creature creature; + public final Npc npc; + public final Player headless; + + private FakePlayerBotRef(int botId, String personality, Npc npc, Player headless) + { + this.botId = botId; + this.personality = (personality == null) || personality.isEmpty() ? "neutral" : personality; + this.npc = npc; + this.headless = headless; + this.creature = (npc != null) ? npc : headless; + } + + public static FakePlayerBotRef ofNpc(Npc npc) + { + return new FakePlayerBotRef(FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId()), npc.getTemplate().getParameters().getString("fpcPersonality", "neutral"), npc, null); + } + + public static FakePlayerBotRef ofHeadless(Player bot) + { + return new FakePlayerBotRef(FakePlayerHeadlessManager.getInstance().seedIdOf(bot), FakePlayerHeadlessManager.getInstance().personalityOf(bot), null, bot); + } + + /** + * @param creature any creature. + * @return a bot reference when the creature is a fake player of either kind, otherwise null. + */ + public static FakePlayerBotRef of(Creature creature) + { + if (creature instanceof Npc npc) + { + return npc.isFakePlayer() ? ofNpc(npc) : null; + } + if ((creature instanceof Player player) && player.isHeadlessBot()) + { + return ofHeadless(player); + } + return null; + } + + public boolean isHeadless() + { + return headless != null; + } + + public String getName() + { + return creature.getName(); + } + + public int getLevel() + { + if (headless != null) + { + return headless.getLevel(); + } + final int level = FakePlayerProgressionManager.getInstance().getLevel(npc.getObjectId()); + return (level > 0) ? level : npc.getTemplate().getLevel(); + } + + public String getZone() + { + if (headless != null) + { + return FakePlayerHeadlessManager.getInstance().zoneOf(headless); + } + return FakePlayerProgressionManager.getInstance().getZoneName(npc.getObjectId()); + } + + public int getClanId() + { + return (headless != null) ? headless.getClanId() : npc.getFakePlayerClanId(); + } + + public Clan getClan() + { + if (headless != null) + { + return headless.getClan(); + } + final int clanId = npc.getFakePlayerClanId(); + return (clanId > 0) ? org.l2jmobius.gameserver.data.sql.ClanTable.getInstance().getClan(clanId) : null; + } + + public boolean isInCombat() + { + return creature.isInCombat(); + } + + public boolean isDead() + { + return creature.isDead(); + } + + public double distanceTo(WorldObject object) + { + return creature.calculateDistance2D(object); + } + + /** + * Attacks a player (insult, clan war, gank) with the mechanics of the bot kind. + * @param target the player. + * @param seconds how long a headless bot stays focused on the target. + */ + public void attack(Player target, int seconds) + { + if (headless != null) + { + FakePlayerHeadlessManager.getInstance().forceTarget(headless, target, seconds); + return; + } + if (npc instanceof Attackable attackable) + { + attackable.addDamageHate(target, 0, 500); + npc.setRunning(); + npc.getAI().setIntentionAttack(target); + } + } + + /** + * Helps against a monster (ask_help while the player fights). + * @param monster the monster. + */ + public void assist(Monster monster) + { + if (headless != null) + { + headless.setTarget(monster); + headless.getAI().setIntentionAttack(monster); + return; + } + if (npc instanceof Attackable attackable) + { + attackable.addDamageHate(monster, 0, 200); + npc.setRunning(); + npc.getAI().setIntentionAttack(monster); + } + } + + /** + * Runs to a player (ask_help with nothing to fight yet). + * @param player the player. + */ + public void moveTo(Player player) + { + if (headless != null) + { + headless.setRunning(); + headless.getAI().setIntentionFollow(player); + return; + } + npc.setRunning(); + npc.getAI().setIntentionMoveTo(new Location(player.getX(), player.getY(), player.getZ())); + } + + /** + * Teams up with the player: a real party invite for headless bots, escort mode for NPC bots. + * @param player the player. + */ + public void teamUp(Player player) + { + if (headless != null) + { + FakePlayerSocial.getInstance().inviteToParty(headless, player); + return; + } + FakePlayerAiTaskManager.getInstance().startCompanion(npc, player, 15); + } + + /** + * @param player the player. + * @return true when this bot invited the player recently ("yes" means accept). + */ + public boolean hasPendingInvite(Player player) + { + if (headless != null) + { + return FakePlayerSocial.getInstance().hasPendingInvite(headless, player); + } + return FakePlayerAiTaskManager.getInstance().hasPendingInvite(npc, player); + } + + /** + * Says a line in local chat. + * @param line the text. + * @param seam the seam it came from (heard classification). + */ + public void say(String line, String seam) + { + if ((line == null) || line.isEmpty()) + { + return; + } + if (headless != null) + { + FakePlayerHeadlessManager.getInstance().say(headless, line, seam); + return; + } + FakePlayerChatManager.getInstance().broadcastToNearby(npc, line, seam); + } + + /** + * Whispers a player. + * @param to the player. + * @param line the text. + */ + public void whisper(Player to, String line) + { + if ((line == null) || line.isEmpty()) + { + return; + } + if (headless != null) + { + FakePlayerHeadlessManager.getInstance().whisper(headless, to, line); + return; + } + FakePlayerChatManager.getInstance().sendChat(to, npc.getName(), line); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotState.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotState.java new file mode 100644 index 0000000..7b87465 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBotState.java @@ -0,0 +1,116 @@ +/* + * 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.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import org.l2jmobius.gameserver.entity.Location; + +/** + * Runtime state of one headless bot: identity from the seed, timers of the + * social/economy layers, the combat memory of the brain and the activity + * counters that prove the bot really farms, trades and fights (see + * {@link FakePlayerTelemetry}). + * @author Mobius SPP + */ +public class FakePlayerBotState +{ + // --- identity (seed) --- + public int seedId; + public String personality = "neutral"; + public String race = ""; + public String zone = ""; + public Location spot; // Centre of the current farming spot. + + // --- level / gear --- + public int lastLevel = 1; + public long lastExp = 0; + public long nextGearCheck = 0; + public int gearTier = -1; + public long nextProfessionCheck = 0; + + // --- combat --- + public int forcedTargetId = 0; + public long forcedUntil = 0; + public int lastPvpTargetId = 0; + public long lastHelpCall = 0; + public final FakePlayerBrain.CombatState combat = new FakePlayerBrain.CombatState(); + + // --- social --- + public long lastChat = 0; + public long lastHelpCredit = 0; + public long partyCooldown = 0; + public long nextBotTalk = 0; // Bot to bot small talk. + public long nextGossip = 0; + public long nextLfp = 0; + public int companionPlayerId = 0; + public long companionUntil = 0; + public int pendingInvitePlayerId = 0; + public long pendingInviteUntil = 0; + public long nextSlotRefresh = 0; + public String lastDrop = ""; + /** Bots met recently (objectId - time), to greet each one only now and then. */ + public final ConcurrentHashMap met = new ConcurrentHashMap<>(); + + // --- economy --- + public long nextTradeAt = 0; + public long tradeUntil = 0; + public long nextShoppingAt = 0; + public long lastTownVisit = 0; + public boolean giftedStock = false; + + // --- siege --- + public byte siegeSide = 0; // 0 none, 1 attacker, 2 defender. + public int siegeCastleId = 0; + public long siegeNextThink = 0; + + // --- telemetry --- + public final AtomicLong kills = new AtomicLong(); + public final AtomicLong expGained = new AtomicLong(); + public final AtomicLong deaths = new AtomicLong(); + public final AtomicLong pvpKills = new AtomicLong(); + public final AtomicLong lootPicked = new AtomicLong(); + public final AtomicLong itemsSold = new AtomicLong(); + public final AtomicLong adenaEarned = new AtomicLong(); + public final AtomicLong adenaSpent = new AtomicLong(); + public final AtomicLong storesOpened = new AtomicLong(); + public final AtomicLong linesSaid = new AtomicLong(); + public final AtomicLong repliesGiven = new AtomicLong(); + public final AtomicLong skillsCast = new AtomicLong(); + public final AtomicLong rests = new AtomicLong(); + public final AtomicLong siegesJoined = new AtomicLong(); + public final AtomicLong migrations = new AtomicLong(); + public final AtomicLong professions = new AtomicLong(); + public final AtomicLong partiesJoined = new AtomicLong(); + public volatile String lastAction = "spawn"; + public volatile long lastActionAt = System.currentTimeMillis(); + + /** + * Records what the bot is doing right now (visible on the dashboard and in do=stats). + * @param action short action name. + */ + public void action(String action) + { + lastAction = action; + lastActionAt = System.currentTimeMillis(); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBrain.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBrain.java index 643c903..2a65d95 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBrain.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerBrain.java @@ -20,32 +20,41 @@ */ package org.l2jmobius.gameserver.managers; -import java.util.List; - import org.l2jmobius.commons.util.Rnd; +import org.l2jmobius.gameserver.entity.Location; import org.l2jmobius.gameserver.entity.World; import org.l2jmobius.gameserver.entity.WorldObject; +import org.l2jmobius.gameserver.entity.actor.Attackable; 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.entity.item.instance.Item; 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. + * Priority order (first match wins), the way real farming bots are built: + * survival, upkeep (rest / self buffs), party role, combat rotation, sweeping, + * loot, farming (target choice with a leash to the spot). * @author Mobius SPP */ public class FakePlayerBrain { - private static final int HEAL_POTION = 1061; // Healing Potion + private static final int HEAL_POTION = 1061; + private static final int GREATER_HEAL_POTION = 1539; + private static final int SPOIL = 254; + private static final int SWEEPER = 42; private static final int MELEE_RANGE = 80; private static final int BOW_RANGE = 500; - + private static final int PREY_RANGE = 1100; + private static final int LOOT_RANGE = 300; + private static final int LEASH = 1500; // Wander this far from the spot before walking back. + private static final int LEASH_TELEPORT = 7000; // Too far to walk (fell off a cliff, chased someone) - teleport back. + /** * Runtime combat memory of a bot. */ @@ -55,40 +64,46 @@ public class FakePlayerBrain public long restingUntil = 0; public long lastPotion = 0; public long fleeingUntil = 0; + public int sweepCorpseId = 0; + public long nextLeashCheck = 0; + public long stuckSince = 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. + * @param botState its state. + * @param hunts true when the bot may pick monsters on its own. * @return true when an action was taken. */ - public static boolean act(Player bot, CombatState state, boolean aggressive) + public static boolean act(Player bot, FakePlayerBotState botState, boolean hunts) { final long now = System.currentTimeMillis(); + final CombatState state = botState.combat; 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)) + if (castHeal(bot, bot, profile, hpPercent, botState)) { return true; } - if ((hpPercent < 40) && ((now - state.lastPotion) > 15000) && drinkPotion(bot)) + if ((hpPercent < 45) && ((now - state.lastPotion) > 8000) && drinkPotion(bot)) { state.lastPotion = now; + botState.action("potion"); return true; } if (hpPercent < 25) { final Creature threat = nearestThreat(bot); - if (threat != null) + if ((threat != null) && (threat.isMonster() || (threat.getLevel() > (bot.getLevel() + 3)))) { state.fleeingUntil = now + 12000; + botState.action("flee"); flee(bot, threat); return true; } @@ -98,7 +113,7 @@ public class FakePlayerBrain { return true; // Keep running. } - + // 2. Upkeep: rest when safe and low, refresh self buffs. if (state.restingUntil > now) { @@ -107,23 +122,31 @@ public class FakePlayerBrain state.restingUntil = 0; bot.standUp(); } + else if (nearestThreat(bot) != null) + { + state.restingUntil = 0; + bot.standUp(); + return false; + } return true; } - if (!bot.isInCombat() && (nearestThreat(bot) == null) && ((hpPercent < 45) || (mpPercent < 35))) + if (!bot.isInCombat() && !bot.isInStoreMode() && (nearestThreat(bot) == null) && ((hpPercent < 45) || (mpPercent < 35))) { state.restingUntil = now + 120000; + botState.rests.incrementAndGet(); + botState.action("rest"); bot.sitDown(); return true; } if ((now > state.nextSelfBuff) && !bot.isInCombat()) { state.nextSelfBuff = now + 180000; - if (castSelfBuffs(bot, profile)) + if (castSelfBuffs(bot, profile, botState)) { return true; } } - + // 3. Party role: healers keep the party alive. final Party party = bot.getParty(); if ((party != null) && "healer".equals(profile.role)) @@ -139,58 +162,175 @@ public class FakePlayerBrain wounded = member; } } - if ((wounded != null) && (worst < 75) && castHeal(bot, wounded, profile, worst)) + if ((wounded != null) && (worst < 75) && castHeal(bot, wounded, profile, worst, botState)) { 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()) + if ((target instanceof Creature victim) && !victim.isDead() && (bot.isInCombat() || bot.isAttackingNow() || (victim.getTarget() == bot))) { final int targetHp = (int) ((victim.getCurrentHp() * 100) / Math.max(1, victim.getMaxHp())); - if (castOffensive(bot, victim, profile, targetHp, mpPercent)) + if ("spoiler".equals(profile.role) && (victim instanceof Attackable attackable) && !attackable.isSpoiled() && (targetHp > 30) && castSkill(bot, victim, SPOIL, botState)) + { + return true; + } + if ((victim instanceof Attackable attackable) && attackable.isSpoiled() && (attackable.getSpoilerObjectId() == bot.getObjectId())) + { + state.sweepCorpseId = victim.getObjectId(); + } + if (castOffensive(bot, victim, profile, targetHp, mpPercent, botState)) { return true; } keepDistance(bot, victim, profile); return true; } - - // 5. Farming: pick a sensible monster. - if (aggressive && !bot.isInCombat()) + + // 4b. Under attack by something we are not fighting: fight back (or run from a monster far above us). + final Creature aggressor = nearestThreat(bot); + if ((aggressor != null) && (aggressor != target)) { + if (aggressor.isMonster() && (aggressor.getLevel() > (bot.getLevel() + 7)) && (hpPercent < 70)) + { + state.fleeingUntil = now + 10000; + botState.action("flee " + aggressor.getName()); + flee(bot, aggressor); + return true; + } + bot.setTarget(aggressor); + bot.getAI().setIntentionAttack(aggressor); + botState.action("fight back " + aggressor.getName()); + return true; + } + + // 5. Sweep the spoiled corpse before it decays. + if (state.sweepCorpseId != 0) + { + final WorldObject corpse = World.findObject(state.sweepCorpseId); + state.sweepCorpseId = 0; + if ((corpse instanceof Attackable attackable) && attackable.isDead() && attackable.isSweepActive() && (bot.calculateDistance2D(corpse) < 400)) + { + if (castSkill(bot, attackable, SWEEPER, botState)) + { + botState.action("sweep"); + return true; + } + } + } + + // 6. Loot: pick up what dropped from our kills (a real inventory, a real store later). + if (!bot.isInCombat() && (bot.getInventory().getSize() < 70)) + { + final Item loot = World.getNearestVisibleObjectInRange(bot, Item.class, LOOT_RANGE, item -> item.isSpawned() && ((item.getOwnerId() == 0) || (item.getOwnerId() == bot.getObjectId()) || ((party != null) && (World.findObject(item.getOwnerId()) instanceof Player owner) && party.getMembers().contains(owner)))); + if (loot != null) + { + if (bot.calculateDistance2D(loot) > 40) + { + bot.getAI().setIntentionMoveTo(loot.getLocation()); + } + else + { + bot.doPickupItem(loot); + FakePlayerHeadlessManager.getInstance().onLootPicked(bot, loot); + botState.action("loot " + loot.getName()); + } + return true; + } + } + + // 7. Farming: pick a sensible monster, stay on the spot. Only when fit for a fight. + if (hunts && !bot.isInCombat()) + { + if ((hpPercent < 60) || (mpPercent < 25)) + { + if (nearestThreat(bot) == null) + { + state.restingUntil = now + 90000; + botState.rests.incrementAndGet(); + botState.action("rest"); + bot.sitDown(); + } + return true; + } final Monster prey = pickPrey(bot); if (prey != null) { bot.setTarget(prey); + bot.setRunning(); bot.getAI().setIntentionAttack(prey); + botState.action("hunt " + prey.getName()); return true; } + // Nothing worth fighting here: walk back to the spot if we drifted (or teleport if far away). + if ((botState.spot != null) && (now > state.nextLeashCheck)) + { + state.nextLeashCheck = now + 15000; + final double distance = bot.calculateDistance2D(botState.spot); + if (distance > LEASH_TELEPORT) + { + FakePlayerHeadlessManager.getInstance().teleport(bot, botState.spot.getX() + Rnd.get(-300, 300), botState.spot.getY() + Rnd.get(-300, 300), botState.spot.getZ()); + botState.action("back to spot (tp)"); + return true; + } + if (distance > LEASH) + { + bot.setRunning(); + bot.getAI().setIntentionMoveTo(new Location(botState.spot.getX() + Rnd.get(-300, 300), botState.spot.getY() + Rnd.get(-300, 300), botState.spot.getZ())); + botState.action("back to spot"); + return true; + } + } } return false; } - + /** - * Monster worth attacking: close to our level, reachable, not a raid. + * Monster worth attacking: close to our level, reachable, not a raid, nobody else's. * @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) // не бьём слишком сильных и не тратим время на слабых + final java.util.List candidates = World.getVisibleObjectsInRange(bot, Monster.class, PREY_RANGE, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() // + && (monster.getLevel() <= (level + 1)) && (monster.getLevel() >= (level - 9)) // не бьём слишком сильных и не тратим время на серых && (Math.abs(monster.getZ() - bot.getZ()) < 200) // не пытаемся достать тех, кто выше/ниже - && ((monster.getTarget() == null) || (monster.getTarget() == bot))); // не воруем чужую цель + && ((monster.getTarget() == null) || (monster.getTarget() == bot) || ((bot.getParty() != null) && bot.getParty().getMembers().contains(monster.getTarget())))); // не воруем чужую цель + if (candidates.isEmpty()) + { + return null; + } + // A player pulls the lonely one, not the one in the middle of a pack: score = distance + 350 per neighbour. + Monster best = null; + double bestScore = Double.MAX_VALUE; + for (Monster monster : candidates) + { + int neighbours = 0; + for (Monster other : candidates) + { + if ((other != monster) && (other.calculateDistance2D(monster) < 350)) + { + neighbours++; + } + } + final double score = bot.calculateDistance2D(monster) + (neighbours * 350) + ((monster.getLevel() > level) ? 200 : 0); + if (score < bestScore) + { + bestScore = score; + best = monster; + } + } + return best; } - + private static Creature nearestThreat(Player bot) { - return World.getNearestVisibleObjectInRange(bot, Creature.class, 600, creature -> !creature.isDead() && (creature.getTarget() == bot) && (creature.isMonster() || creature.isPlayer())); + return World.getNearestVisibleObjectInRange(bot, Creature.class, 600, creature -> !creature.isDead() && (creature.getTarget() == bot) && (creature.isMonster() || creature.isPlayer()) && creature.isInCombat()); } - + private static void flee(Player bot, Creature threat) { final int dx = bot.getX() - threat.getX(); @@ -199,25 +339,36 @@ public class FakePlayerBrain 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())); + bot.getAI().setIntentionMoveTo(new Location(targetX, targetY, bot.getZ())); } - + private static boolean drinkPotion(Player bot) { - if (bot.getInventory().getItemByItemId(HEAL_POTION) == null) + Item potion = bot.getInventory().getItemByItemId(GREATER_HEAL_POTION); + if (potion == null) { - bot.addItem(ItemProcessType.NONE, HEAL_POTION, 20, bot, false); + potion = bot.getInventory().getItemByItemId(HEAL_POTION); + } + if (potion == null) + { + // Out of potions in the field: the cheapest one is always in a real player's bag. + bot.addItem(ItemProcessType.NONE, HEAL_POTION, 5, bot, false); + potion = bot.getInventory().getItemByItemId(HEAL_POTION); } - 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; + // Potions are used through their item handler, like a click in the inventory. + final org.l2jmobius.gameserver.handler.IItemHandler handler = org.l2jmobius.gameserver.handler.ItemHandler.getInstance().getHandler(potion.getEtcItem()); + if (handler == null) + { + return false; + } + return handler.onItemUse(bot, potion, false); } - - private static boolean castSelfBuffs(Player bot, Profile profile) + + private static boolean castSelfBuffs(Player bot, Profile profile, FakePlayerBotState botState) { for (BotSkill entry : profile.self) { @@ -231,12 +382,17 @@ public class FakePlayerBrain continue; } bot.setTarget(bot); - return bot.useMagic(skill, true, false); + if (bot.useMagic(skill, true, false)) + { + botState.skillsCast.incrementAndGet(); + botState.action("buff"); + return true; + } } return false; } - - private static boolean castHeal(Player bot, Player patient, Profile profile, int patientHp) + + private static boolean castHeal(Player bot, Player patient, Profile profile, int patientHp, FakePlayerBotState botState) { for (BotSkill entry : profile.heal) { @@ -250,12 +406,37 @@ public class FakePlayerBrain continue; } bot.setTarget(patient); - return bot.useMagic(skill, true, false); + if (bot.useMagic(skill, true, false)) + { + botState.skillsCast.incrementAndGet(); + botState.action((patient == bot) ? "heal self" : ("heal " + patient.getName())); + return true; + } } return false; } - - private static boolean castOffensive(Player bot, Creature victim, Profile profile, int targetHp, int mpPercent) + + private static boolean castSkill(Player bot, Creature target, int skillId, FakePlayerBotState botState) + { + final Skill skill = bot.getKnownSkill(skillId); + if ((skill == null) || bot.isSkillDisabled(skill) || (bot.getCurrentMp() < skill.getMpConsume())) + { + return false; + } + if ((skill.getCastRange() > 0) && (bot.calculateDistance2D(target) > skill.getCastRange())) + { + return false; + } + bot.setTarget(target); + if (bot.useMagic(skill, true, false)) + { + botState.skillsCast.incrementAndGet(); + return true; + } + return false; + } + + private static boolean castOffensive(Player bot, Creature victim, Profile profile, int targetHp, int mpPercent, FakePlayerBotState botState) { for (BotSkill entry : profile.offensive) { @@ -275,12 +456,13 @@ public class FakePlayerBrain bot.setTarget(victim); if (bot.useMagic(skill, true, false)) { + botState.skillsCast.incrementAndGet(); return true; } } return false; } - + /** * Archers and mages should not stand in melee; fighters should close in. * @param bot the bot. @@ -289,31 +471,23 @@ public class FakePlayerBrain */ private static void keepDistance(Player bot, Creature victim, Profile profile) { + // Kiting a monster only trades hits for nothing: mages nuke point blank, archers shoot, fighters close in. 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) + if ((distance > BOW_RANGE) || (!bot.isAttackingNow() && !bot.isCastingNow())) { bot.getAI().setIntentionAttack(victim); } return; } - if (distance > MELEE_RANGE) + if ((distance > MELEE_RANGE) || !bot.isAttackingNow()) { bot.getAI().setIntentionAttack(victim); } } - + /** * @param bot the bot. * @return short human readable description of what the brain would do now. @@ -338,10 +512,36 @@ public class FakePlayerBrain final Skill skill = bot.getKnownSkill(entry.id); sb.append(entry.id).append(skill != null ? "+" : "-").append(' '); } + sb.append("\n самобафы: "); + for (BotSkill entry : profile.self) + { + final Skill skill = bot.getKnownSkill(entry.id); + sb.append(entry.id).append(skill != null ? "+" : "-").append(' '); + } + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + if (state != null) + { + sb.append("\n действие: ").append(state.lastAction).append(" | спот: ").append(state.zone).append(" (").append((state.spot != null) ? (int) bot.calculateDistance2D(state.spot) : -1).append(" от центра)"); + final WorldObject target = bot.getTarget(); + sb.append("\n цель: ").append((target != null) ? target.getName() : "-"); + if (target instanceof Creature creature) + { + sb.append(" lvl").append(creature.getLevel()).append(" hp").append((int) (creature.getCurrentHp() * 100 / Math.max(1, creature.getMaxHp()))).append("% dist ").append((int) bot.calculateDistance2D(creature)); + } + sb.append(" | в бою: ").append(bot.isInCombat()).append(" бьёт: ").append(bot.isAttackingNow()).append(" кастует: ").append(bot.isCastingNow()).append(" сидит: ").append(bot.isSitting()); + final Item weapon = bot.getActiveWeaponInstance(); + sb.append("\n оружие: ").append((weapon != null) ? weapon.getName() : "кулаки").append(" | автошоты: ").append(bot.getAutoSoulShot()); + for (int shotId : bot.getAutoSoulShot()) + { + final Item shots = bot.getInventory().getItemByItemId(shotId); + sb.append(" x").append((shots != null) ? shots.getCount() : 0); + } + sb.append(" | бафов: ").append(bot.getEffectList().getBuffCount()).append(" | hp ").append((int) bot.getCurrentHp()).append('/').append(bot.getMaxHp()).append(" p.def ").append((int) bot.getPDef(null)).append(" p.atk ").append((int) bot.getPAtk(null)).append(" m.atk ").append((int) bot.getMAtk(null, null)); + } sb.append("\n (+ есть у бота, - не выучен)"); return sb.toString(); } - + private FakePlayerBrain() { } diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerChatManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerChatManager.java index b92f8e4..b8a9000 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerChatManager.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerChatManager.java @@ -215,7 +215,7 @@ public class FakePlayerChatManager implements IXmlReader return; } - final Understanding understanding = understand(player, npc, message); + final Understanding understanding = understand(player, npc, message, false); if ((understanding != null) && understanding.silent) { return; @@ -529,234 +529,23 @@ public class FakePlayerChatManager implements IXmlReader * @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 + public Understanding understand(Player player, Npc npc, String message) { - final String seam; - final String text; - final long timestamp = System.currentTimeMillis(); - - Stance(String seam, String text) - { - this.seam = seam; - this.text = text; - } + return understand(player, npc, message, true); } - private static final Map STANCES = new java.util.concurrent.ConcurrentHashMap<>(); - private static final Map FIRST_CONTACT = new java.util.concurrent.ConcurrentHashMap<>(); - private static final java.util.Set 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) + /** + * Understands an incoming message through the shared understanding layer + * (intents, memory facts, stance memory, silence, caps, mirroring, context). + * @param player the speaking player. + * @param npc the addressed bot. + * @param message the raw message. + * @param privateChannel true for whispers. + * @return understanding or null when no intent matched. + */ + public Understanding understand(Player player, Npc npc, String message, boolean privateChannel) { - 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 facts = new HashMap<>(); - facts.put("personality", personality); - facts.put("asker", asker); - final Map 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 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; + return FakePlayerUnderstanding.understand(FakePlayerBotRef.ofNpc(npc), player, message, privateChannel); } private Npc findNpc(String fpcName) diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerDashboard.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerDashboard.java index 87b505a..3d83847 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerDashboard.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerDashboard.java @@ -69,6 +69,8 @@ public class FakePlayerDashboard respond(exchange, "application/json", merged); }); server.createContext("/api/seams", exchange -> respond(exchange, "application/json", seamsJson())); + server.createContext("/api/stats", exchange -> respond(exchange, "application/json", FakePlayerTelemetry.getInstance().statsJson())); + server.createContext("/api/sieges", exchange -> respond(exchange, "text/plain", FakePlayerSiegeManager.getInstance().info())); 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))); diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerEconomy.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerEconomy.java new file mode 100644 index 0000000..090920a --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerEconomy.java @@ -0,0 +1,478 @@ +/* + * 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.logging.Level; +import java.util.logging.Logger; + +import org.l2jmobius.commons.util.Rnd; +import org.l2jmobius.gameserver.config.custom.FakePlayersConfig; +import org.l2jmobius.gameserver.data.xml.ItemData; +import org.l2jmobius.gameserver.entity.actor.Player; +import org.l2jmobius.gameserver.entity.actor.enums.player.PlayerClass; +import org.l2jmobius.gameserver.entity.item.ItemTemplate; +import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType; +import org.l2jmobius.gameserver.entity.item.instance.Item; +import org.l2jmobius.gameserver.entity.item.type.WeaponType; +import org.l2jmobius.gameserver.data.xml.SkillData; +import org.l2jmobius.gameserver.entity.item.enums.BodyPart; +import org.l2jmobius.gameserver.mechanics.skill.Skill; + +/** + * The money side of a bot's life, done with real inventory and real adena: + * junk from the field is sold to the town merchant (half of the reference + * price, as NPCs pay), shots / potions / arrows are bought with that adena + * before going back to the spot, and at 20 / 40 / 76 the bot takes its next + * profession with the skills that come with it. Everything is counted in the + * bot's telemetry so it can be verified with do=stats. + * @author Mobius SPP + */ +public class FakePlayerEconomy +{ + private static final Logger LOGGER = Logger.getLogger(FakePlayerEconomy.class.getName()); + + private static final int ADENA = 57; + private static final int[] SOULSHOTS = { 1835, 1463, 1464, 1465, 1466, 1467 }; + private static final int[] SPIRITSHOTS = { 3947, 3948, 3949, 3950, 3951, 3952 }; + private static final int[] ARROWS = { 17, 1341, 1342, 1343, 1344, 1345 }; + private static final int HEALING_POTION = 1061; + private static final int GREATER_HEALING_POTION = 1539; + private static final int SHOT_STOCK = 2500; + private static final int ARROW_STOCK = 3000; + private static final int POTION_STOCK = 10; + private static final int JUNK_PRICE = 1500; // Cheaper things go to the NPC, dearer ones to the private store. + + protected FakePlayerEconomy() + { + } + + /** + * Everything a player does in town between two farming sessions. + * @param bot the bot (already standing in a town). + * @param state its state. + * @return a short description of what happened (for the action log). + */ + public String visitTown(Player bot, FakePlayerBotState state) + { + if (!FakePlayersConfig.FAKE_PLAYER_ECONOMY) + { + return ""; + } + state.lastTownVisit = System.currentTimeMillis(); + final int sold = sellJunk(bot, state); + final int bought = restock(bot, state); + buffUp(bot); + final String summary = "town: sold " + sold + ", bought " + bought + ", adena " + bot.getAdena(); + state.action(summary); + return summary; + } + + /** + * Sells the low value part of the loot to the merchant: NPCs pay half of the reference price. + * @param bot the bot. + * @param state its state. + * @return number of item stacks sold. + */ + public int sellJunk(Player bot, FakePlayerBotState state) + { + int sold = 0; + long earned = 0; + final List junk = new ArrayList<>(); + for (Item item : bot.getInventory().getItems()) + { + if ((item == null) || item.isEquipped() || (item.getId() == ADENA) || !item.isSellable() || isConsumable(item.getId())) + { + continue; + } + final int price = item.getReferencePrice(); + if ((price < JUNK_PRICE) || (item.getCount() > 50) || (bot.getInventory().getSize() > 55)) + { + junk.add(item); + } + } + for (Item item : junk) + { + final long value = Math.max(1L, item.getReferencePrice() / 2L) * item.getCount(); + if (bot.destroyItem(ItemProcessType.SELL, item, bot, false)) + { + bot.addAdena(ItemProcessType.SELL, (int) Math.min(Integer.MAX_VALUE, value), bot, false); + earned += value; + sold++; + } + } + if (sold > 0) + { + state.itemsSold.addAndGet(sold); + state.adenaEarned.addAndGet(earned); + } + return sold; + } + + /** + * Buys shots, arrows and potions with the bot's own adena. + * @param bot the bot. + * @param state its state. + * @return number of purchases. + */ + public int restock(Player bot, FakePlayerBotState state) + { + int bought = 0; + final Item weapon = bot.getActiveWeaponInstance(); + final int grade = (weapon != null) ? Math.min(5, Math.max(0, weapon.getTemplate().getCrystalType().getLevel())) : 0; + final boolean mage = bot.isMageClass(); + final int shotId = mage ? SPIRITSHOTS[grade] : SOULSHOTS[grade]; + if (buyUpTo(bot, state, shotId, SHOT_STOCK)) + { + bought++; + } + bot.addAutoSoulShot(shotId); + if ((weapon != null) && (weapon.getItemType() == WeaponType.BOW) && buyUpTo(bot, state, ARROWS[grade], ARROW_STOCK)) + { + bought++; + } + if (buyUpTo(bot, state, (bot.getLevel() >= 40) ? GREATER_HEALING_POTION : HEALING_POTION, POTION_STOCK)) + { + bought++; + } + return bought; + } + + /** + * Buys the missing part of a stack, when the bot can afford it. + * @param bot the bot. + * @param state its state. + * @param itemId the item. + * @param stock the stock to keep. + * @return true when something was bought. + */ + private boolean buyUpTo(Player bot, FakePlayerBotState state, int itemId, int stock) + { + final ItemTemplate template = ItemData.getInstance().getTemplate(itemId); + if (template == null) + { + return false; + } + final Item existing = bot.getInventory().getItemByItemId(itemId); + final int have = (existing != null) ? existing.getCount() : 0; + if (have >= (stock / 2)) + { + return false; + } + final int missing = stock - have; + final long unit = Math.max(1, template.getReferencePrice()); + long affordable = Math.min(missing, bot.getAdena() / unit); + if (affordable <= 0) + { + // A fresh character has no adena yet: the first pack is a gift so it can farm at all. + if ((have == 0) && !state.giftedStock) + { + state.giftedStock = true; + bot.addItem(ItemProcessType.BUY, itemId, Math.min(missing, stock / 5), bot, false); + return true; + } + return false; + } + final int count = (int) affordable; + if (bot.reduceAdena(ItemProcessType.BUY, (int) Math.min(Integer.MAX_VALUE, unit * count), bot, false)) + { + bot.addItem(ItemProcessType.BUY, itemId, count, bot, false); + state.adenaSpent.addAndGet(unit * count); + return true; + } + return false; + } + + /** + * Makes sure the auto shots are switched on for the equipped weapon (cheap, every gear check). + * @param bot the bot. + */ + public void ensureShots(Player bot) + { + final Item weapon = bot.getActiveWeaponInstance(); + final int grade = (weapon != null) ? Math.min(5, Math.max(0, weapon.getTemplate().getCrystalType().getLevel())) : 0; + final int shotId = bot.isMageClass() ? SPIRITSHOTS[grade] : SOULSHOTS[grade]; + if (!bot.getAutoSoulShot().contains(shotId)) + { + bot.getAutoSoulShot().clear(); + bot.addAutoSoulShot(shotId); + } + } + + private static boolean isConsumable(int itemId) + { + for (int id : SOULSHOTS) + { + if (id == itemId) + { + return true; + } + } + for (int id : SPIRITSHOTS) + { + if (id == itemId) + { + return true; + } + } + for (int id : ARROWS) + { + if (id == itemId) + { + return true; + } + } + return (itemId == HEALING_POTION) || (itemId == GREATER_HEALING_POTION); + } + + /** + * Class transfer at 20 / 40 / 76, like a player visiting the class master: + * the next class is chosen deterministically from the character id, so the + * same bot always grows into the same profession. + * @param bot the bot. + * @param state its state. + * @return the new class, or null when nothing changed. + */ + public PlayerClass checkProfession(Player bot, FakePlayerBotState state) + { + if (!FakePlayersConfig.FAKE_PLAYER_PROFESSION_CHANGE || bot.isSubClassActive()) + { + return null; + } + final PlayerClass current = bot.getPlayerClass(); + final int level = bot.getLevel(); + final int classLevel = current.level(); + final boolean due = ((classLevel == 0) && (level >= 20)) || ((classLevel == 1) && (level >= 40)) || ((classLevel == 2) && (level >= 76)); + if (!due || current.getNextClasses().isEmpty()) + { + return null; + } + final List options = new ArrayList<>(current.getNextClasses()); + options.sort((a, b) -> Integer.compare(a.getId(), b.getId())); + final PlayerClass next = options.get(Math.floorMod(FakePlayerIdiolect.hash32("prof" + state.seedId + ":" + classLevel), options.size())); + try + { + bot.setPlayerClass(next.getId()); + bot.setBaseClass(bot.getActiveClass()); + bot.giveAvailableSkills(true, true, true); + bot.store(false); + bot.broadcastUserInfo(); + bot.sendSkillList(); + state.professions.incrementAndGet(); + FakePlayerBotContext.setEvent(state.seedId, "profession"); + state.action("profession:" + next); + LOGGER.info(getClass().getSimpleName() + ": " + bot.getName() + " lvl" + level + " became " + next + " (was " + current + ")."); + return next; + } + catch (Exception e) + { + LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not change class of " + bot.getName(), e); + return null; + } + } + + /** + * @param bot the bot. + * @return farmed items worth a private store (no adena, nothing equipped, no consumables, not junk). + */ + public List storeGoods(Player bot) + { + final List result = new ArrayList<>(); + for (Item item : bot.getInventory().getItems()) + { + if ((item == null) || item.isEquipped() || (item.getId() == ADENA) || !item.isSellable() || !item.isTradeable() || isConsumable(item.getId())) + { + continue; + } + if (item.getReferencePrice() >= JUNK_PRICE) + { + result.add(item); + } + } + return result; + } + + /** + * Store title slots: the dearest item and its price, for the store_title seam. + * @param goods store goods. + * @return slots. + */ + public Map storeSlots(List goods) + { + Item best = null; + for (Item item : goods) + { + if ((best == null) || (item.getReferencePrice() > best.getReferencePrice())) + { + best = item; + } + } + if (best == null) + { + return Map.of(); + } + final int price = best.getReferencePrice() * (80 + Rnd.get(90)) / 100; + return Map.of("item", best.getName(), "price", (price >= 1000000) ? ((price / 1000000) + "кк") : (price >= 1000) ? ((price / 1000) + "к") : String.valueOf(price)); + } + + // ======================== Jewelry and buffs ======================== + + private static final Map JEWELRY = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * The best necklace / earring / ring of a grade (by reference price), found once in the item data. + * @param grade crystal grade level (0 = NG ... 5 = S). + * @return item ids {necklace, earring, ring}. + */ + private static int[] jewelryOf(int grade) + { + return JEWELRY.computeIfAbsent(grade, g -> + { + final int[] best = new int[3]; + final int[] price = new int[3]; + for (ItemTemplate template : ItemData.getInstance().getAllItems()) + { + if ((template == null) || !(template instanceof org.l2jmobius.gameserver.entity.item.Armor) || (template.getCrystalType().getLevel() != g) || !template.isSellable() || !template.isTradeable()) + { + continue; + } + final BodyPart part = template.getBodyPart(); + final int slot = (part == BodyPart.NECK) ? 0 : ((part == BodyPart.R_EAR) || (part == BodyPart.L_EAR) || (part == BodyPart.LR_EAR)) ? 1 : ((part == BodyPart.R_FINGER) || (part == BodyPart.L_FINGER) || (part == BodyPart.LR_FINGER)) ? 2 : -1; + if ((slot < 0) || (template.getReferencePrice() <= price[slot]) || (template.getReferencePrice() > 50000000)) + { + continue; + } + price[slot] = template.getReferencePrice(); + best[slot] = template.getId(); + } + return best; + }); + } + + /** + * Gives and wears a full jewelry set of the bot's grade (real players never farm without one). + * @param bot the bot. + * @param grade crystal grade level. + */ + public void wearJewelry(Player bot, int grade) + { + final int[] set = jewelryOf(Math.max(0, Math.min(5, grade))); + final int[] wanted = { set[0], set[1], set[1], set[2], set[2] }; + for (int itemId : wanted) + { + if (itemId <= 0) + { + continue; + } + int worn = 0; + int owned = 0; + for (Item item : bot.getInventory().getItems()) + { + if (item.getId() == itemId) + { + owned++; + if (item.isEquipped()) + { + worn++; + } + } + } + final int needed = (itemId == set[1]) || (itemId == set[2]) ? 2 : 1; + if (worn >= needed) + { + continue; + } + Item item = null; + for (Item candidate : bot.getInventory().getItems()) + { + if ((candidate.getId() == itemId) && !candidate.isEquipped()) + { + item = candidate; + break; + } + } + if ((item == null) && (owned < needed)) + { + item = bot.addItem(ItemProcessType.NONE, itemId, 1, bot, false); + } + if ((item != null) && !item.isEquipped()) + { + bot.useEquippableItem(item, false); + } + } + } + + /** Town buffs (the scheme buffer everybody uses): id, level for fighters / mages. */ + private static final int[][] FIGHTER_BUFFS = { { 1204, 2 }, { 1040, 3 }, { 1068, 3 }, { 1077, 3 }, { 1086, 2 }, { 1062, 2 }, { 1035, 4 }, { 1045, 6 }, { 1268, 4 }, { 1240, 3 }, { 1242, 3 } }; + private static final int[][] MAGE_BUFFS = { { 1204, 2 }, { 1040, 3 }, { 1059, 3 }, { 1085, 3 }, { 1078, 6 }, { 1035, 4 }, { 1045, 6 }, { 1062, 2 }, { 1303, 2 }, { 1048, 6 } }; + + /** + * The full buff set a player takes from the town buffer before going to farm. Levels are + * clamped to what exists; buffs the character would not have at its level are skipped. + * @param bot the bot. + */ + public void buffUp(Player bot) + { + final int[][] buffs = bot.isMageClass() ? MAGE_BUFFS : FIGHTER_BUFFS; + for (int[] entry : buffs) + { + final int maxLevel = SkillData.getInstance().getMaxLevel(entry[0]); + if (maxLevel <= 0) + { + continue; + } + int level = Math.min(entry[1], maxLevel); + // Low levels get weaker buffs, like a real buffer's level restriction. + if (bot.getLevel() < 20) + { + level = Math.min(level, 1); + } + else if (bot.getLevel() < 40) + { + level = Math.min(level, 2); + } + final Skill skill = SkillData.getInstance().getSkill(entry[0], level); + if ((skill != null) && !bot.isAffectedBySkill(entry[0])) + { + try + { + skill.applyEffects(bot, bot); + } + catch (Exception e) + { + // A buff that cannot be applied is just skipped. + } + } + } + } + + public static FakePlayerEconomy getInstance() + { + return SingletonHolder.INSTANCE; + } + + private static class SingletonHolder + { + protected static final FakePlayerEconomy INSTANCE = new FakePlayerEconomy(); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java index dcf9a57..342995b 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java @@ -24,54 +24,56 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Level; import java.util.logging.Logger; import org.l2jmobius.commons.database.DatabaseFactory; -import org.l2jmobius.gameserver.data.sql.ClanTable; import org.l2jmobius.commons.threads.ThreadPool; import org.l2jmobius.commons.util.Rnd; import org.l2jmobius.gameserver.config.custom.FakePlayersConfig; +import org.l2jmobius.gameserver.data.sql.ClanTable; +import org.l2jmobius.gameserver.data.xml.MapRegionData; import org.l2jmobius.gameserver.entity.Location; 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.data.xml.MapRegionData; import org.l2jmobius.gameserver.entity.actor.enums.player.PrivateStoreType; import org.l2jmobius.gameserver.entity.actor.enums.player.TeleportWhereType; import org.l2jmobius.gameserver.entity.actor.instance.Monster; import org.l2jmobius.gameserver.entity.clan.Clan; -import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType; import org.l2jmobius.gameserver.entity.groups.Party; +import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType; +import org.l2jmobius.gameserver.entity.item.instance.Item; +import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding; import org.l2jmobius.gameserver.network.enums.ChatType; import org.l2jmobius.gameserver.network.serverpackets.CreatureSay; import org.l2jmobius.gameserver.network.serverpackets.JoinParty; +import org.l2jmobius.gameserver.network.serverpackets.PrivateStoreMsgSell; /** * Headless players: real Player instances restored from the seeded characters, - * living in the world without a network client. They can join real parties, - * share experience and behave like actual party members. - * Prototype: basic farm AI, auto party accept, auto revive. + * living in the world without a network client. For the engine they are + * players: they earn real experience, join real parties, own real inventories, + * open real private stores, fight in real clan wars and castle sieges. + *

+ * Each tick (3 s) a bot goes through: revive - level/profession/migration - + * siege - social perception and talk ({@link FakePlayerSocial}) - human party + * escort - gear - forced target - combat brain ({@link FakePlayerBrain}) - + * trading shift - idle wandering. The social layer runs before the combat + * brain on purpose: bots keep talking and noticing people while they farm. * @author Mobius SPP */ public class FakePlayerHeadlessManager { private static final Logger LOGGER = Logger.getLogger(FakePlayerHeadlessManager.class.getName()); - - private static final Location[] SPOTS = - { - new Location(83400, 147900, -3404), // Giran town edge - new Location(91280, 145900, -3500), // east of Giran - new Location(73700, 142800, -3600), // west field - new Location(82900, 138000, -3464), // south field - new Location(89200, 125900, -3200), // toward Alligator - }; - - private static final Location TRADE_SPOT = new Location(83396, 147904, -3404); // Giran center + + private static final Location GIRAN = new Location(83396, 147904, -3404); // Экипировка по тирам (уровни 8/20/30/40/50/59/68/74): грейд подобран под экспертизу, // иначе игрок получает штраф и бегает медленно. private static final int[] SWORDS = { 2, 143, 143, 84, 84, 141, 150, 150 }; @@ -90,7 +92,8 @@ public class FakePlayerHeadlessManager private static final boolean[] ROBE_ONEPIECE = { true, true, true, true, true, true, true, true }; private static final int[] SOULSHOTS = { 1835, 1463, 1463, 1464, 1464, 1465, 1466, 1466 }; private static final int[] SPIRITSHOTS = { 3947, 3948, 3948, 3949, 3949, 3950, 3951, 3951 }; - + private static final int[] ARROWS = { 17, 1341, 1341, 1342, 1342, 1343, 1344, 1344 }; + private static final String[] TITLES = { "", "", "", "", "", // most players have no title @@ -98,40 +101,15 @@ public class FakePlayerHeadlessManager "мимо", "новичок", "тут был", "Гиран", "Дион", "Аден", "хочу спать", "качаюсь", "без обид", "мирный", "за кланы", "рб хантер", "спойлер", "бафер", "тащу", "нуб" }; - + private static final String[] STORE_MSGS = { "продам дроп с фарма", "скупаю ресы дорого", "распродажа, налетай", "продам D грейд дешево", "куплю бижу" }; - - /** - * Per bot runtime state (mirrors what NPC bots keep in their managers). - */ - private static class BotState - { - int seedId; - String personality = "neutral"; - String race = ""; - String zone = ""; - int lastLevel = 1; - long lastChat = 0; - long lastHelpCredit = 0; - int lastPvpTargetId = 0; - long partyCooldown = 0; - int forcedTargetId = 0; - long forcedUntil = 0; - final FakePlayerBrain.CombatState combat = new FakePlayerBrain.CombatState(); - long nextGearCheck = 0; - int gearTier = -1; - } - - private static final int NOTICE_RANGE = 1000; - private static final long AMBIENT_COOLDOWN = 90000; - private static final long TAUNT_COOLDOWN = 20000; - - private final List _bots = new ArrayList<>(); - private final Map _states = new ConcurrentHashMap<>(); + + private final List _bots = new CopyOnWriteArrayList<>(); + private final List _view = Collections.unmodifiableList(_bots); + private final Map _states = new ConcurrentHashMap<>(); private final Map _reviveAt = new ConcurrentHashMap<>(); - private final Map _tradeUntil = new ConcurrentHashMap<>(); - private final Map _nextTradeAt = new ConcurrentHashMap<>(); - + private final Map _clanSpots = new ConcurrentHashMap<>(); + protected FakePlayerHeadlessManager() { if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || (FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT <= 0)) @@ -139,12 +117,14 @@ public class FakePlayerHeadlessManager LOGGER.info(getClass().getSimpleName() + ": Disabled."); return; } - + spawnAll(FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT); ThreadPool.scheduleAtFixedRate(this::tick, 5000, 3000); LOGGER.info(getClass().getSimpleName() + ": Spawned " + _bots.size() + " headless players."); } - + + // ======================== Spawning ======================== + private void spawnAll(int count) { // Pick characters spread across all levels (levels 1..76 in the seed). @@ -168,84 +148,194 @@ public class FakePlayerHeadlessManager LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not list characters.", e); return; } - + for (int charId : charIds) { - try + spawnOne(charId); + } + } + + /** + * Loads one seeded character into the world as a headless player. + * @param charId the character id. + * @return the player, or null when it could not be loaded. + */ + private Player spawnOne(int charId) + { + try + { + final Player player = Player.load(charId); + if (player == null) { - final Player player = Player.load(charId); - if (player == null) + return null; + } + player.setHeadlessBot(true); + + // Identity from the seeded population: personality, race, home zone. + final FakePlayerBotState state = new FakePlayerBotState(); + final FakePlayerProgressionManager.SeedInfo seed = FakePlayerProgressionManager.getInstance().getSeedInfo(player.getName()); + if (seed != null) + { + state.seedId = seed.id; + state.personality = seed.personality; + state.race = seed.race; + } + state.lastLevel = player.getLevel(); + state.lastExp = player.getExp(); + state.nextTradeAt = System.currentTimeMillis() + Rnd.get(600000, 7200000); + _states.put(player.getObjectId(), state); + + // Экипируем не сразу: сервер пересчитывает уровень из опыта после загрузки, + // иначе новичок может получить шмот по «бумажному» уровню из базы. + ThreadPool.schedule(() -> equipAndTeach(player), 3000); + + // Believable player title instead of a personality label. + player.setTitle(TITLES[Rnd.get(TITLES.length)]); + // Restore clan membership so crests, clan wars and sieges work like for real players. + if (player.getClanId() > 0) + { + final Clan clan = ClanTable.getInstance().getClan(player.getClanId()); + if (clan != null) { - continue; + player.setClan(clan); + player.setPledgeType(0); + player.setPowerGrade((clan.getLeaderId() == player.getObjectId()) ? 1 : 6); } - player.setHeadlessBot(true); - - // Identity from the seeded population: personality, race, home zone. - final BotState state = new BotState(); - final FakePlayerProgressionManager.SeedInfo seed = FakePlayerProgressionManager.getInstance().getSeedInfo(player.getName()); - if (seed != null) - { - state.seedId = seed.id; - state.personality = seed.personality; - state.race = seed.race; - } - state.lastLevel = player.getLevel(); - _states.put(player.getObjectId(), state); - - // Экипируем не сразу: сервер пересчитывает уровень из опыта после загрузки, - // иначе новичок может получить шмот по «бумажному» уровню из базы. - final Player equipTarget = player; - ThreadPool.schedule(() -> equipAndTeach(equipTarget), 3000); - - // Believable player title instead of a personality label. - final String title = TITLES[Rnd.get(TITLES.length)]; - if (!title.isEmpty()) - { - player.setTitle(title); - } - else - { - player.setTitle(""); - } - // Restore clan membership so crests and clan wars work like for real players. - if (player.getClanId() > 0) - { - final Clan clan = ClanTable.getInstance().getClan(player.getClanId()); - if (clan != null) - { - player.setClan(clan); - player.setPledgeType(0); - player.setPowerGrade(6); - - } - } - _nextTradeAt.put(player.getObjectId(), System.currentTimeMillis() + Rnd.get(600000, 7200000)); - // Place the bot in a level appropriate farming zone (shared zone table). - Location spot = SPOTS[Rnd.get(SPOTS.length)]; - final BotState placedState = _states.get(player.getObjectId()); - final Object[] picked = FakePlayerProgressionManager.getInstance().pickSpot(player.getLevel(), (placedState != null) ? placedState.race : ""); + } + // Place the bot in a level appropriate farming zone (shared zone table). Clanmates of a + // similar level start on the same spot, so real parties form between them. + Location spot = new Location(GIRAN.getX() + Rnd.get(-2000, 2000), GIRAN.getY() + Rnd.get(-2000, 2000), GIRAN.getZ()); + final String clanKey = (player.getClanId() > 0) ? (player.getClanId() + ":" + (player.getLevel() / 6) + ":" + state.race) : null; + final Object[] shared = (clanKey != null) ? _clanSpots.get(clanKey) : null; + if ((shared != null) && (Rnd.get(100) < 70)) + { + spot = (Location) shared[0]; + state.zone = (String) shared[1]; + } + else + { + final Object[] picked = FakePlayerProgressionManager.getInstance().pickSpot(player.getLevel(), state.race); if (picked != null) { spot = (Location) picked[0]; - if (placedState != null) + state.zone = (String) picked[1]; + if (clanKey != null) { - placedState.zone = (String) picked[1]; + _clanSpots.put(clanKey, picked); } } - player.getStatus().setCurrentHp(player.getMaxHp()); - player.getStatus().setCurrentMp(player.getMaxMp()); - player.getStatus().setCurrentCp(player.getMaxCp()); - player.spawnMe(spot.getX() + Rnd.get(-400, 400), spot.getY() + Rnd.get(-400, 400), spot.getZ()); - player.setRunning(); - _bots.add(player); - } - catch (Exception e) - { - LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not load char " + charId, e); } + state.spot = spot; + player.getStatus().setCurrentHp(player.getMaxHp()); + player.getStatus().setCurrentMp(player.getMaxMp()); + player.getStatus().setCurrentCp(player.getMaxCp()); + player.spawnMe(spot.getX() + Rnd.get(-400, 400), spot.getY() + Rnd.get(-400, 400), spot.getZ()); + player.setRunning(); + _bots.add(player); + return player; + } + catch (Exception e) + { + LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not load char " + charId, e); + return null; } } - + + /** + * Makes sure a seeded character (a clan leader before a siege, for example) is in the world. + * @param charId the character id. + * @return the headless player, or null. + */ + public Player ensureOnline(int charId) + { + if (charId <= 0) + { + return null; + } + for (Player bot : _bots) + { + if (bot.getObjectId() == charId) + { + return bot; + } + } + if (World.getPlayer(charId) != null) + { + return null; // A real player (or someone else) holds this character. + } + final Player bot = spawnOne(charId); + if (bot != null) + { + LOGGER.info(getClass().getSimpleName() + ": " + bot.getName() + " logged in on demand (" + _bots.size() + " headless players now)."); + } + return bot; + } + + // ======================== Registry ======================== + + /** + * @return all headless bots (read only). + */ + public List bots() + { + return _view; + } + + /** + * @param bot a headless bot. + * @return its state, null for other players. + */ + public FakePlayerBotState stateOf(Player bot) + { + return (bot == null) ? null : _states.get(bot.getObjectId()); + } + + /** + * @param player any player. + * @return true when the player is one of the headless bots. + */ + public boolean isBot(Player player) + { + return (player != null) && _states.containsKey(player.getObjectId()); + } + + /** + * @param bot a headless bot. + * @return its seed character id (idiolect/anti-repeat identity), 0 when unknown. + */ + public int seedIdOf(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + return (state != null) ? state.seedId : 0; + } + + /** + * @param bot a headless bot. + * @return its personality, "neutral" when unknown. + */ + public String personalityOf(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + return (state != null) ? state.personality : "neutral"; + } + + /** + * @param bot a headless bot. + * @return the name of its farming zone ("" when unknown). + */ + public String zoneOf(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + return (state != null) ? state.zone : ""; + } + + public int size() + { + return _bots.size(); + } + + // ======================== Party ======================== + /** * Auto accept a party invite - mirrors RequestAnswerJoinParty (response 1). * @param requestor the inviting player. @@ -272,6 +362,12 @@ public class FakePlayerHeadlessManager } target.setActiveRequester(null); requestor.onTransactionResponse(); + onJoinedParty(target); + final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(target), "party_accept", requestor, null); + if (line != null) + { + ThreadPool.schedule(() -> say(target, line, "party_accept"), Rnd.get(1500, 4000)); + } } catch (Exception e) { @@ -279,7 +375,24 @@ public class FakePlayerHeadlessManager } }, 1000 + Rnd.get(1500)); } - + + /** + * Bookkeeping when a bot ends up in a party with a real player. + * @param bot the bot. + */ + public void onJoinedParty(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.partiesJoined.incrementAndGet(); + state.action("party"); + closeStore(bot); + } + } + + // ======================== Tick ======================== + private void tick() { final long now = System.currentTimeMillis(); @@ -295,7 +408,7 @@ public class FakePlayerHeadlessManager } } } - + private void handle(Player bot, long now) { // Safety net: a headless bot must never stay decayed after a teleport. @@ -306,106 +419,154 @@ public class FakePlayerHeadlessManager bot.broadcastUserInfo(); return; } - - if (bot.isDead()) + + final FakePlayerBotState state = _states.get(bot.getObjectId()); + if (state == null) { - final Long at = _reviveAt.get(bot.getObjectId()); - if (at == null) - { - _reviveAt.put(bot.getObjectId(), now + 15000); - } - else if (now >= at) - { - _reviveAt.remove(bot.getObjectId()); - - // Respawn in the nearest town, exactly like a player pressing "to village". - final Location town = MapRegionData.getInstance().getTeleToLocation(bot, TeleportWhereType.TOWN); - if (town != null) - { - teleport(bot, town); - } - bot.doRevive(); - bot.getStatus().setCurrentHp(bot.getMaxHp() * 0.7); - bot.getStatus().setCurrentMp(bot.getMaxMp() * 0.7); - bot.broadcastUserInfo(); - - // Head back to the farming zone after a short break. - final BotState state = _states.get(bot.getObjectId()); - final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), (state != null) ? state.race : ""); - if (spot != null) - { - final Location back = (Location) spot[0]; - if (state != null) - { - state.zone = (String) spot[1]; - } - ThreadPool.schedule(() -> - { - if (!bot.isDead()) - { - teleport(bot, back); - } - }, 20000 + Rnd.get(40000)); - } - } return; } - - // Party behavior: stay with the leader, assist their target. - final Party party = bot.getParty(); - if (party != null) + + if (bot.isDead()) { - final Player leader = party.getLeader(); - if ((leader != null) && (leader != bot) && !leader.isHeadlessBot()) - { - if (bot.calculateDistance2D(leader) > 3000) - { - teleport(bot, leader.getLocation()); - return; - } - final WorldObject leaderTarget = leader.getTarget(); - if ((leaderTarget instanceof Monster monster) && leader.isInCombat() && !monster.isDead()) - { - if (bot.getTarget() != monster) - { - bot.setTarget(monster); - } - bot.getAI().setIntentionAttack(monster); - return; - } - if (!bot.isInCombat() && (bot.calculateDistance2D(leader) > 250)) - { - bot.getAI().setIntentionFollow(leader); - return; - } - return; - } + handleDead(bot, state, now); + return; } - - // Раз в минуту сверяем экипировку с уровнем: после апа тира надо переодеться, - // а после отката уровня - снять слишком высокий грейд. - final BotState gearState = _states.get(bot.getObjectId()); - if ((gearState != null) && (now > gearState.nextGearCheck)) + + // Real level ups happen by themselves (they earn real exp) - react, take the profession, migrate. + final long exp = bot.getExp(); + if (exp > state.lastExp) { - gearState.nextGearCheck = now + 60000; - if ((bot.getExpertisePenaltyBonus() > 0) || (FakePlayerProgressionManager.tierOf(bot.getLevel()) != gearState.gearTier)) + state.expGained.addAndGet(exp - state.lastExp); + } + state.lastExp = exp; + if (bot.getLevel() > state.lastLevel) + { + final int oldTier = FakePlayerProgressionManager.tierOf(state.lastLevel); + final int newTier = FakePlayerProgressionManager.tierOf(bot.getLevel()); + state.lastLevel = bot.getLevel(); + if (FakePlayerEconomy.getInstance().checkProfession(bot, state) != null) { - gearState.gearTier = FakePlayerProgressionManager.tierOf(bot.getLevel()); equipAndTeach(bot); + final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "profession", null, Map.of("class", String.valueOf(bot.getPlayerClass()))); + if (line != null) + { + ThreadPool.schedule(() -> say(bot, line, "profession"), Rnd.get(2000, 6000)); + } } - } - - // Заблокированная цель (ганг / дебаг-команда): не отвлекаемся на мобов. - final BotState focus = _states.get(bot.getObjectId()); - if ((focus != null) && (focus.forcedTargetId != 0)) - { - if (now >= focus.forcedUntil) + if ((newTier != oldTier) && (state.siegeSide == 0) && (FakePlayerSocial.humanPartyAnchor(bot) == null)) { - focus.forcedTargetId = 0; + migrate(bot, state, true); } else { - final WorldObject victim = World.findObject(focus.forcedTargetId); + FakePlayerSocial.getInstance().onLevelUp(bot, state); + } + return; + } + + // Castle siege: the siege logic owns the bot while the battle lasts. + if ((state.siegeSide != 0) && FakePlayerSiegeManager.getInstance().handle(bot, state, now)) + { + return; + } + + // Perception and talk first: a farming bot still notices people and chats. + if (FakePlayerSocial.getInstance().tick(bot, state, now)) + { + return; + } + + // Party with a real player: stay with them, assist their target. + final Player anchor = FakePlayerSocial.humanPartyAnchor(bot); + if (anchor != null) + { + if (bot.calculateDistance2D(anchor) > 3000) + { + teleport(bot, anchor.getLocation()); + return; + } + final WorldObject anchorTarget = anchor.getTarget(); + if ((anchorTarget instanceof Monster monster) && anchor.isInCombat() && !monster.isDead()) + { + if (bot.getTarget() != monster) + { + bot.setTarget(monster); + } + bot.getAI().setIntentionAttack(monster); + state.action("assist party"); + return; + } + if (FakePlayerBrain.act(bot, state, false)) + { + return; + } + if (!bot.isInCombat() && (bot.calculateDistance2D(anchor) > 250)) + { + bot.getAI().setIntentionFollow(anchor); + } + return; + } + + // Squad (bot only party): members stay with the leader and assist its target. + final Party squad = bot.getParty(); + if ((squad != null) && (squad.getLeader() != null) && (squad.getLeader() != bot) && !squad.getLeader().isDead() && (state.forcedTargetId == 0)) + { + final Player leader = squad.getLeader(); + final double distance = bot.calculateDistance2D(leader); + if (distance > 4000) + { + teleport(bot, leader.getX() + Rnd.get(-150, 150), leader.getY() + Rnd.get(-150, 150), leader.getZ()); + return; + } + final WorldObject leaderTarget = leader.getTarget(); + if (!bot.isInCombat() && (leaderTarget instanceof Monster monster) && !monster.isDead() && (monster.getTarget() != null) && (bot.calculateDistance2D(monster) < 1500)) + { + bot.setTarget(monster); + bot.getAI().setIntentionAttack(monster); + state.action("assist squad"); + return; + } + if (FakePlayerBrain.act(bot, state, distance < 900)) + { + return; + } + if (distance > 500) + { + bot.setRunning(); + bot.getAI().setIntentionFollow(leader); + state.action("follow squad"); + return; + } + } + + // Раз в минуту сверяем экипировку с уровнем: после апа тира надо переодеться, + // а после отката уровня - снять слишком высокий грейд. + if (now > state.nextGearCheck) + { + state.nextGearCheck = now + 60000; + if ((bot.getExpertisePenaltyBonus() > 0) || (FakePlayerProgressionManager.tierOf(bot.getLevel()) != state.gearTier)) + { + state.gearTier = FakePlayerProgressionManager.tierOf(bot.getLevel()); + equipAndTeach(bot); + } + FakePlayerEconomy.getInstance().ensureShots(bot); + // Buffs ran out: a player would run to the buffer; we re-buff on the spot between fights. + if (!bot.isInCombat() && !bot.isAffectedBySkill(1204)) + { + FakePlayerEconomy.getInstance().buffUp(bot); + } + } + + // Заблокированная цель (ганг / клановая война / дебаг-команда): не отвлекаемся на мобов. + if (state.forcedTargetId != 0) + { + if (now >= state.forcedUntil) + { + state.forcedTargetId = 0; + } + else + { + final WorldObject victim = World.findObject(state.forcedTargetId); if ((victim instanceof Player victimPlayer) && !victimPlayer.isDead() && (bot.calculateDistance2D(victimPlayer) < 3000)) { if ((bot.getTarget() != victimPlayer) || !bot.isAttackingNow()) @@ -415,243 +576,194 @@ public class FakePlayerHeadlessManager } return; } - focus.forcedTargetId = 0; + state.forcedTargetId = 0; } } - - // Game sense: survival, upkeep, party role, combat rotation, target picking. + + // Private store shift: sitting in town with a real sell store. + if (state.tradeUntil > 0) + { + if (now < state.tradeUntil) + { + return; + } + closeStore(bot); + state.nextTradeAt = now + Rnd.get(1800000, 5400000); + // Restock with the adena earned and go back to the spot. + FakePlayerEconomy.getInstance().visitTown(bot, state); + ThreadPool.schedule(() -> returnToSpot(bot), Rnd.get(5000, 20000)); + return; + } + + // Game sense: survival, upkeep, party role, combat rotation, loot, target picking. // Runs before the "life" logic so a bot never trades or wanders while it is // bleeding out or has a monster on its back. - final BotState brainState = _states.get(bot.getObjectId()); - if (brainState != null) - { - final boolean hunts = !_tradeUntil.containsKey(bot.getObjectId()); - if (FakePlayerBrain.act(bot, brainState.combat, hunts)) - { - return; - } - } - - // Private store shift: sit in Giran with a real sell store. - final Long tradeEnd = _tradeUntil.get(bot.getObjectId()); - if (tradeEnd != null) - { - if (now < tradeEnd) - { - return; - } - _tradeUntil.remove(bot.getObjectId()); - bot.setPrivateStoreType(PrivateStoreType.NONE); - bot.standUp(); - bot.broadcastUserInfo(); - _nextTradeAt.put(bot.getObjectId(), now + Rnd.get(1800000, 5400000)); - } - else - { - final Long tradeAt = _nextTradeAt.get(bot.getObjectId()); - if ((tradeAt != null) && (now >= tradeAt) && !bot.isInCombat() && (bot.getParty() == null) && !bot.isDead()) - { - // A bot goes to the market only when it actually has farmed loot to sell. - if (!sellableLoot(bot).isEmpty() && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_TRADE_CHANCE)) - { - openStore(bot, now); - return; - } - _nextTradeAt.put(bot.getObjectId(), now + Rnd.get(600000, 1800000)); - } - } - - final BotState state = _states.get(bot.getObjectId()); - if (state != null) - { - // Real level ups happen by themselves (they earn real exp) - react and migrate. - if (bot.getLevel() > state.lastLevel) - { - final int oldTier = FakePlayerProgressionManager.tierOf(state.lastLevel); - final int newTier = FakePlayerProgressionManager.tierOf(bot.getLevel()); - state.lastLevel = bot.getLevel(); - if (newTier != oldTier) - { - eventChat(bot, "Ты апнул " + bot.getLevel() + " уровень и перерос эту локацию. Попрощайся, уходишь на новый спот.", "migrate", null, null); - final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race); - if (spot != null) - { - teleport(bot, (Location) spot[0]); - state.zone = (String) spot[1]; - } - } - else - { - final Map slots = new HashMap<>(); - slots.put("level", String.valueOf(bot.getLevel())); - FakePlayerBotContext.setEvent(seedIdOf(bot), "levelup"); - if ((bot.getClan() != null) && (Rnd.get(100) < 40)) - { - final BotState levelState = _states.get(bot.getObjectId()); - final String clanLine = FakePlayerChatLines.getInstance().compose((levelState != null) ? levelState.seedId : 0, "levelup", (levelState != null) ? levelState.personality : "neutral", slots); - if (clanLine != null) - { - bot.getClan().broadcastToOnlineMembers(new CreatureSay(bot, ChatType.CLAN, bot.getName(), clanLine)); - } - } - else - { - eventChat(bot, "Ты только что взял " + bot.getLevel() + " уровень. Порадуйся одной фразой.", "levelup", slots, null); - } - } - return; - } - - // Credit players helping this bot with its monster. - final WorldObject current = bot.getTarget(); - if (bot.isInCombat() && (current instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000)) - { - final Player helper = World.getNearestVisibleObjectInRange(bot, Player.class, 900, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat() && (other.getTarget() == monsterTarget)); - if (helper != null) - { - state.lastHelpCredit = now; - FakePlayerMemoryManager.getInstance().onHelpedBy(state.seedId, helper.getName()); - } - } - - // Personality actions against real players. - if (!bot.isInCombat() && (bot.getParty() == null)) - { - Player victim = null; - String seam = null; - String instruction = null; - if ("ganker".equals(state.personality)) - { - victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible() && !other.isGM()); - seam = "gank_start"; - instruction = "Ты нападаешь на игрока. Крикни что-нибудь дерзкое."; - } - else if ("pkk".equals(state.personality)) - { - victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && ((other.getKarma() > 0) || (other.getPvpFlag() != 0))); - seam = "pkk_start"; - instruction = "Ты охотник на ПК и нападаешь на нарушителя. Крикни про правосудие."; - } - if ((victim != null) && (victim.getObjectId() != state.lastPvpTargetId)) - { - state.lastPvpTargetId = victim.getObjectId(); - state.lastChat = now; - forceTarget(bot, victim, 45); - eventChat(bot, instruction, seam, Map.of("target", victim.getName()), victim.getName()); - return; - } - - // Helpers assist real players fighting monsters. - if ("helper".equals(state.personality)) - { - final Player ally = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat()); - if (ally != null) - { - final WorldObject allyTarget = ally.getTarget(); - if ((allyTarget instanceof Monster monster) && !monster.isDead()) - { - bot.setTarget(monster); - bot.getAI().setIntentionAttack(monster); - if ((now - state.lastChat) > TAUNT_COOLDOWN) - { - state.lastChat = now; - eventChat(bot, "Ты помогаешь игроку " + ally.getName() + " убить монстра. Скажи что-то дружелюбное.", "assist", Map.of("player", ally.getName()), ally.getName()); - } - return; - } - } - } - } - - // Ambient talk when a real player is around (memory and rumors aware). - if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE)) - { - final Player nearby = World.getNearestVisibleObjectInRange(bot, Player.class, 700, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible()); - if (nearby != null) - { - state.lastChat = now; - final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(state.seedId, nearby.getName()); - final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(state.seedId, nearby.getName()); - if ((memory != null) && (memory.score <= -2)) - { - eventChat(bot, "Рядом игрок " + nearby.getName() + ", который тебя убивал. Скажи что-то злопамятное.", "revenge_meet", Map.of("player", nearby.getName()), nearby.getName()); - } - else if ((memory != null) && (memory.score >= 2)) - { - eventChat(bot, "Рядом игрок " + nearby.getName() + ", который тебе помогал. Поприветствуй тепло.", "friendly_meet", Map.of("player", nearby.getName()), nearby.getName()); - } - else if (rumor != null) - { - say(bot, FakePlayerRumorManager.getInstance().tell(rumor)); - } - else if ((("helper".equals(state.personality)) || ("neutral".equals(state.personality))) && (Rnd.get(100) < 30)) - { - eventChat(bot, "Рядом игрок " + nearby.getName() + ". Позови его в пати.", "invite_player", Map.of("player", nearby.getName()), nearby.getName()); - } - else - { - eventChat(bot, "Рядом игрок. Скажи что-нибудь бытовое про фарм, дроп или спот.", "ambient", null, null); - } - } - } - - // Squads: headless bots of the same clan form REAL parties with each other. - if ((bot.getParty() == null) && (now > state.partyCooldown) && (bot.getClanId() > 0) && (Rnd.get(100) < 10)) - { - state.partyCooldown = now + 300000; - final Player mate = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && (other.getParty() == null) && (other.getClanId() == bot.getClanId())); - if (mate != null) - { - final Party squad = new Party(bot, bot.getPartyDistributionType()); - bot.setParty(squad); - mate.joinParty(squad); - say(bot, FakePlayerChatLines.getInstance().compose(state.seedId, "invite_player", state.personality, "player", mate.getName()), "invite_player"); - } - } - } - - // Clan war PvP: attack players and headless bots of enemy clans. - if (!bot.isInCombat() && (bot.getClan() != null) && (Rnd.get(100) < 40)) - { - final Clan clan = bot.getClan(); - final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> !other.isDead() && (other != bot) && (other.getClan() != null) && (clan.isAtWarWith(other.getClanId()) || other.getClan().isAtWarWith(clan.getId()))); - if (enemy != null) - { - forceTarget(bot, enemy, 45); - return; - } - } - - if (bot.isInCombat() || bot.isAttackingNow()) + if (FakePlayerBrain.act(bot, state, true)) { return; } - - // Grab the loot that dropped from the last kills. - pickupLoot(bot); - + + // Time to sell: a bot goes to the market only when it has real loot worth a store. + if ((state.nextTradeAt > 0) && (now >= state.nextTradeAt) && !bot.isInCombat() && (bot.getParty() == null)) + { + if (FakePlayersConfig.FAKE_PLAYER_TRADING && !FakePlayerEconomy.getInstance().storeGoods(bot).isEmpty() && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_TRADE_CHANCE)) + { + openStore(bot, now); + return; + } + state.nextTradeAt = now + Rnd.get(600000, 1800000); + } + // Nothing to fight nearby - wander a bit inside the zone. if (Rnd.get(100) < 30) { bot.getAI().setIntentionMoveTo(new Location(bot.getX() + Rnd.get(-800, 800), bot.getY() + Rnd.get(-800, 800), bot.getZ())); } } - + + private void handleDead(Player bot, FakePlayerBotState state, long now) + { + final Long at = _reviveAt.get(bot.getObjectId()); + if (at == null) + { + _reviveAt.put(bot.getObjectId(), now + 15000); + return; + } + if (now < at) + { + return; + } + _reviveAt.remove(bot.getObjectId()); + + // Respawn in the nearest town, exactly like a player pressing "to village". + final Location town = MapRegionData.getInstance().getTeleToLocation(bot, TeleportWhereType.TOWN); + if (town != null) + { + teleport(bot, town); + } + bot.doRevive(); + bot.getStatus().setCurrentHp(bot.getMaxHp() * 0.7); + bot.getStatus().setCurrentMp(bot.getMaxMp() * 0.7); + bot.broadcastUserInfo(); + state.action("revived"); + + // In town anyway: sell the junk, buy shots, then head back to the farming zone after a short break. + ThreadPool.schedule(() -> + { + if (!bot.isDead()) + { + FakePlayerEconomy.getInstance().visitTown(bot, state); + } + }, 4000); + if (state.siegeSide != 0) + { + return; // The siege logic brings the bot back to the battle. + } + ThreadPool.schedule(() -> + { + if (!bot.isDead() && (state.siegeSide == 0)) + { + returnToSpot(bot); + } + }, 20000 + Rnd.get(40000)); + } + /** - * Says a line in local chat as this bot (real player broadcast). + * Teleports the bot back to a farming spot of its level (a fresh one, like a player choosing a location). + * @param bot the bot. + */ + public void returnToSpot(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + if ((state == null) || bot.isDead() || bot.isInStoreMode()) + { + return; + } + final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race); + if (spot != null) + { + state.spot = (Location) spot[0]; + state.zone = (String) spot[1]; + teleport(bot, state.spot); + state.action("back to " + state.zone); + } + } + + /** + * Moves the bot to a zone of its level (tier change or debug): a farewell line, town shopping, new spot. + * @param bot the bot. + * @param state its state. + * @param farewell true to say goodbye first. + */ + private void migrate(Player bot, FakePlayerBotState state, boolean farewell) + { + final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race); + if (spot == null) + { + return; + } + state.migrations.incrementAndGet(); + FakePlayerBotContext.setPlan(state.seedId, "migrate", 300000); + if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null)) + { + bot.leaveParty(); + } + if (farewell) + { + final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "migrate", null, null); + if (line != null) + { + say(bot, line, "migrate"); + } + } + closeStore(bot); + final Location target = (Location) spot[0]; + final String zone = (String) spot[1]; + ThreadPool.schedule(() -> + { + if (bot.isDead()) + { + return; + } + // Through town: a player would sell and restock on the way to a new area. + FakePlayerEconomy.getInstance().visitTown(bot, state); + state.spot = target; + state.zone = zone; + teleport(bot, target); + state.action("migrated to " + zone); + }, farewell ? Rnd.get(4000, 9000) : 500); + } + + // ======================== Speech ======================== + + private static final Logger CHAT_LOG = Logger.getLogger("fakeplayer.chat"); + + /** + * Writes a bot line to the chat log (FakePlayerChatLog = True): the way to see what the population says + * without standing next to every bot. + * @param bot the speaker. + * @param channel channel name. + * @param seam seam the line came from. + * @param message the line. + */ + private static void logChat(Player bot, String channel, String seam, String message) + { + if (FakePlayersConfig.FAKE_PLAYER_CHAT_LOG) + { + CHAT_LOG.info("[" + channel + "] " + bot.getName() + " (" + seam + "): " + message); + } + } + + /** + * Says a line in local chat as this bot (real player broadcast) and lets the neighbours hear it. * @param bot the headless player. * @param message the text. */ public void say(Player bot, String message) { - if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead()) - { - return; - } - bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message)); - FakePlayerHeardManager.getInstance().onSpeech(bot, message); + say(bot, message, ""); } - + public void say(Player bot, String message, String seam) { if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead()) @@ -659,9 +771,37 @@ public class FakePlayerHeadlessManager return; } bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message)); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + } + logChat(bot, "say", seam, message); FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam); } - + + /** + * A dead bot's line: the corpse lies there and the chat still works, like for real players. + * @param bot the dead bot. + * @param message the text. + * @param seam the seam. + */ + public void sayDead(Player bot, String message, String seam) + { + if ((bot == null) || (message == null) || message.isEmpty()) + { + return; + } + bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message)); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + } + logChat(bot, "dead", seam, message); + FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam); + } + /** * Say without the heard hook - used for pickup replies (depth 1). * @param bot the headless bot. @@ -674,55 +814,83 @@ public class FakePlayerHeadlessManager return; } bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message)); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + state.repliesGiven.incrementAndGet(); + } + logChat(bot, "pickup", "", message); } - + /** - * @param player any player. - * @return true when the player is one of the headless bots. + * Region-wide shout (call for help, siege cries). + * @param bot the bot. + * @param message the line. + * @param seam the seam. */ - public boolean isBot(Player player) + public void shout(Player bot, String message, String seam) { - return (player != null) && _states.containsKey(player.getObjectId()); + if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead()) + { + return; + } + World.broadcastToVisiblePlayersInRange(bot, new CreatureSay(bot, ChatType.SHOUT, bot.getName(), message), 6000); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + } + logChat(bot, "shout", seam, message); + FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam); } - + /** - * @param bot a headless bot. - * @return its seed character id (idiolect/anti-repeat identity), 0 when unknown. + * Trade channel line (party search, sales). + * @param bot the bot. + * @param message the line. + * @param seam the seam. */ - public int seedIdOf(Player bot) + public void trade(Player bot, String message, String seam) { - final BotState state = (bot == null) ? null : _states.get(bot.getObjectId()); - return (state != null) ? state.seedId : 0; + if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead()) + { + return; + } + World.broadcastToVisiblePlayersInRange(bot, new CreatureSay(bot, ChatType.TRADE, bot.getName(), message), 9000); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + } + logChat(bot, "trade", seam, message); } - - /** - * @param bot a headless bot. - * @return its personality, "neutral" when unknown. - */ - public String personalityOf(Player bot) + + public void whisper(Player bot, Player to, String message) { - final BotState state = (bot == null) ? null : _states.get(bot.getObjectId()); - return (state != null) ? state.personality : "neutral"; - } - - private void whisper(Player bot, Player to, String message) - { - if ((message == null) || message.isEmpty()) + if ((message == null) || message.isEmpty() || (to == null)) { return; } to.sendPacket(new CreatureSay(bot, ChatType.WHISPER, bot.getName(), message)); + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.linesSaid.incrementAndGet(); + state.repliesGiven.incrementAndGet(); + } + logChat(bot, "whisper->" + to.getName(), "", message); } - + /** * Persona for the LLM, including this bot's memory and rumors about the target. * @param bot the headless player. * @param about player name the bot talks to or about. * @return prompt text. */ - private String persona(Player bot, String about) + public String persona(Player bot, String about) { - final BotState state = _states.get(bot.getObjectId()); + final FakePlayerBotState state = stateOf(bot); final StringBuilder sb = new StringBuilder(256); sb.append("Ты - ").append(bot.getName()).append(", игрок на сервере Lineage 2 Interlude, класс ").append(bot.getPlayerClass()).append(", уровень ").append(bot.getLevel()).append('.'); if (state != null) @@ -745,6 +913,11 @@ public class FakePlayerHeadlessManager default: sb.append(" Ты спокойно фармишь, болтаешь о дропе, ценах и сервере."); } + final String plan = FakePlayerBotContext.planOf(state.seedId); + if (!"farm".equals(plan)) + { + sb.append(" Сейчас ты занят: ").append(plan).append('.'); + } if (about != null) { final String memory = FakePlayerMemoryManager.getInstance().describe(state.seedId, about); @@ -761,31 +934,7 @@ public class FakePlayerHeadlessManager } return sb.toString(); } - - /** - * Event comment: LLM when available, seam line otherwise. - * @param bot the bot. - * @param instruction LLM instruction. - * @param seam seam key for the fallback. - * @param slots seam slots. - * @param about player the event is about (memory context), may be null. - */ - private void eventChat(Player bot, String instruction, String seam, Map slots, String about) - { - final BotState state = _states.get(bot.getObjectId()); - final int seedId = (state != null) ? state.seedId : 0; - final String mood = (state != null) ? state.personality : "neutral"; - final String fallback = FakePlayerChatLines.getInstance().compose(seedId, seam, mood, slots); - if (FakePlayerLlmService.getInstance().isEnabled()) - { - FakePlayerLlmService.getInstance().generate(persona(bot, about), instruction, reply -> say(bot, reply, seam), () -> say(bot, fallback, seam)); - } - else - { - say(bot, fallback, seam); - } - } - + /** * Called from ChatWhisper when a player whispers a headless bot. * @param player the speaker. @@ -794,246 +943,175 @@ public class FakePlayerHeadlessManager */ public void onWhisper(Player player, Player bot, String message) { - final BotState state = _states.get(bot.getObjectId()); - final int seedId = (state != null) ? state.seedId : 0; - final String personality = (state != null) ? state.personality : "neutral"; - final String reply = understand(player, bot, message, personality, seedId); - if (FakePlayerLlmService.getInstance().isEnabled()) + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot); + final Understanding understanding = FakePlayerUnderstanding.understand(ref, player, message, true); + if ((understanding != null) && understanding.silent) { - final String hint = FakePlayerChatManager.intentHint(lastIntent(message)); + return; // The bot ignores the stranger, like real players sometimes do. + } + final String templateReply = ((understanding != null) && (understanding.responseLine != null)) ? understanding.responseLine : null; + final String fallback = (templateReply != null) ? templateReply : FakePlayerChatLines.getInstance().speakPrivate(ref.botId, "whisper_default", FakePlayerUnderstanding.facts(ref, player), FakePlayerUnderstanding.slots(ref, player), FakePlayerIdiolect.Mirror.of(message)); + final boolean useLlm = FakePlayerLlmService.getInstance().isEnabled() && ("llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY) || (templateReply == null)); + final long delay = Rnd.get(1500, 5000); + if (useLlm) + { + final String hint = (understanding != null) ? understanding.llmHint : ""; FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " написал тебе в личные сообщения: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer -> { whisper(bot, player, answer); - FakePlayerMemoryManager.getInstance().addNote(seedId, player.getName(), "он писал: " + message + " / я: " + answer); - }, () -> whisper(bot, player, (reply != null) ? reply : FakePlayerChatLines.getInstance().pick("whisper_default", null))); + FakePlayerMemoryManager.getInstance().addNote(ref.botId, player.getName(), "он писал: " + message + " / я: " + answer); + }, () -> ThreadPool.schedule(() -> whisper(bot, player, fallback), delay)); return; } - whisper(bot, player, (reply != null) ? reply : FakePlayerChatLines.getInstance().pick("whisper_default", null)); + ThreadPool.schedule(() -> whisper(bot, player, fallback), delay); } - + /** - * Called from ChatGeneral when a player speaks near a headless bot. + * Called when a player speaks near a headless bot. * @param player the speaker. * @param bot the headless bot. * @param message the text. */ public void onLocalChat(Player player, Player bot, String message) { - final BotState state = _states.get(bot.getObjectId()); - final int seedId = (state != null) ? state.seedId : 0; - final String personality = (state != null) ? state.personality : "neutral"; - final String reply = understand(player, bot, message, personality, seedId); + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot); + final Understanding understanding = FakePlayerUnderstanding.understand(ref, player, message, false); + if ((understanding != null) && understanding.silent) + { + return; + } + final String templateReply = ((understanding != null) && (understanding.responseLine != null)) ? understanding.responseLine : null; + final boolean useLlm = FakePlayerLlmService.getInstance().isEnabled() && ("llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY) || (templateReply == null)); + final FakePlayerBotState state = stateOf(bot); ThreadPool.schedule(() -> { - if (FakePlayerLlmService.getInstance().isEnabled()) + if (bot.isDead() || (bot.calculateDistance2D(player) > 1500)) { - final String hint = FakePlayerChatManager.intentHint(lastIntent(message)); - FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer -> say(bot, answer), () -> say(bot, reply)); + return; } - else if (reply != null) + if (useLlm) { - say(bot, reply); + final String hint = (understanding != null) ? understanding.llmHint : ""; + FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer -> reply(bot, state, answer, "reply"), () -> reply(bot, state, templateReply, (understanding != null) ? understanding.intentKey : "reply")); + } + else + { + reply(bot, state, templateReply, (understanding != null) ? understanding.intentKey : "reply"); } }, Rnd.get(3000, 8000)); } - - private String lastIntent(String message) + + private void reply(Player bot, FakePlayerBotState state, String line, String seam) { - final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message); - return (intent != null) ? intent.key : ""; + if (line == null) + { + return; + } + say(bot, line, seam); + if (state != null) + { + state.repliesGiven.incrementAndGet(); + state.action("reply"); + } } - + /** - * Applies the intent effects for a headless bot and returns the template reply. - * @param player speaker. - * @param bot headless bot. - * @param message text. - * @param personality bot personality. - * @param seedId memory id. - * @return reply line or null. + * Called from Player.doDie when a headless bot dies (any killer). + * @param bot the dead bot. + * @param killer the killer (may be null). */ - private String understand(Player player, Player bot, String message, String personality, int seedId) + public void onBotDied(Player bot, Creature killer) { - final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message); - if (intent == null) + final FakePlayerBotState state = stateOf(bot); + if (state == null) { - return null; + return; } - - String seam = intent.seam; - final Map slots = new HashMap<>(); - slots.put("player", player.getName()); - final BotState state = _states.get(bot.getObjectId()); - slots.put("zone", ((state != null) && !state.zone.isEmpty()) ? state.zone : "поле"); - slots.put("level", String.valueOf(bot.getLevel())); - - switch (intent.key) - { - case "insult": - { - FakePlayerMemoryManager.getInstance().adjustScore(seedId, player.getName(), -1); - final boolean aggressive = "ganker".equals(personality) || "pkk".equals(personality); - seam = aggressive ? "reply_insult_aggro" : "reply_insult_soft"; - if (aggressive && (bot.calculateDistance2D(player) < 1500)) - { - forceTarget(bot, player, 45); - } - break; - } - case "thanks": - { - FakePlayerMemoryManager.getInstance().adjustScore(seedId, player.getName(), 1); - break; - } - case "ask_help": - { - final boolean willing = !"ganker".equals(personality) && (bot.calculateDistance2D(player) < 3000); - seam = willing ? "reply_help_yes" : "reply_help_no"; - if (willing) - { - final WorldObject playerTarget = player.getTarget(); - if ((playerTarget instanceof Monster monster) && !monster.isDead()) - { - bot.setTarget(monster); - bot.getAI().setIntentionAttack(monster); - } - else - { - bot.getAI().setIntentionFollow(player); - } - } - break; - } - case "ask_party": - { - // Real party: the bot invites the player back, the client shows a normal invite. - seam = "ganker".equals(personality) ? "reply_party_no" : "party_accept"; - break; - } - case "ask_rumors": - { - final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(seedId); - if (story != null) - { - return "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story); - } - break; - } - } - return seam.isEmpty() ? null : FakePlayerChatLines.getInstance().compose(seedId, seam, personality, slots); + state.forcedTargetId = 0; + FakePlayerSocial.getInstance().onDied(bot, state, killer); } - + /** - * Called from Player.doDie when a headless bot is killed by a player. + * Kept for older call sites: a player killed the bot. * @param bot the dead bot. * @param killer the killer. */ public void onBotKilled(Player bot, Player killer) { - final BotState state = _states.get(bot.getObjectId()); + onBotDied(bot, killer); + } + + /** + * Called from Attackable.doDie when a headless bot kills a monster. + * @param bot the bot. + * @param victim the monster. + */ + public void onKilledMonster(Player bot, Creature victim) + { + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.kills.incrementAndGet(); + state.action("killed " + victim.getName()); + } + } + + /** + * Called by the brain after picking an item up. + * @param bot the bot. + * @param item the item. + */ + public void onLootPicked(Player bot, Item item) + { + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.lootPicked.incrementAndGet(); + if (item.getId() != 57) + { + state.lastDrop = item.getName(); + } + } + } + + // ======================== Trading ======================== + + private void openStore(Player bot, long now) + { + final FakePlayerBotState state = stateOf(bot); if (state == null) { return; } - FakePlayerMemoryManager.getInstance().onKilledBy(state.seedId, killer.getName()); - FakePlayerBotContext.setEvent(state.seedId, "ganked"); - final String deathCry = FakePlayerChatLines.getInstance().compose(state.seedId, "killed_by", state.personality, "player", killer.getName()); - if (deathCry != null) - { - ThreadPool.schedule(() -> - { - if (bot != null) - { - // The bot lies dead for a while - dead players still type. - bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), deathCry)); - FakePlayerHeardManager.getInstance().onSpeech(bot, deathCry, "killed_by"); - } - }, Rnd.get(1500, 4000)); - } - - // Nearby bots witness the kill (rumor seed) - both headless and NPC ones. - final String victimName = bot.getName(); - World.forEachVisibleObjectInRange(bot, Player.class, 900, witness -> - { - if (witness.isHeadlessBot() && (witness != bot)) - { - final BotState witnessState = _states.get(witness.getObjectId()); - if (witnessState != null) - { - FakePlayerRumorManager.getInstance().addWitnessed(witnessState.seedId, (killer.getKarma() > 0) ? "pk" : "kill", killer.getName(), victimName); - } - } - }); - } - - /** - * Opens a real private sell store in Giran: gives the bot goods if the - * inventory is empty, fills the trade list and sits down. - * @param bot the headless player. - * @param now current time. - */ - /** - * @param bot the bot. - * @return farmed items the bot can put in a private store (no adena, nothing equipped). - */ - private List sellableLoot(Player bot) - { - final List result = new ArrayList<>(); - for (org.l2jmobius.gameserver.entity.item.instance.Item item : bot.getInventory().getItems()) - { - if ((item == null) || item.isEquipped() || (item.getId() == 57) || !item.isSellable() || !item.isTradeable()) - { - continue; - } - result.add(item); - } - return result; - } - - /** - * Picks up loot dropped by this bot's kills, so its store has real goods. - * @param bot the bot. - */ - private void pickupLoot(Player bot) - { - World.forEachVisibleObjectInRange(bot, org.l2jmobius.gameserver.entity.item.instance.Item.class, 250, item -> - { - if (!item.isSpawned() || (bot.getInventory().getSize() > 60)) - { - return; - } - final int owner = item.getOwnerId(); - if ((owner != 0) && (owner != bot.getObjectId())) - { - return; // Someone else's drop. - } - bot.doPickupItem(item); - }); - } - - private void openStore(Player bot, long now) - { try { - // Stop whatever the bot was doing, then travel to the market. + if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null)) + { + bot.leaveParty(); + } + // Stop whatever the bot was doing, then travel to the market of its level. bot.getAI().setIntentionActive(); bot.stopMove(null); - teleport(bot, TRADE_SPOT.getX() + Rnd.get(-250, 250), TRADE_SPOT.getY() + Rnd.get(-250, 250), TRADE_SPOT.getZ()); - // Reserve the slot right away so the trader cap is honoured. + Location market = GIRAN; + final Object[] town = FakePlayerProgressionManager.getInstance().pickTownSpot(bot.getLevel(), state.race); + if (town != null) + { + market = (Location) town[0]; + } + teleport(bot, market.getX() + Rnd.get(-250, 250), market.getY() + Rnd.get(-250, 250), market.getZ()); final long tradeFor = Rnd.get(1200000, 3600000); - _tradeUntil.put(bot.getObjectId(), now + tradeFor); - FakePlayerBotContext.setPlan(seedIdOf(bot), "trade", tradeFor); - FakePlayerBotContext.setEvent(seedIdOf(bot), "opened_store"); - ThreadPool.schedule(() -> finishStore(bot), 1200); - + state.tradeUntil = now + tradeFor; + FakePlayerBotContext.setPlan(state.seedId, "trade", tradeFor); + FakePlayerBotContext.setEvent(state.seedId, "opened_store"); + ThreadPool.schedule(() -> finishStore(bot), 1500); } catch (Exception e) { - _tradeUntil.remove(bot.getObjectId()); - _nextTradeAt.put(bot.getObjectId(), now + 600000); + state.tradeUntil = 0; + state.nextTradeAt = now + 600000; } } - + /** * Second half of opening a store - runs after the teleport actually landed. * @param bot the bot. @@ -1041,45 +1119,51 @@ public class FakePlayerHeadlessManager private void finishStore(Player bot) { final long now = System.currentTimeMillis(); + final FakePlayerBotState state = stateOf(bot); + if (state == null) + { + return; + } try { if (bot.isDead() || !bot.isSpawned()) { - _tradeUntil.remove(bot.getObjectId()); - _nextTradeAt.put(bot.getObjectId(), now + 300000); + state.tradeUntil = 0; + state.nextTradeAt = now + 300000; return; } - - // Sell what was actually farmed. - final List loot = sellableLoot(bot); + // In town: the junk goes to the merchant first, the good stuff to the store. + FakePlayerEconomy.getInstance().visitTown(bot, state); + final List loot = FakePlayerEconomy.getInstance().storeGoods(bot); if (loot.isEmpty()) { - _tradeUntil.remove(bot.getObjectId()); - _nextTradeAt.put(bot.getObjectId(), now + 900000); + state.tradeUntil = 0; + state.nextTradeAt = now + 900000; + returnToSpot(bot); return; } - + bot.getSellList().clear(); int listed = 0; - for (org.l2jmobius.gameserver.entity.item.instance.Item item : loot) + for (Item item : loot) { - if (listed >= 6) + if (listed >= 8) { break; } final int base = Math.max(1, item.getReferencePrice()); final int price = (int) Math.min(Integer.MAX_VALUE, base * (80 + Rnd.get(90)) / 100L); - bot.getSellList().addItem(item.getObjectId(), (int) Math.min(item.getCount(), 5000), price); + bot.getSellList().addItem(item.getObjectId(), Math.min(item.getCount(), 5000), price); listed++; } if (listed == 0) { - _tradeUntil.remove(bot.getObjectId()); - _nextTradeAt.put(bot.getObjectId(), now + 900000); + state.tradeUntil = 0; + state.nextTradeAt = now + 900000; + returnToSpot(bot); return; } - final BotState storeState = _states.get(bot.getObjectId()); - final String title = FakePlayerChatLines.getInstance().compose((storeState != null) ? storeState.seedId : 0, "store_title", (storeState != null) ? storeState.personality : "neutral", null); + final String title = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "store_title", null, FakePlayerEconomy.getInstance().storeSlots(loot)); final String caption = ((title != null) && !title.isEmpty()) ? title : STORE_MSGS[Rnd.get(STORE_MSGS.length)]; bot.getSellList().setTitle(caption); bot.setStoreName(caption); @@ -1087,14 +1171,43 @@ public class FakePlayerHeadlessManager bot.setPrivateStoreType(PrivateStoreType.SELL); bot.broadcastUserInfo(); // Without this the store caption stays invisible for everyone around. - bot.broadcastPacket(new org.l2jmobius.gameserver.network.serverpackets.PrivateStoreMsgSell(bot)); + bot.broadcastPacket(new PrivateStoreMsgSell(bot)); + state.storesOpened.incrementAndGet(); + state.action("store: " + caption); + // A trader shouts about it in the trade channel once in a while. + final String shout = FakePlayerUnderstanding.optionalLine(FakePlayerBotRef.ofHeadless(bot), "trade_shout", null, FakePlayerEconomy.getInstance().storeSlots(loot)); + if (shout != null) + { + ThreadPool.schedule(() -> trade(bot, shout, "trade_shout"), Rnd.get(10000, 40000)); + } } catch (Exception e) { - _nextTradeAt.put(bot.getObjectId(), now + 600000); + state.nextTradeAt = now + 600000; } } - + + /** + * Closes the private store of a bot if it has one (party, siege, migration). + * @param bot the bot. + */ + public void closeStore(Player bot) + { + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.tradeUntil = 0; + } + if (bot.isInStoreMode() || bot.isSitting()) + { + bot.setPrivateStoreType(PrivateStoreType.NONE); + bot.standUp(); + bot.broadcastUserInfo(); + } + } + + // ======================== Movement ======================== + /** * Teleports a headless bot and finishes the teleport by hand: a normal player * returns to the world when its client confirms the teleport, a headless one @@ -1120,12 +1233,34 @@ public class FakePlayerHeadlessManager } }, 500); } - - private void teleport(Player bot, Location location) + + public void teleport(Player bot, Location location) { teleport(bot, location.getX(), location.getY(), location.getZ()); } - + + /** + * Держит бота на цели, чтобы рутинный фарм не перебивал ганг. + * @param bot the bot. + * @param target the victim. + * @param seconds focus duration. + */ + public void forceTarget(Player bot, Player target, int seconds) + { + final FakePlayerBotState state = stateOf(bot); + if (state != null) + { + state.forcedTargetId = target.getObjectId(); + state.forcedUntil = System.currentTimeMillis() + (seconds * 1000L); + } + closeStore(bot); + bot.setTarget(target); + bot.setRunning(); + bot.getAI().setIntentionAttack(target); + } + + // ======================== Gear ======================== + /** * Выдаёт боту скиллы его класса и экипировку по уровню: сид-персонажи приходят * из базы голыми и без скиллов, поэтому раньше они дрались кулаками. @@ -1137,41 +1272,54 @@ public class FakePlayerHeadlessManager { bot.giveAvailableSkills(true, true, true); bot.sendSkillList(); - + // Снимаем всё, что не по уровню (например, после пересчёта уровня из опыта). - for (org.l2jmobius.gameserver.entity.item.instance.Item worn : bot.getInventory().getItems()) + for (Item worn : bot.getInventory().getItems()) { if (worn.isEquipped() && (worn.getTemplate().getCrystalType().getLevel() > gradeForLevel(bot.getLevel()))) { bot.getInventory().unEquipItemInSlot(worn.getLocationSlot()); } } - + final int tier = Math.min(7, FakePlayerProgressionManager.tierOf(bot.getLevel())); final boolean mage = bot.isMageClass(); final int classId = bot.getPlayerClass().getId(); final boolean archer = (classId == 9) || (classId == 24) || (classId == 37) || (classId == 92) || (classId == 102) || (classId == 109); final boolean dagger = (classId == 8) || (classId == 23) || (classId == 36) || (classId == 93) || (classId == 101) || (classId == 108); final boolean blunt = (classId == 15) || (classId == 16) || (classId == 30) || (classId == 97) || (classId == 105) || (classId == 116); - + final int weaponId = mage ? STAVES[tier] : archer ? BOWS[tier] : dagger ? DAGGERS[tier] : blunt ? BLUNTS[tier] : SWORDS[tier]; final int chestId = mage ? ROBE_CHEST[tier] : (archer || dagger) ? LIGHT_CHEST[tier] : HEAVY_CHEST[tier]; final boolean onepiece = mage ? ROBE_ONEPIECE[tier] : (archer || dagger) ? LIGHT_ONEPIECE[tier] : HEAVY_ONEPIECE[tier]; final int legsId = mage ? ROBE_LEGS[tier] : (archer || dagger) ? LIGHT_LEGS[tier] : HEAVY_LEGS[tier]; - + giveAndWear(bot, weaponId); giveAndWear(bot, chestId); if (!onepiece) { giveAndWear(bot, legsId); } - - // Шоты того же грейда, что оружие - иначе просто не сработают. + + // Шоты того же грейда, что оружие - иначе просто не сработают. Стартовый запас, дальше покупает сам. final int shotId = mage ? SPIRITSHOTS[tier] : SOULSHOTS[tier]; if (bot.getInventory().getItemByItemId(shotId) == null) { - bot.addItem(ItemProcessType.NONE, shotId, 5000, bot, false); + bot.addItem(ItemProcessType.NONE, shotId, 1500, bot, false); } + if (archer && (bot.getInventory().getItemByItemId(ARROWS[tier]) == null)) + { + final Item arrows = bot.addItem(ItemProcessType.NONE, ARROWS[tier], 2000, bot, false); + if ((arrows != null) && !arrows.isEquipped()) + { + bot.useEquippableItem(arrows, false); + } + } + bot.getAutoSoulShot().clear(); + bot.addAutoSoulShot(shotId); + // Jewelry of the grade and the usual town buffs: without them a real player of this level dies too. + FakePlayerEconomy.getInstance().wearJewelry(bot, gradeForLevel(bot.getLevel())); + FakePlayerEconomy.getInstance().buffUp(bot); bot.refreshExpertisePenalty(); bot.broadcastUserInfo(); } @@ -1180,7 +1328,7 @@ public class FakePlayerHeadlessManager LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not equip " + bot.getName(), e); } } - + /** * @param level character level. * @return highest crystal grade level the character may wear without penalty. @@ -1209,14 +1357,14 @@ public class FakePlayerHeadlessManager } return 0; // NG } - + private void giveAndWear(Player bot, int itemId) { if (itemId <= 0) { return; } - org.l2jmobius.gameserver.entity.item.instance.Item item = bot.getInventory().getItemByItemId(itemId); + Item item = bot.getInventory().getItemByItemId(itemId); if (item == null) { item = bot.addItem(ItemProcessType.NONE, itemId, 1, bot, false); @@ -1226,25 +1374,9 @@ public class FakePlayerHeadlessManager bot.useEquippableItem(item, false); } } - - /** - * Держит бота на цели, чтобы рутинный фарм не перебивал ганг. - * @param bot the bot. - * @param target the victim. - * @param seconds focus duration. - */ - private void forceTarget(Player bot, Player target, int seconds) - { - final BotState state = _states.get(bot.getObjectId()); - if (state != null) - { - state.forcedTargetId = target.getObjectId(); - state.forcedUntil = System.currentTimeMillis() + (seconds * 1000L); - } - bot.setTarget(target); - bot.getAI().setIntentionAttack(target); - } - + + // ======================== Debug ======================== + private Player findBot(String name) { for (Player bot : _bots) @@ -1256,7 +1388,7 @@ public class FakePlayerHeadlessManager } return null; } - + /** * Debug commands for the dashboard: force behaviors without waiting for timers. * @param command command name. @@ -1280,7 +1412,8 @@ public class FakePlayerHeadlessManager } } } - + final FakePlayerBotState state = stateOf(bot); + switch (command) { case "list": @@ -1293,20 +1426,35 @@ public class FakePlayerHeadlessManager { break; } - final BotState state = _states.get(player.getObjectId()); - sb.append(player.getName()).append(" lvl").append(player.getLevel()).append(' ').append((state != null) ? state.personality : "?").append(' ').append((state != null) ? state.zone : "").append(player.isDead() ? " [dead]" : "").append('\n'); + final FakePlayerBotState playerState = _states.get(player.getObjectId()); + sb.append(player.getName()).append(" lvl").append(player.getLevel()).append(' ').append(player.getPlayerClass()).append(' ').append((playerState != null) ? playerState.personality : "?").append(' ').append((playerState != null) ? playerState.zone : "").append(player.isDead() ? " [dead]" : (" hp" + (int) (player.getCurrentHp() * 100 / Math.max(1, player.getMaxHp())) + "%")).append(" k").append((playerState != null) ? playerState.kills.get() : 0).append(" d").append((playerState != null) ? playerState.deaths.get() : 0).append(" - ").append((playerState != null) ? playerState.lastAction : "").append('\n'); } return "headless: " + _bots.size() + "\n" + sb; } + case "stats": + { + if (!name.isEmpty() && (bot != null) && (state != null)) + { + return FakePlayerTelemetry.getInstance().statsText(bot, state); + } + return FakePlayerTelemetry.getInstance().statsText(); + } case "trade": { - if (bot == null) + if ((bot == null) || (state == null)) { return "bot not found"; } - _tradeUntil.remove(bot.getObjectId()); + state.tradeUntil = 0; + if (FakePlayerEconomy.getInstance().storeGoods(bot).isEmpty()) + { + // Debug convenience: give the bot something to sell. + bot.addItem(ItemProcessType.NONE, 1463, 300, bot, false); + bot.addItem(ItemProcessType.NONE, 1874, 12, bot, false); // Oriharukon Ore + bot.addItem(ItemProcessType.NONE, 1867, 40, bot, false); // Animal Skin + } openStore(bot, now); - return bot.getName() + ": лавка открыта в Гиране"; + return bot.getName() + ": лавка открыта (" + FakePlayerEconomy.getInstance().storeGoods(bot).size() + " позиций)"; } case "tradeall": { @@ -1314,15 +1462,18 @@ public class FakePlayerHeadlessManager final int limit = argument.isEmpty() ? 10 : Math.min(50, Integer.parseInt(argument)); for (Player player : _bots) { - if ((count >= limit) || player.isDead() || (player.getParty() != null)) + final FakePlayerBotState playerState = _states.get(player.getObjectId()); + if ((count >= limit) || player.isDead() || (player.getParty() != null) || (playerState == null) || (playerState.tradeUntil > 0)) { continue; } - if (!_tradeUntil.containsKey(player.getObjectId())) + if (FakePlayerEconomy.getInstance().storeGoods(player).isEmpty()) { - openStore(player, now); - count++; + player.addItem(ItemProcessType.NONE, 1463, 300, player, false); + player.addItem(ItemProcessType.NONE, 1874, 12, player, false); } + openStore(player, now); + count++; } return "лавок открыто: " + count; } @@ -1332,10 +1483,7 @@ public class FakePlayerHeadlessManager { return "bot not found"; } - _tradeUntil.remove(bot.getObjectId()); - bot.setPrivateStoreType(PrivateStoreType.NONE); - bot.standUp(); - bot.broadcastUserInfo(); + closeStore(bot); return bot.getName() + ": лавка закрыта"; } case "come": @@ -1366,9 +1514,23 @@ public class FakePlayerHeadlessManager { bot.joinParty(target.getParty()); } - say(bot, FakePlayerChatLines.getInstance().compose(0, "party_accept", "neutral", null), "party_accept"); + onJoinedParty(bot); + say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "party_accept", target, null), "party_accept"); return bot.getName() + " в пати с " + target.getName(); } + case "invite": + { + final Player target = World.getPlayer(argument); + if ((bot == null) || (target == null)) + { + return "usage: invite&arg="; + } + teleport(bot, target.getLocation()); + final Player inviter = bot; + ThreadPool.schedule(() -> FakePlayerSocial.getInstance().inviteToParty(inviter, target), 1500); + say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "invite_player", target, null), "invite_player"); + return bot.getName() + " зовёт " + target.getName() + " в пати (окно приглашения)"; + } case "gank": { final Player target = World.getPlayer(argument); @@ -1379,7 +1541,7 @@ public class FakePlayerHeadlessManager final Player ganker = bot; teleport(ganker, target.getX() + 150, target.getY() + 150, target.getZ()); ThreadPool.schedule(() -> forceTarget(ganker, target, 90), 1400); - say(bot, FakePlayerChatLines.getInstance().compose(0, "gank_start", "ganker", "target", target.getName()), "gank_start"); + say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "gank_start", target, Map.of("target", target.getName())), "gank_start"); return bot.getName() + " атакует " + target.getName(); } case "equip": @@ -1419,7 +1581,7 @@ public class FakePlayerHeadlessManager return "bot not found"; } final StringBuilder sb = new StringBuilder(bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + "\n"); - for (org.l2jmobius.gameserver.entity.item.instance.Item item : bot.getInventory().getItems()) + for (Item item : bot.getInventory().getItems()) { if (item.isEquipped()) { @@ -1427,6 +1589,7 @@ public class FakePlayerHeadlessManager } } sb.append(" штраф экспертизы: ").append(bot.getExpertisePenaltyBonus()); + sb.append("\n автошоты: ").append(bot.getAutoSoulShot()).append(", адена: ").append(bot.getAdena()); return sb.toString(); } case "levelup": @@ -1440,38 +1603,76 @@ public class FakePlayerHeadlessManager } case "migrate": { - if (bot == null) + if ((bot == null) || (state == null)) { return "bot not found"; } - final BotState state = _states.get(bot.getObjectId()); - final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), (state != null) ? state.race : ""); - if (spot == null) + migrate(bot, state, true); + return bot.getName() + " переезжает (прощание, город, новый спот)"; + } + case "town": + { + if ((bot == null) || (state == null)) { - return "no zone"; + return "bot not found"; } - teleport(bot, (Location) spot[0]); - if (state != null) + return bot.getName() + ": " + FakePlayerEconomy.getInstance().visitTown(bot, state); + } + case "profession": + { + if ((bot == null) || (state == null)) { - state.zone = (String) spot[1]; + return "bot not found"; } - say(bot, FakePlayerChatLines.getInstance().compose(0, "migrate", (state != null) ? state.personality : "neutral", null), "migrate"); - return bot.getName() + " переехал в " + ((state != null) ? state.zone : ""); + final Object result = FakePlayerEconomy.getInstance().checkProfession(bot, state); + return bot.getName() + ": " + ((result != null) ? ("новая профа " + result) : ("профа не положена (lvl " + bot.getLevel() + ", " + bot.getPlayerClass() + ")")); } case "say": { - if (bot == null) + if ((bot == null) || (state == null)) { return "bot not found"; } if (argument.isEmpty()) { - eventChat(bot, "Скажи что-нибудь бытовое про фарм.", "ambient", null, null); + FakePlayerSocial.getInstance().eventChat(bot, state, "Скажи что-нибудь бытовое про фарм.", "ambient", null, null); return bot.getName() + ": сказал реплику из шва ambient"; } say(bot, argument); return bot.getName() + ": " + argument; } + case "talk": + { + // Two bots talk to each other in front of you: talk&name=&arg= + if ((bot == null) || (state == null)) + { + return "bot not found"; + } + final Player target = World.getPlayer(argument); + Player peer = null; + for (Player candidate : _bots) + { + if ((candidate != bot) && !candidate.isDead() && (candidate.getParty() == null)) + { + peer = candidate; + break; + } + } + if (peer == null) + { + return "no second bot"; + } + if (target != null) + { + teleport(bot, target.getX() + 100, target.getY() + 100, target.getZ()); + teleport(peer, target.getX() - 100, target.getY() + 100, target.getZ()); + } + final Player speaker = bot; + final Player listener = peer; + final String opener = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(speaker), "bot_talk", listener, null); + ThreadPool.schedule(() -> FakePlayerSocial.getInstance().converse(speaker, listener, (opener != null) ? opener : ("как фарм, " + listener.getName()), "bot_talk", 1), 2000); + return speaker.getName() + " заговорит с " + listener.getName(); + } case "kill": { if (bot == null) @@ -1484,15 +1685,10 @@ public class FakePlayerHeadlessManager } case "rumor": { - if ((bot == null) || argument.isEmpty()) + if ((bot == null) || (state == null) || argument.isEmpty()) { return "usage: rumor&arg="; } - final BotState state = _states.get(bot.getObjectId()); - if (state == null) - { - return "no state"; - } FakePlayerRumorManager.getInstance().addWitnessed(state.seedId, "kill", argument, "кого-то"); return bot.getName() + " теперь помнит слух про " + argument; } @@ -1510,10 +1706,34 @@ public class FakePlayerHeadlessManager ClanTable.getInstance().storeClanWars(bot.getClanId(), target.getClanId()); return "война: " + bot.getClan().getName() + " против " + target.getClan().getName(); } + case "siege": + { + return FakePlayerSiegeManager.getInstance().forceSiege(argument.isEmpty() ? "Giran" : argument); + } + case "siegeend": + { + return FakePlayerSiegeManager.getInstance().endSiege(argument.isEmpty() ? "Giran" : argument); + } + case "siegeinfo": + { + return FakePlayerSiegeManager.getInstance().info(); + } + case "online": + { + try + { + final Player extra = ensureOnline(Integer.parseInt(argument)); + return (extra != null) ? (extra.getName() + " в мире") : "не удалось (уже онлайн или нет такого charId)"; + } + catch (NumberFormatException e) + { + return "usage: online&arg="; + } + } } return "unknown command"; } - + /** * @return JSON array of headless bots for the dashboard. */ @@ -1523,7 +1743,7 @@ public class FakePlayerHeadlessManager boolean first = true; for (Player bot : _bots) { - final BotState state = _states.get(bot.getObjectId()); + final FakePlayerBotState state = _states.get(bot.getObjectId()); if (!first) { sb.append(','); @@ -1534,7 +1754,11 @@ public class FakePlayerHeadlessManager { activity = "dead"; } - else if (_tradeUntil.containsKey(bot.getObjectId())) + else if ((state != null) && (state.siegeSide != 0)) + { + activity = "siege"; + } + else if ((state != null) && (state.tradeUntil > 0)) { activity = "trading"; } @@ -1546,6 +1770,10 @@ public class FakePlayerHeadlessManager { activity = "combat"; } + else if (bot.isSitting()) + { + activity = "resting"; + } else if (bot.isMoving()) { activity = "moving"; @@ -1559,25 +1787,22 @@ public class FakePlayerHeadlessManager .append("\",\"clanId\":").append(bot.getClanId()) .append(",\"zone\":\"").append((state != null) ? state.zone : "") .append("\",\"activity\":\"").append(activity) + .append("\",\"action\":\"").append((state != null) ? state.lastAction.replace('"', ' ') : "") .append("\",\"target\":\"").append(target.replace('"', ' ')) .append("\",\"hp\":").append((int) (bot.getCurrentHp() * 100 / Math.max(1, bot.getMaxHp()))) + .append(",\"kills\":").append((state != null) ? state.kills.get() : 0) .append(",\"headless\":true") .append(",\"x\":").append(bot.getX()).append(",\"y\":").append(bot.getY()).append(",\"z\":").append(bot.getZ()) .append('}'); } return sb.toString(); } - - public int size() - { - return _bots.size(); - } - + public static FakePlayerHeadlessManager getInstance() { return SingletonHolder.INSTANCE; } - + private static class SingletonHolder { protected static final FakePlayerHeadlessManager INSTANCE = new FakePlayerHeadlessManager(); diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java index 966876e..82af07e 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java @@ -48,13 +48,15 @@ public class FakePlayerHeardManager 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 long GLOBAL_THROTTLE = 2500; // At most one pickup per N ms server-wide. + private static final long LISTENER_COOLDOWN = 25000; // A bot picks something up at most this often. 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(); + private final Map _listenerCooldown = new java.util.concurrent.ConcurrentHashMap<>(); protected FakePlayerHeardManager() { @@ -101,7 +103,7 @@ public class FakePlayerHeardManager // 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); + 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) || "dialogue".equals(seam) || "bot_talk".equals(seam) || "reply".equals(seam) || "invite_player".equals(seam) || "party_accept".equals(seam) || seam.startsWith("siege_") || seam.startsWith("reply_")) : isCommercialLine(text); if (!levelup && commercial) { return; // Nobody "agrees" with an advertisement (or a pickup - depth 1). @@ -124,14 +126,14 @@ public class FakePlayerHeardManager final List listeners = new ArrayList<>(); for (Npc npc : World.getVisibleObjectsInRange(speaker, Npc.class, HEAR_RANGE)) { - if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead()) + if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead() && !onCooldown(npc.getObjectId(), now)) { listeners.add(npc); } } for (Player player : World.getVisibleObjectsInRange(speaker, Player.class, HEAR_RANGE)) { - if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead()) + if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead() && !player.isInStoreMode() && !onCooldown(player.getObjectId(), now)) { listeners.add(player); } @@ -163,8 +165,30 @@ public class FakePlayerHeardManager } final Creature listener = listeners.get(Rnd.get(listeners.size())); + _listenerCooldown.put(listener.getObjectId(), now); + if (_listenerCooldown.size() > 2000) + { + _listenerCooldown.entrySet().removeIf(entry -> (now - entry.getValue()) > LISTENER_COOLDOWN); + } final boolean question = isQuestionLine(text); - final Map slots = new HashMap<>(1); + final Map slots = new HashMap<>(2); + slots.put("msg", echoOf(text)); + slots.put("player", speaker.getName()); + scheduleReply(listener, question ? "overheard_q" : "overheard", slots); + } + + private boolean onCooldown(int objectId, long now) + { + final Long last = _listenerCooldown.get(objectId); + return (last != null) && ((now - last) < LISTENER_COOLDOWN); + } + + /** + * @param text a said line. + * @return its first words, the way a neighbour echoes a topic ("да, {msg}, точно"). + */ + public static String echoOf(String text) + { final String[] words = text.split("\\s+"); final StringBuilder echo = new StringBuilder(); for (int i = 0; (i < words.length) && (i < 3); i++) @@ -175,8 +199,7 @@ public class FakePlayerHeardManager } echo.append(words[i]); } - slots.put("msg", echo.toString()); - scheduleReply(listener, question ? "overheard_q" : "overheard", slots); + return echo.toString(); } private void scheduleReply(Creature listener, String seam, Map slots) diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java index 61c528f..5f608d0 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java @@ -173,21 +173,31 @@ public class FakePlayerIntentParser implements IXmlReader { continue; } + // Each group needs its own token: "слышь" alone must not satisfy both halves of "что нового". + final boolean[] used = new boolean[tokens.length]; boolean all = true; for (String[] group : intent.groups) { boolean any = false; - for (String token : tokens) + for (int index = 0; index < tokens.length; index++) { - if (token.isEmpty()) + final String token = tokens[index]; + if (token.isEmpty() || used[index]) { continue; } for (String stem : group) { - if (token.startsWith(stem) || ((stem.length() > 3) && fuzzyPrefix(token, stem))) + if (matches(token, stem)) { any = true; + used[index] = true; + // The same word appears twice (raw and phonetic): consume both copies. + final int twin = (index < rawTokens.length) ? (index + rawTokens.length) : (index - rawTokens.length); + if ((twin >= 0) && (twin < tokens.length) && tokens[twin].equals(token)) + { + used[twin] = true; + } break; } } @@ -210,6 +220,26 @@ public class FakePlayerIntentParser implements IXmlReader return null; } + /** + * @param token message token. + * @param stem lexicon stem; a leading "=" means exact prefix only (no typo tolerance). + * @return true when the token matches the stem. + */ + private static boolean matches(String token, String stem) + { + if (stem.startsWith("=")) + { + return token.startsWith(stem.substring(1)); + } + if (token.startsWith(stem)) + { + return true; + } + // Typo tolerance only for longer stems and for tokens that could be the same word: + // "молодая" is not a typo of "соло", "думаешь" is not "дурак". + return (stem.length() >= 5) && (token.length() <= (stem.length() + 3)) && fuzzyPrefix(token, stem); + } + /** * Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein * distance 1 of the stem ("помгите" matches stem "помо"). diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java index 6be2666..af95b4f 100644 --- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java @@ -1300,6 +1300,26 @@ public class FakePlayerProgressionManager implements IXmlReader }; } + /** + * Picks a town for a level and race (the town a character of that level would trade in). + * @param level character level. + * @param race race name. + * @return location and town name, or null. + */ + public Object[] pickTownSpot(int level, String race) + { + final ZoneInfo town = pickTown(level, race); + if (town == null) + { + return null; + } + return new Object[] + { + town.points.get(Rnd.get(town.points.size())), + town.name + }; + } + public static FakePlayerProgressionManager getInstance() { return SingletonHolder.INSTANCE; diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java new file mode 100644 index 0000000..04d7088 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java @@ -0,0 +1,732 @@ +/* + * 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.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +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.data.sql.ClanTable; +import org.l2jmobius.gameserver.entity.Location; +import org.l2jmobius.gameserver.entity.World; +import org.l2jmobius.gameserver.entity.WorldObject; +import org.l2jmobius.gameserver.entity.actor.Player; +import org.l2jmobius.gameserver.entity.actor.instance.Artefact; +import org.l2jmobius.gameserver.entity.actor.instance.Door; +import org.l2jmobius.gameserver.entity.clan.Clan; +import org.l2jmobius.gameserver.geoengine.GeoEngine; +import org.l2jmobius.gameserver.mechanics.siege.Castle; +import org.l2jmobius.gameserver.mechanics.siege.Siege; +import org.l2jmobius.gameserver.mechanics.siege.SiegeClan; +import org.l2jmobius.gameserver.mechanics.skill.CommonSkill; +import org.l2jmobius.gameserver.mechanics.skill.Skill; + +/** + * Castle sieges played by the bot clans with the real siege engine: + *

    + *
  • while registration is open, bot clans with enough high level members online register as attackers of + * castles owned by bot clans (and, when allowed, of castles owned by real players);
  • + *
  • when the siege starts, attackers gather outside the outer gate and defenders inside by the holy artifact;
  • + *
  • attackers fight defenders, break the gates, walk to the artifact and their clan leader engraves it with Seal + * of Ruler (3 minutes of casting the others have to cover); defenders hold the artifact and hunt attackers;
  • + *
  • battle cries at the start, at the gate, on engraving, and win / lose lines at the end; the castle really + * changes hands because the engine does the engraving, not us.
  • + *
+ * Everything runs on the real siege schedule (SiegeSchedule.xml); do=siege&arg=Giran forces one right now. + * @author Mobius SPP + */ +public class FakePlayerSiegeManager +{ + private static final Logger LOGGER = Logger.getLogger(FakePlayerSiegeManager.class.getName()); + + private static final long TICK = 10000; + private static final int MAX_PER_CLAN = 12; + private static final int MIN_LEVEL = 40; + private static final int MIN_MEMBERS = 3; + + private static class Battle + { + Siege siege; + Castle castle; + int firstOwnerId; + Location inner; // By the artifact. + Location staging; // Outside the outer gate. + final Set attackerClans = new HashSet<>(); + final Set defenderClans = new HashSet<>(); + final List participants = new ArrayList<>(); + final Map lastCry = new HashMap<>(); + boolean engraveAnnounced; + } + + private final Map _battles = new ConcurrentHashMap<>(); + private final Map _lastRegisterTry = new ConcurrentHashMap<>(); + + protected FakePlayerSiegeManager() + { + if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || !FakePlayersConfig.FAKE_PLAYER_SIEGES || (FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT <= 0)) + { + LOGGER.info(getClass().getSimpleName() + ": Disabled."); + return; + } + ThreadPool.scheduleAtFixedRate(this::tick, 60000, TICK); + LOGGER.info(getClass().getSimpleName() + ": Bot clans will register for castle sieges (" + FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACKER_CLANS + " attacker clans per castle)."); + } + + private void tick() + { + try + { + for (Siege siege : SiegeManager.getInstance().getSieges()) + { + final int castleId = siege.getCastle().getResidenceId(); + final Battle battle = _battles.get(castleId); + if (siege.isInProgress()) + { + if (battle == null) + { + onSiegeStarted(siege); + } + } + else + { + if (battle != null) + { + onSiegeEnded(battle); + } + else + { + maybeRegister(siege); + } + } + } + } + catch (Exception e) + { + LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": tick failed.", e); + } + } + + // ======================== Registration ======================== + + private static boolean isBotClan(int clanId) + { + return (clanId >= 300000001) && (clanId <= 300000999); + } + + /** + * Registers bot clans as attackers while registration is open. Retried every 10 minutes. + * @param siege the siege. + */ + private void maybeRegister(Siege siege) + { + final int castleId = siege.getCastle().getResidenceId(); + final long now = System.currentTimeMillis(); + final Long last = _lastRegisterTry.get(castleId); + if ((last != null) && ((now - last) < 600000)) + { + return; + } + _lastRegisterTry.put(castleId, now); + if (siege.isRegistrationOver()) + { + return; + } + final int ownerId = siege.getCastle().getOwnerId(); + if ((ownerId > 0) && !isBotClan(ownerId) && !FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES) + { + return; // A real clan's castle stays in peace unless allowed. + } + int registered = 0; + for (SiegeClan siegeClan : siege.getAttackerClans()) + { + if (isBotClan(siegeClan.getClanId())) + { + registered++; + } + } + final int wanted = FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACKER_CLANS - registered; + if (wanted <= 0) + { + return; + } + // Candidate clans: enough strong members online, no castle, not registered anywhere else that day. + final Map> byClan = membersByClan(); + final List candidates = new ArrayList<>(); + for (Map.Entry> entry : byClan.entrySet()) + { + final int clanId = entry.getKey(); + final Clan clan = ClanTable.getInstance().getClan(clanId); + if ((clan == null) || (clanId == ownerId) || (clan.getCastleId() > 0) || (entry.getValue().size() < MIN_MEMBERS)) + { + continue; + } + if ((ownerId > 0) && (clan.getAllyId() != 0)) + { + final Clan owner = ClanTable.getInstance().getClan(ownerId); + if ((owner != null) && (owner.getAllyId() == clan.getAllyId())) + { + continue; // Allies do not attack each other. + } + } + if (SiegeManager.getInstance().checkIsRegistered(clan, castleId) || siege.checkIfAlreadyRegisteredForSameDay(clan)) + { + continue; + } + candidates.add(clanId); + } + if (candidates.isEmpty()) + { + return; + } + // Deterministic per castle and siege date, so the same clans keep coming back to "their" castle. + final long seed = castleId * 31L + (siege.getSiegeDate().getTimeInMillis() / 86400000L); + candidates.sort((a, b) -> Integer.compare(FakePlayerIdiolect.hash32(seed + ":" + a), FakePlayerIdiolect.hash32(seed + ":" + b))); + int done = 0; + for (int clanId : candidates) + { + if (done >= wanted) + { + break; + } + final Player member = byClan.get(clanId).get(0); + siege.registerAttacker(member, true); + if (SiegeManager.getInstance().checkIsRegistered(member.getClan(), castleId)) + { + done++; + final Clan clan = ClanTable.getInstance().getClan(clanId); + FakePlayerHeadlessManager.getInstance().ensureOnline(clan.getLeaderId()); + LOGGER.info(getClass().getSimpleName() + ": " + clan.getName() + " registered to attack " + siege.getCastle().getName() + " (siege " + siege.getSiegeDate().getTime() + ")."); + } + } + if ((done > 0) && (ownerId > 0) && isBotClan(ownerId)) + { + // The defending lord should be there too. + final Clan owner = ClanTable.getInstance().getClan(ownerId); + if (owner != null) + { + FakePlayerHeadlessManager.getInstance().ensureOnline(owner.getLeaderId()); + } + } + } + + private Map> membersByClan() + { + final Map> byClan = new HashMap<>(); + for (Player bot : FakePlayerHeadlessManager.getInstance().bots()) + { + if ((bot.getClanId() > 0) && isBotClan(bot.getClanId()) && (bot.getLevel() >= MIN_LEVEL) && !bot.isDead()) + { + byClan.computeIfAbsent(bot.getClanId(), k -> new ArrayList<>()).add(bot); + } + } + return byClan; + } + + // ======================== Battle ======================== + + private void onSiegeStarted(Siege siege) + { + final Castle castle = siege.getCastle(); + final Battle battle = new Battle(); + battle.siege = siege; + battle.castle = castle; + battle.firstOwnerId = castle.getOwnerId(); + for (SiegeClan siegeClan : siege.getAttackerClans()) + { + if (isBotClan(siegeClan.getClanId())) + { + battle.attackerClans.add(siegeClan.getClanId()); + } + } + for (SiegeClan siegeClan : siege.getDefenderClans()) + { + if (isBotClan(siegeClan.getClanId())) + { + battle.defenderClans.add(siegeClan.getClanId()); + } + } + if (isBotClan(castle.getOwnerId())) + { + battle.defenderClans.add(castle.getOwnerId()); + } + if (battle.attackerClans.isEmpty() && battle.defenderClans.isEmpty()) + { + return; // Not our siege. + } + if (!computeLocations(battle)) + { + LOGGER.warning(getClass().getSimpleName() + ": " + castle.getName() + " has no artifact or gates loaded, bots skip this siege."); + return; + } + _battles.put(castle.getResidenceId(), battle); + + // Participants: online headless members of the registered clans. + final Map perClan = new HashMap<>(); + for (Player bot : FakePlayerHeadlessManager.getInstance().bots()) + { + final int clanId = bot.getClanId(); + final boolean attacker = battle.attackerClans.contains(clanId); + final boolean defender = battle.defenderClans.contains(clanId); + if ((!attacker && !defender) || (bot.getLevel() < (MIN_LEVEL - 10))) + { + continue; + } + final int count = perClan.getOrDefault(clanId, 0); + if ((count >= MAX_PER_CLAN) && !bot.isClanLeader()) + { + continue; + } + perClan.put(clanId, count + 1); + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + if (state == null) + { + continue; + } + state.siegeSide = attacker ? (byte) 1 : (byte) 2; + state.siegeCastleId = castle.getResidenceId(); + state.siegeNextThink = 0; + state.siegesJoined.incrementAndGet(); + state.action((attacker ? "siege attack " : "siege defend ") + castle.getName()); + bot.setSiegeState(state.siegeSide); + FakePlayerBotContext.setPlan(state.seedId, "siege", 3 * 3600000L); + FakePlayerBotContext.setSlot(state.seedId, "castle", castle.getName()); + battle.participants.add(bot); + // Leave the farm party, close the shop, go to the castle in small waves. + if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null)) + { + bot.leaveParty(); + } + FakePlayerHeadlessManager.getInstance().closeStore(bot); + final Location where = attacker ? battle.staging : battle.inner; + final int delay = 2000 + (battle.participants.size() * 700); + ThreadPool.schedule(() -> + { + if (!bot.isDead()) + { + FakePlayerHeadlessManager.getInstance().teleport(bot, where.getX() + Rnd.get(-150, 150), where.getY() + Rnd.get(-150, 150), where.getZ()); + } + }, delay); + } + // One battle cry per clan. + final Set cried = new HashSet<>(); + for (Player bot : battle.participants) + { + if (cried.add(bot.getClanId())) + { + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + final String seam = (state.siegeSide == 1) ? "siege_start" : "siege_defend"; + ThreadPool.schedule(() -> cry(bot, seam), 6000 + Rnd.get(4000)); + } + } + LOGGER.info(getClass().getSimpleName() + ": Siege of " + castle.getName() + " - " + battle.participants.size() + " bots joined (attacker clans " + battle.attackerClans + ", defender clans " + battle.defenderClans + ")."); + } + + /** + * @param battle the battle. + * @return true when the castle has enough geometry (artifact / gates) to play on. + */ + private boolean computeLocations(Battle battle) + { + final Castle castle = battle.castle; + Location artefact = null; + for (Artefact art : castle.getArtefacts()) + { + artefact = art.getLocation(); + break; + } + Door outer = null; + double best = -1; + for (Door door : castle.getDoors()) + { + if (!door.isShowHp()) + { + continue; + } + final double distance = (artefact != null) ? door.calculateDistance2D(artefact) : 0; + if (distance > best) + { + best = distance; + outer = door; + } + } + if ((artefact == null) && (outer == null)) + { + return false; + } + battle.inner = (artefact != null) ? new Location(artefact.getX(), artefact.getY(), artefact.getZ()) : outer.getLocation(); + if ((outer != null) && (artefact != null)) + { + final double dx = outer.getX() - artefact.getX(); + final double dy = outer.getY() - artefact.getY(); + final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy))); + final int x = outer.getX() + (int) ((dx / length) * 900); + final int y = outer.getY() + (int) ((dy / length) * 900); + int z = outer.getZ(); + try + { + z = GeoEngine.getInstance().getHeight(x, y, z); + } + catch (Exception e) + { + // Keep the door height. + } + battle.staging = new Location(x, y, z); + } + else + { + battle.staging = battle.inner; + } + return true; + } + + /** + * Called every headless tick for a bot taking part in a siege. + * @param bot the bot. + * @param state its state. + * @param now current time. + * @return true when the siege logic took the tick. + */ + public boolean handle(Player bot, FakePlayerBotState state, long now) + { + final Battle battle = _battles.get(state.siegeCastleId); + if ((battle == null) || !battle.siege.isInProgress()) + { + release(bot, state, battle); + return false; + } + if (now < state.siegeNextThink) + { + return bot.isInCombat() || bot.isAttackingNow() || bot.isCastingNow(); + } + state.siegeNextThink = now + 3000 + Rnd.get(2000); + if (bot.isCastingNow()) + { + return true; // Engraving. + } + final boolean attacker = state.siegeSide == 1; + final Location post = attacker ? battle.staging : battle.inner; + if (bot.calculateDistance2D(post) > 4000) + { + // Came back from the village after dying: rejoin the battle. + FakePlayerHeadlessManager.getInstance().teleport(bot, post.getX() + Rnd.get(-150, 150), post.getY() + Rnd.get(-150, 150), post.getZ()); + return true; + } + + // Enemies first: players of the other side inside / around the castle. + final byte enemySide = attacker ? (byte) 2 : (byte) 1; + final int ownerId = battle.castle.getOwnerId(); + final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, attacker ? 900 : 1200, other -> (other != bot) && !other.isDead() && !other.isInvisible() && ((other.getSiegeState() == enemySide) || (attacker && (other.getClanId() == ownerId) && (ownerId > 0)))); + if (enemy != null) + { + if ((bot.getTarget() != enemy) || !bot.isAttackingNow()) + { + bot.setTarget(enemy); + bot.getAI().setIntentionAttack(enemy); + } + state.action("siege fight " + enemy.getName()); + return true; + } + + if (attacker) + { + // The leader engraves once the artifact is reachable. + Artefact artefact = null; + for (Artefact art : battle.castle.getArtefacts()) + { + if ((artefact == null) || (bot.calculateDistance2D(art) < bot.calculateDistance2D(artefact))) + { + artefact = art; + } + } + if ((artefact != null) && bot.isClanLeader() && (bot.calculateDistance2D(artefact) < 150) && (Math.abs(bot.getZ() - artefact.getZ()) < 45)) + { + final Skill seal = bot.getKnownSkill(CommonSkill.SEAL_OF_RULER.getId()); + if (seal != null) + { + bot.setTarget(artefact); + if (bot.useMagic(seal, false, false)) + { + state.action("siege engrave"); + if (!battle.engraveAnnounced) + { + battle.engraveAnnounced = true; + cry(bot, "siege_engrave"); + } + return true; + } + } + } + // Break the nearest closed gate on the way in. + Door gate = null; + for (Door door : battle.castle.getDoors()) + { + if (door.isOpen() || door.isDead() || !door.isAutoAttackable(bot)) + { + continue; + } + if ((gate == null) || (bot.calculateDistance2D(door) < bot.calculateDistance2D(gate))) + { + gate = door; + } + } + if ((gate != null) && (bot.calculateDistance2D(gate) < 1200)) + { + if ((bot.getTarget() != gate) || !bot.isAttackingNow()) + { + bot.setTarget(gate); + bot.getAI().setIntentionAttack(gate); + } + state.action("siege gate"); + final Long last = battle.lastCry.get(bot.getClanId()); + if ((last == null) || ((now - last) > 90000)) + { + battle.lastCry.put(bot.getClanId(), now); + cry(bot, "siege_gate"); + } + return true; + } + // Otherwise walk towards the artifact (the leader) or escort the leader / hold the yard. + final Location goal = (artefact != null) ? artefact.getLocation() : battle.inner; + if (bot.calculateDistance2D(goal) > 250) + { + bot.setRunning(); + bot.getAI().setIntentionMoveTo(new Location(goal.getX() + Rnd.get(-120, 120), goal.getY() + Rnd.get(-120, 120), goal.getZ())); + state.action("siege advance"); + } + return true; + } + + // Defender: hold the artifact room. + if (bot.calculateDistance2D(battle.inner) > 350) + { + bot.setRunning(); + bot.getAI().setIntentionMoveTo(new Location(battle.inner.getX() + Rnd.get(-150, 150), battle.inner.getY() + Rnd.get(-150, 150), battle.inner.getZ())); + state.action("siege hold"); + } + return true; + } + + private void onSiegeEnded(Battle battle) + { + _battles.remove(battle.castle.getResidenceId()); + final int ownerNow = battle.castle.getOwnerId(); + final Set cried = new HashSet<>(); + for (Player bot : battle.participants) + { + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + if (state == null) + { + continue; + } + final boolean won = (bot.getClanId() == ownerNow) || ((state.siegeSide == 2) && (ownerNow == battle.firstOwnerId) && (bot.getClanId() == battle.firstOwnerId)); + if (cried.add(bot.getClanId())) + { + ThreadPool.schedule(() -> cry(bot, won ? "siege_win" : "siege_lose"), 3000 + Rnd.get(5000)); + } + release(bot, state, battle); + } + LOGGER.info(getClass().getSimpleName() + ": Siege of " + battle.castle.getName() + " finished, owner now " + ownerNow + (ownerNow != battle.firstOwnerId ? " (castle changed hands!)" : "") + "."); + } + + private void release(Player bot, FakePlayerBotState state, Battle battle) + { + if (state.siegeSide == 0) + { + return; + } + state.siegeSide = 0; + state.siegeCastleId = 0; + FakePlayerBotContext.setPlan(state.seedId, "farm", 0); + FakePlayerBotContext.setEvent(state.seedId, "siege_finished"); + state.action("siege over"); + // Back to the farming spot after a while (the engine already sends everybody to town). + ThreadPool.schedule(() -> + { + if (!bot.isDead() && (FakePlayerHeadlessManager.getInstance().stateOf(bot) != null) && (FakePlayerHeadlessManager.getInstance().stateOf(bot).siegeSide == 0)) + { + FakePlayerHeadlessManager.getInstance().returnToSpot(bot); + } + }, 60000 + Rnd.get(120000)); + } + + private void cry(Player bot, String seam) + { + try + { + if (bot.isDead()) + { + return; + } + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + final Map slots = new HashMap<>(2); + slots.put("castle", (state != null) && (state.siegeCastleId > 0) && (CastleManager.getInstance().getCastleById(state.siegeCastleId) != null) ? CastleManager.getInstance().getCastleById(state.siegeCastleId).getName() : "замок"); + final Clan clan = bot.getClan(); + if (clan != null) + { + slots.put("clan", clan.getName()); + } + final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), seam, null, slots); + if (line != null) + { + FakePlayerHeadlessManager.getInstance().shout(bot, line, seam); + } + } + catch (Exception e) + { + // Silence is fine. + } + } + + /** + * @param state a bot state. + * @return true while the bot is fighting a siege. + */ + public boolean isBusy(FakePlayerBotState state) + { + return state.siegeSide != 0; + } + + // ======================== Debug ======================== + + /** + * Forces a siege right now: registers bot clans and starts the engine's siege. + * @param castleName castle name or id. + * @return result text. + */ + public String forceSiege(String castleName) + { + final Castle castle = findCastle(castleName); + if (castle == null) + { + return "castle not found: " + castleName; + } + final Siege siege = castle.getSiege(); + if (siege.isInProgress()) + { + return castle.getName() + ": siege already in progress"; + } + _lastRegisterTry.remove(castle.getResidenceId()); + final boolean allowPlayers = FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES; + FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = true; + try + { + maybeRegister(siege); + } + finally + { + FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = allowPlayers; + } + int botAttackers = 0; + for (SiegeClan siegeClan : siege.getAttackerClans()) + { + if (isBotClan(siegeClan.getClanId())) + { + botAttackers++; + } + } + if (botAttackers == 0) + { + return castle.getName() + ": no bot clan could register (need " + MIN_MEMBERS + " online members of level " + MIN_LEVEL + "+ in a clan without a castle)"; + } + siege.startSiege(); + ThreadPool.schedule(this::tick, 3000); + return castle.getName() + ": siege started, bot attacker clans: " + botAttackers + " (owner clan " + castle.getOwnerId() + "). Ends in " + SiegeManager.getInstance().getSiegeLength() + " min or do=siegeend"; + } + + /** + * Ends a forced siege. + * @param castleName castle name or id. + * @return result text. + */ + public String endSiege(String castleName) + { + final Castle castle = findCastle(castleName); + if (castle == null) + { + return "castle not found: " + castleName; + } + if (!castle.getSiege().isInProgress()) + { + return castle.getName() + ": no siege in progress"; + } + castle.getSiege().endSiege(); + ThreadPool.schedule(this::tick, 2000); + return castle.getName() + ": siege ended, owner " + castle.getOwnerId(); + } + + /** + * @return status of all sieges and bot registrations. + */ + public String info() + { + final StringBuilder sb = new StringBuilder(512); + for (Siege siege : SiegeManager.getInstance().getSieges()) + { + final Castle castle = siege.getCastle(); + sb.append(castle.getResidenceId()).append(' ').append(castle.getName()).append(": owner ").append(castle.getOwnerId()); + sb.append(siege.isInProgress() ? " IN PROGRESS" : (" next " + siege.getSiegeDate().getTime())); + sb.append(", attackers "); + for (SiegeClan siegeClan : siege.getAttackerClans()) + { + final Clan clan = ClanTable.getInstance().getClan(siegeClan.getClanId()); + sb.append((clan != null) ? clan.getName() : siegeClan.getClanId()).append(' '); + } + final Battle battle = _battles.get(castle.getResidenceId()); + if (battle != null) + { + sb.append("| bots in battle: ").append(battle.participants.size()); + } + sb.append('\n'); + } + return sb.toString(); + } + + private static Castle findCastle(String name) + { + for (Castle castle : CastleManager.getInstance().getCastles()) + { + if (castle.getName().equalsIgnoreCase(name) || String.valueOf(castle.getResidenceId()).equals(name)) + { + return castle; + } + } + return null; + } + + public static FakePlayerSiegeManager getInstance() + { + return SingletonHolder.INSTANCE; + } + + private static class SingletonHolder + { + protected static final FakePlayerSiegeManager INSTANCE = new FakePlayerSiegeManager(); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java new file mode 100644 index 0000000..cd5bd4c --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java @@ -0,0 +1,751 @@ +/* + * 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.ConcurrentHashMap; + +import org.l2jmobius.commons.threads.ThreadPool; +import org.l2jmobius.commons.util.Rnd; +import org.l2jmobius.gameserver.config.custom.FakePlayersConfig; +import org.l2jmobius.gameserver.data.xml.MapRegionData; +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.clan.Clan; +import org.l2jmobius.gameserver.entity.groups.Party; +import org.l2jmobius.gameserver.entity.groups.PartyDistributionType; +import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding; +import org.l2jmobius.gameserver.network.enums.ChatType; +import org.l2jmobius.gameserver.network.serverpackets.AskJoinParty; +import org.l2jmobius.gameserver.network.serverpackets.CreatureSay; + +/** + * The social life of headless bots: everything that is perception and talk + * rather than combat. Runs every tick BEFORE the combat brain, so a bot keeps + * noticing players, clan enemies and neighbours while it farms - real players + * type while fighting too. + *
    + *
  • personality triggers: gankers hunt players, pk hunters hunt flagged/red ones, helpers assist, clan wars;
  • + *
  • calls for help when losing a fight, clanmates answer;
  • + *
  • ambient talk near real players (memory, rumors, invitations, party search);
  • + *
  • bot to bot talk: greetings when meeting, small talk openers, gossip - every reply goes through the same + * understanding pipeline players get, so bots answer each other's questions;
  • + *
  • real parties between clanmates, party invitations to players;
  • + *
  • grounding slots for every line the bot says: zone, level, town, mob, crowd, last drop.
  • + *
+ * @author Mobius SPP + */ +public class FakePlayerSocial +{ + private static final java.util.logging.Logger LOGGER = java.util.logging.Logger.getLogger(FakePlayerSocial.class.getName()); + private static final int NOTICE_RANGE = 1000; + private static final int AMBIENT_RANGE = 700; + private static final int WITNESS_RANGE = 1600; // A real player who can see bots talking to each other. + private static final int PEER_RANGE = 600; + private static final long AMBIENT_COOLDOWN = 90000; + private static final long TAUNT_COOLDOWN = 20000; + private static final long MEET_MEMORY = 30 * 60000L; + private static final int MAX_DIALOGUE_DEPTH = 3; + + /** Pending party invitations from bots to players: bot objectId - player objectId + deadline. */ + private final Map _pendingInvites = new ConcurrentHashMap<>(); + + protected FakePlayerSocial() + { + } + + /** + * Perception and talk for one bot. Cheap: every branch is cooldown gated. + * @param bot the bot. + * @param state its state. + * @param now current time. + * @return true when the bot started an exclusive action (fight, assist) and the tick should end. + */ + public boolean tick(Player bot, FakePlayerBotState state, long now) + { + if (now > state.nextSlotRefresh) + { + state.nextSlotRefresh = now + 30000; + refreshSlots(bot, state); + } + + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot); + final Player human = humanPartyAnchor(bot); + + // Losing a PvP fight: shout for help, clanmates nearby join in. + final WorldObject target = bot.getTarget(); + if (bot.isInCombat() && (target instanceof Player attacker) && (attacker != bot) && !attacker.isDead() && (attacker.getTarget() == bot) && (bot.getCurrentHp() < (bot.getMaxHp() * 0.6)) && ((now - state.lastHelpCall) > 45000)) + { + state.lastHelpCall = now; + final String cry = FakePlayerUnderstanding.eventLine(ref, "call_help", attacker, null); + if (cry != null) + { + FakePlayerHeadlessManager.getInstance().shout(bot, cry, "call_help"); + } + final int clanId = bot.getClanId(); + if (clanId > 0) + { + World.forEachVisibleObjectInRange(bot, Player.class, 1500, ally -> + { + if (ally.isHeadlessBot() && (ally != bot) && !ally.isDead() && !ally.isInCombat() && (ally.getClanId() == clanId)) + { + FakePlayerHeadlessManager.getInstance().forceTarget(ally, attacker, 40); + } + }); + } + } + + // Credit players helping this bot with its monster. + if (bot.isInCombat() && (target instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000)) + { + final Player helper = World.getNearestVisibleObjectInRange(bot, Player.class, 900, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat() && (other.getTarget() == monsterTarget)); + if (helper != null) + { + state.lastHelpCredit = now; + FakePlayerMemoryManager.getInstance().onHelpedBy(state.seedId, helper.getName()); + } + } + + // Personality actions against real players (not while escorting a human party). + if ((human == null) && (state.forcedTargetId == 0) && !FakePlayerSiegeManager.getInstance().isBusy(state)) + { + if (FakePlayersConfig.FAKE_PLAYER_AGGRO_PLAYERS) + { + Player victim = null; + String seam = null; + String instruction = null; + if ("ganker".equals(state.personality)) + { + victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible() && !other.isGM() && (Math.abs(other.getLevel() - bot.getLevel()) <= 12)); + seam = "gank_start"; + instruction = "Ты нападаешь на игрока. Крикни что-нибудь дерзкое."; + } + else if ("pkk".equals(state.personality)) + { + victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isInvisible() && (other != bot) && ((other.getKarma() > 0) || ((other.getPvpFlag() != 0) && !other.isHeadlessBot()))); + seam = "pkk_start"; + instruction = "Ты охотник на ПК и нападаешь на нарушителя. Крикни про правосудие."; + } + if ((victim != null) && (victim.getObjectId() != state.lastPvpTargetId)) + { + state.lastPvpTargetId = victim.getObjectId(); + state.lastChat = now; + state.action("pvp:" + victim.getName()); + FakePlayerHeadlessManager.getInstance().forceTarget(bot, victim, 60); + if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE) + { + eventChat(bot, state, instruction, seam, Map.of("target", victim.getName()), victim); + } + return true; + } + } + + // Clan war: attack players and bots of clans at war with ours. + final Clan clan = bot.getClan(); + if ((clan != null) && !bot.isInCombat() && (Rnd.get(100) < 40)) + { + final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> !other.isDead() && (other != bot) && !other.isInvisible() && (other.getClan() != null) && (clan.isAtWarWith(other.getClanId()) || other.getClan().isAtWarWith(clan.getId()))); + if (enemy != null) + { + state.action("clanwar:" + enemy.getName()); + FakePlayerHeadlessManager.getInstance().forceTarget(bot, enemy, 45); + if (((now - state.lastChat) > TAUNT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)) + { + state.lastChat = now; + final Map slots = new HashMap<>(2); + slots.put("target", enemy.getName()); + slots.put("enemyclan", enemy.getClan().getName()); + eventChat(bot, state, "Клан игрока " + enemy.getName() + " воюет с твоим кланом, ты его атакуешь. Крикни боевую фразу.", "war_start", slots, enemy); + } + return true; + } + } + + // Helpers assist real players fighting monsters. + if ("helper".equals(state.personality) && !bot.isInCombat()) + { + final Player ally = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat()); + if (ally != null) + { + final WorldObject allyTarget = ally.getTarget(); + if ((allyTarget instanceof Monster monster) && !monster.isDead() && (bot.calculateDistance2D(monster) < 1500)) + { + bot.setTarget(monster); + bot.getAI().setIntentionAttack(monster); + state.action("assist:" + ally.getName()); + if ((now - state.lastChat) > TAUNT_COOLDOWN) + { + state.lastChat = now; + if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE) + { + eventChat(bot, state, "Ты помогаешь игроку " + ally.getName() + " убить монстра. Скажи что-то дружелюбное.", "assist", Map.of("player", ally.getName()), ally); + } + } + return true; + } + } + } + } + + // Companion mode with a real player (they asked for help / party): escort is handled by the party + // logic when a real party exists; a plain escort ends after its time. + if ((state.companionPlayerId != 0) && (now > state.companionUntil)) + { + state.companionPlayerId = 0; + if (bot.getParty() == null) + { + eventChat(bot, state, "Ты пофармил с игроком в паре и уходишь по своим делам. Попрощайся.", "companion_done", null, null); + } + } + + // Squads: headless clanmates form REAL parties with each other. + if ((bot.getParty() == null) && (now > state.partyCooldown) && (bot.getClanId() > 0) && (Rnd.get(100) < 40)) + { + state.partyCooldown = now + 180000; + final Player mate = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && (other.getClanId() == bot.getClanId()) && (Math.abs(other.getLevel() - bot.getLevel()) <= 10) && ((other.getParty() == null) || ((other.getParty().getMemberCount() < 6) && (humanPartyAnchor(other) == null)))); + if (mate != null) + { + final Party squad; + if (mate.getParty() != null) + { + squad = mate.getParty(); + bot.joinParty(squad); + } + else + { + squad = new Party(bot, bot.getPartyDistributionType()); + bot.setParty(squad); + mate.joinParty(squad); + } + state.partiesJoined.incrementAndGet(); + final FakePlayerBotState mateState = FakePlayerHeadlessManager.getInstance().stateOf(mate); + if (mateState != null) + { + mateState.partiesJoined.incrementAndGet(); + } + state.action("squad:" + mate.getName()); + final String invite = FakePlayerUnderstanding.eventLine(ref, "invite_player", mate, null); + if (invite != null) + { + converse(bot, mate, invite, "invite_player", 1); + } + } + } + // Everything below is talk - only worth it when a real player can see it. + final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible()); + if (witness == null) + { + return false; + } + + // Ambient talk when a real player is close (memory and rumors aware). + if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE)) + { + final Player nearby = World.getNearestVisibleObjectInRange(bot, Player.class, AMBIENT_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible()); + if (nearby != null) + { + state.lastChat = now; + ambientTalk(bot, state, ref, nearby, now); + return false; + } + } + + // Bot to bot: greet a neighbour met for the first time in a while. + if (FakePlayersConfig.FAKE_PLAYER_BOT_DIALOGUES && (now > state.nextBotTalk)) + { + final Player peer = World.getNearestVisibleObjectInRange(bot, Player.class, PEER_RANGE, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && !other.isInStoreMode()); + if (peer != null) + { + final Long lastMet = state.met.get(peer.getObjectId()); + if ((lastMet == null) || ((now - lastMet) > MEET_MEMORY)) + { + state.met.put(peer.getObjectId(), now); + if (state.met.size() > 64) + { + state.met.entrySet().removeIf(entry -> (now - entry.getValue()) > MEET_MEMORY); + } + state.nextBotTalk = now + Rnd.get(40000, 120000); + if (Rnd.get(100) < 45) + { + final String hello = FakePlayerUnderstanding.eventLine(ref, "greeting", peer, null); + if (hello != null) + { + state.action("greet:" + peer.getName()); + converse(bot, peer, hello, "greeting", 1); + } + } + } + else if (Rnd.get(100) < 35) + { + // Small talk opener addressed to the neighbour; it answers through the understanding pipeline. + state.nextBotTalk = now + Rnd.get(120000, 300000); + final String opener = FakePlayerUnderstanding.optionalLine(ref, "bot_talk", peer, null); + if (opener != null) + { + state.action("talk:" + peer.getName()); + converse(bot, peer, opener, "bot_talk", 1); + } + } + else + { + state.nextBotTalk = now + Rnd.get(30000, 90000); + } + } + } + + // Gossip: stories travel between bots when they meet, players nearby overhear. + if ((now > state.nextGossip) && !bot.isInCombat()) + { + state.nextGossip = now + Rnd.get(180000, 420000); + final Player listener = World.getNearestVisibleObjectInRange(bot, Player.class, 400, other -> other.isHeadlessBot() && (other != bot) && !other.isDead()); + if (listener != null) + { + final FakePlayerRumorManager.Story passed = FakePlayerRumorManager.getInstance().gossip(state.seedId, FakePlayerHeadlessManager.getInstance().seedIdOf(listener)); + if (passed != null) + { + final String story = FakePlayerRumorManager.getInstance().tell(passed).replace(" ты ", " " + passed.subject + " "); + FakePlayerHeadlessManager.getInstance().say(bot, "слышал? " + story, "gossip"); + state.action("gossip"); + } + } + } + + // Party search in the trade channel now and then (real level and zone). + if ((now > state.nextLfp) && (bot.getParty() == null) && !bot.isInCombat()) + { + state.nextLfp = now + Rnd.get(600000, 1800000); + if (Rnd.get(100) < 35) + { + final String lfp = FakePlayerUnderstanding.optionalLine(ref, "lfp", null, null); + if (lfp != null) + { + FakePlayerHeadlessManager.getInstance().trade(bot, lfp, "lfp"); + state.action("lfp"); + } + } + } + + return false; + } + + private void ambientTalk(Player bot, FakePlayerBotState state, FakePlayerBotRef ref, Player nearby, long now) + { + final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(state.seedId, nearby.getName()); + final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(state.seedId, nearby.getName()); + if ((memory != null) && (memory.score <= -2)) + { + eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебя убивал. Скажи что-то злопамятное.", "revenge_meet", Map.of("player", nearby.getName()), nearby); + } + else if ((memory != null) && (memory.score >= 2)) + { + eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебе помогал. Поприветствуй тепло.", "friendly_meet", Map.of("player", nearby.getName()), nearby); + } + else if ((rumor != null) && (Rnd.get(100) < 50)) + { + FakePlayerHeadlessManager.getInstance().say(bot, FakePlayerRumorManager.getInstance().tell(rumor), "rumor"); + } + else if ((("helper".equals(state.personality)) || ("neutral".equals(state.personality))) && (bot.getParty() == null) && (nearby.getParty() == null) && (Rnd.get(100) < 25)) + { + // Invite the player to team up; a "yes" within a minute makes it a real party. + _pendingInvites.put(bot.getObjectId(), new long[] + { + nearby.getObjectId(), + now + 60000 + }); + eventChat(bot, state, "Рядом игрок " + nearby.getName() + ". Позови его в пати.", "invite_player", Map.of("player", nearby.getName()), nearby); + } + else if ((memory == null) && (Rnd.get(100) < 20)) + { + eventChat(bot, state, "Рядом незнакомый игрок " + nearby.getName() + ". Поздоровайся.", "greeting", Map.of("player", nearby.getName()), nearby); + } + else + { + eventChat(bot, state, "Рядом игрок. Скажи что-нибудь бытовое про фарм, дроп или спот.", "ambient", null, null); + } + } + + /** + * Refreshes the grounding slots every line of this bot can use. + * @param bot the bot. + * @param state its state. + */ + private void refreshSlots(Player bot, FakePlayerBotState state) + { + final int botId = state.seedId; + if (botId <= 0) + { + return; + } + FakePlayerBotContext.setSlot(botId, "zone", state.zone.isEmpty() ? "поле" : state.zone); + FakePlayerBotContext.setSlot(botId, "level", String.valueOf(bot.getLevel())); + try + { + FakePlayerBotContext.setSlot(botId, "town", MapRegionData.getInstance().getClosestTownName(bot)); + } + catch (Exception e) + { + // No region data - keep the old value. + } + final WorldObject target = bot.getTarget(); + FakePlayerBotContext.setSlot(botId, "mob", (target instanceof Monster monster) ? monster.getName() : null); + int crowd = 0; + for (Player other : World.getVisibleObjectsInRange(bot, Player.class, 1000)) + { + if ((other != bot) && !other.isDead()) + { + crowd++; + } + } + FakePlayerBotContext.setSlot(botId, "crowd", String.valueOf(crowd)); + FakePlayerBotContext.setSlot(botId, "drop", state.lastDrop); + final Clan clan = bot.getClan(); + if ((clan != null) && !clan.getWarList().isEmpty()) + { + final Clan enemy = org.l2jmobius.gameserver.data.sql.ClanTable.getInstance().getClan(clan.getWarList().iterator().next()); + FakePlayerBotContext.setSlot(botId, "enemyclan", (enemy != null) ? enemy.getName() : null); + } + } + + // ======================== Talking ======================== + + /** + * Event comment: seam line in the bot's voice; the LLM only when it has priority. + * @param bot the bot. + * @param state its state. + * @param instruction LLM instruction. + * @param seam seam key for the template line. + * @param slots extra slots. + * @param about player the event is about (memory context), may be null. + */ + public void eventChat(Player bot, FakePlayerBotState state, String instruction, String seam, Map slots, Player about) + { + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot); + final String line = FakePlayerUnderstanding.eventLine(ref, seam, about, slots); + if (FakePlayerLlmService.getInstance().isEnabled() && "llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY)) + { + final String persona = FakePlayerHeadlessManager.getInstance().persona(bot, (about != null) ? about.getName() : null); + FakePlayerLlmService.getInstance().generate(persona, instruction, reply -> FakePlayerHeadlessManager.getInstance().say(bot, reply, seam), () -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam)); + return; + } + if (line != null) + { + // A human needs a moment to type. + ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam), Rnd.get(800, 2500)); + } + } + + /** + * A bot says something to another bot; the addressee answers through the + * understanding pipeline (intent, memory, stance, idiolect), and the two + * may exchange a couple more lines. Depth limited, never loops. + * @param speaker the speaking bot. + * @param listener the addressed bot. + * @param text the line. + * @param seam the seam of the line. + * @param depth dialogue depth of this line (1 = opener). + */ + public void converse(Player speaker, Player listener, String text, String seam, int depth) + { + FakePlayerHeadlessManager.getInstance().say(speaker, text, (depth == 1) ? seam : "dialogue"); + if ((depth >= MAX_DIALOGUE_DEPTH) || (listener == null) || listener.isDead()) + { + return; + } + ThreadPool.schedule(() -> + { + try + { + if (listener.isDead() || speaker.isDead() || (listener.calculateDistance2D(speaker) > 1250)) + { + return; + } + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(listener); + final Understanding understanding = FakePlayerUnderstanding.understand(ref, speaker, text, false); + String reply = ((understanding != null) && !understanding.silent) ? understanding.responseLine : null; + if ((reply == null) && (depth == 1)) + { + // No intent recognized: react to the topic like an overheard line. + final Map slots = new HashMap<>(2); + slots.put("msg", FakePlayerHeardManager.echoOf(text)); + slots.put("player", speaker.getName()); + reply = FakePlayerUnderstanding.optionalLine(ref, FakePlayerHeardManager.isQuestionLine(text) ? "overheard_q" : "overheard", speaker, slots); + } + if (reply == null) + { + return; + } + final FakePlayerBotState listenerState = FakePlayerHeadlessManager.getInstance().stateOf(listener); + if (listenerState != null) + { + listenerState.repliesGiven.incrementAndGet(); + listenerState.action("reply:" + speaker.getName()); + } + // The opener's author answers back only sometimes, and the chain always ends. + if ((depth + 1 < MAX_DIALOGUE_DEPTH) && (Rnd.get(100) < 40)) + { + converse(listener, speaker, reply, "dialogue", depth + 1); + } + else + { + FakePlayerHeadlessManager.getInstance().say(listener, reply, "dialogue"); + } + } + catch (Exception e) + { + // A failed reply is just silence. + } + }, Rnd.get(2500, 7000)); + } + + /** + * A real player said something in general chat: nearby bots may answer. + * A bot addressed by name always answers. + * @param player the speaker. + * @param text the message. + */ + public void onPlayerSaid(Player player, String text) + { + final List listeners = new ArrayList<>(); + World.forEachVisibleObjectInRange(player, Player.class, 1250, other -> + { + if (other.isHeadlessBot() && !other.isDead()) + { + listeners.add(other); + } + }); + if (listeners.isEmpty()) + { + return; + } + listeners.sort((a, b) -> Double.compare(a.calculateDistance2D(player), b.calculateDistance2D(player))); + final String lower = text.toLowerCase(); + int answered = 0; + for (Player listener : listeners) + { + final boolean addressed = lower.contains(listener.getName().toLowerCase()) || ((listener.getName().length() > 5) && lower.contains(listener.getName().substring(0, 5).toLowerCase())); + if (addressed || ((answered < 2) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE))) + { + answered++; + FakePlayerHeadlessManager.getInstance().onLocalChat(player, listener, text); + if (addressed) + { + break; + } + } + } + } + + // ======================== Parties ======================== + + /** + * Real party invitation from a bot to a player (the client shows the normal window). + * @param bot the bot. + * @param player the player. + */ + public void inviteToParty(Player bot, Player player) + { + try + { + if (bot.isDead() || player.isDead()) + { + return; + } + final Party botParty = bot.getParty(); + final Party playerParty = player.getParty(); + if ((playerParty != null) && (botParty == null) && (playerParty.getMemberCount() < 9)) + { + bot.joinParty(playerParty); + FakePlayerHeadlessManager.getInstance().onJoinedParty(bot); + return; + } + if ((playerParty != null) || player.isProcessingRequest() || player.isProcessingTransaction()) + { + return; + } + if ((botParty != null) && (botParty.getMemberCount() >= 9)) + { + return; + } + player.setActiveRequester(bot); + bot.onTransactionRequest(player); + bot.setPartyDistributionType(PartyDistributionType.FINDERS_KEEPERS); + player.sendPacket(new AskJoinParty(bot.getName(), PartyDistributionType.FINDERS_KEEPERS)); + if (botParty != null) + { + botParty.setPendingInvitation(true); + } + _pendingInvites.remove(bot.getObjectId()); + } + catch (Exception e) + { + // The invitation simply does not happen. + } + } + + /** + * @param bot the bot. + * @param player the player. + * @return true when the bot invited the player recently and the invite is still valid. + */ + public boolean hasPendingInvite(Player bot, Player player) + { + final long[] pending = _pendingInvites.get(bot.getObjectId()); + return (pending != null) && (pending[0] == player.getObjectId()) && (System.currentTimeMillis() < pending[1]); + } + + /** + * @param bot the bot. + * @return the real player the bot's party is built around (any human member), or null. + */ + public static Player humanPartyAnchor(Player bot) + { + final Party party = bot.getParty(); + if (party == null) + { + return null; + } + final Player leader = party.getLeader(); + if ((leader != null) && (leader != bot) && !leader.isHeadlessBot()) + { + return leader; + } + for (Player member : party.getMembers()) + { + if ((member != null) && (member != bot) && !member.isHeadlessBot()) + { + return member; + } + } + return null; + } + + // ======================== Events ======================== + + /** + * Reaction to a real level up (event fact, clan brag or public joy - neighbours congratulate). + * @param bot the bot. + * @param state its state. + */ + public void onLevelUp(Player bot, FakePlayerBotState state) + { + FakePlayerBotContext.setEvent(state.seedId, "levelup"); + FakePlayerBotContext.setSlot(state.seedId, "level", String.valueOf(bot.getLevel())); + final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot); + final Map slots = Map.of("level", String.valueOf(bot.getLevel())); + if ((bot.getClan() != null) && (Rnd.get(100) < 40)) + { + final String clanLine = FakePlayerUnderstanding.eventLine(ref, "levelup", null, slots); + if (clanLine != null) + { + bot.getClan().broadcastToOnlineMembers(new CreatureSay(bot, ChatType.CLAN, bot.getName(), clanLine)); + state.linesSaid.incrementAndGet(); + } + return; + } + final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot()); + if (witness != null) + { + eventChat(bot, state, "Ты только что взял " + bot.getLevel() + " уровень. Порадуйся одной фразой.", "levelup", slots, null); + } + } + + /** + * Reaction to death: killed by a player - memory, rumor seed and a death cry; + * killed by a monster - a complaint now and then. + * @param bot the dead bot. + * @param state its state. + * @param killer the killer. + */ + public void onDied(Player bot, FakePlayerBotState state, Creature killer) + { + state.deaths.incrementAndGet(); + state.action("dead"); + if (FakePlayersConfig.FAKE_PLAYER_CHAT_LOG) + { + LOGGER.info("death: " + bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + " in " + state.zone + " killed by " + ((killer != null) ? (killer.getName() + " lvl" + killer.getLevel()) : "?") + " at " + bot.getX() + "," + bot.getY() + "," + bot.getZ()); + } + final Player killerPlayer = (killer != null) ? killer.asPlayer() : null; + if (killerPlayer != null) + { + if (!killerPlayer.isHeadlessBot()) + { + FakePlayerMemoryManager.getInstance().onKilledBy(state.seedId, killerPlayer.getName()); + } + else + { + final FakePlayerBotState killerState = FakePlayerHeadlessManager.getInstance().stateOf(killerPlayer); + if (killerState != null) + { + killerState.pvpKills.incrementAndGet(); + } + } + FakePlayerBotContext.setEvent(state.seedId, "ganked"); + final String deathCry = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "killed_by", killerPlayer, null); + if (deathCry != null) + { + // Dead players still type - the corpse lies there for a while. + ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, deathCry, "killed_by"), Rnd.get(1500, 4000)); + } + // Nearby bots witness the kill (rumor seed). + final String victimName = bot.getName(); + World.forEachVisibleObjectInRange(bot, Player.class, 900, witness -> + { + if (witness.isHeadlessBot() && (witness != bot)) + { + final FakePlayerBotState witnessState = FakePlayerHeadlessManager.getInstance().stateOf(witness); + if (witnessState != null) + { + FakePlayerRumorManager.getInstance().addWitnessed(witnessState.seedId, (killerPlayer.getKarma() > 0) ? "pk" : "kill", killerPlayer.getName(), victimName); + } + } + }); + return; + } + if ((killer != null) && (Rnd.get(100) < 50)) + { + final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot()); + if (witness != null) + { + final String complaint = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "died_mob", null, Map.of("mob", killer.getName())); + if (complaint != null) + { + ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, complaint, "died_mob"), Rnd.get(2000, 5000)); + } + } + } + } + + public static FakePlayerSocial getInstance() + { + return SingletonHolder.INSTANCE; + } + + private static class SingletonHolder + { + protected static final FakePlayerSocial INSTANCE = new FakePlayerSocial(); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java new file mode 100644 index 0000000..e5a57fb --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java @@ -0,0 +1,252 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; +import java.util.logging.Logger; + +import org.l2jmobius.commons.threads.ThreadPool; +import org.l2jmobius.gameserver.config.custom.FakePlayersConfig; +import org.l2jmobius.gameserver.entity.actor.Player; + +/** + * Proof of life for the bot population: every counter a bot increments while + * it farms, loots, trades, talks and fights is summed here, printed to the + * server log every few minutes and served by the dashboard (/api/stats, + * do=stats). If the numbers do not move, the bots are pretending. + * @author Mobius SPP + */ +public class FakePlayerTelemetry +{ + private static final Logger LOGGER = Logger.getLogger(FakePlayerTelemetry.class.getName()); + + private final long _startedAt = System.currentTimeMillis(); + private Map _lastTotals = new LinkedHashMap<>(); + + protected FakePlayerTelemetry() + { + if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || (FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES <= 0)) + { + return; + } + final long interval = FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES * 60000L; + ThreadPool.scheduleAtFixedRate(this::logSummary, interval, interval); + } + + /** + * @return population totals in a fixed order. + */ + public Map totals() + { + final Map totals = new LinkedHashMap<>(); + long kills = 0; + long exp = 0; + long deaths = 0; + long pvp = 0; + long loot = 0; + long sold = 0; + long earned = 0; + long spent = 0; + long stores = 0; + long lines = 0; + long replies = 0; + long casts = 0; + long rests = 0; + long sieges = 0; + long migrations = 0; + long professions = 0; + long parties = 0; + int alive = 0; + int fighting = 0; + int trading = 0; + int inParty = 0; + int sitting = 0; + int besieging = 0; + int levels = 0; + for (Player bot : FakePlayerHeadlessManager.getInstance().bots()) + { + final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot); + if (state == null) + { + continue; + } + kills += state.kills.get(); + exp += state.expGained.get(); + deaths += state.deaths.get(); + pvp += state.pvpKills.get(); + loot += state.lootPicked.get(); + sold += state.itemsSold.get(); + earned += state.adenaEarned.get(); + spent += state.adenaSpent.get(); + stores += state.storesOpened.get(); + lines += state.linesSaid.get(); + replies += state.repliesGiven.get(); + casts += state.skillsCast.get(); + rests += state.rests.get(); + sieges += state.siegesJoined.get(); + migrations += state.migrations.get(); + professions += state.professions.get(); + parties += state.partiesJoined.get(); + levels += bot.getLevel(); + if (!bot.isDead()) + { + alive++; + } + if (bot.isInCombat()) + { + fighting++; + } + if (bot.isInStoreMode()) + { + trading++; + } + if (bot.getParty() != null) + { + inParty++; + } + if (bot.isSitting()) + { + sitting++; + } + if (state.siegeSide != 0) + { + besieging++; + } + } + final int count = FakePlayerHeadlessManager.getInstance().size(); + totals.put("bots", (long) count); + totals.put("alive", (long) alive); + totals.put("fighting", (long) fighting); + totals.put("sitting", (long) sitting); + totals.put("trading", (long) trading); + totals.put("inParty", (long) inParty); + totals.put("besieging", (long) besieging); + totals.put("avgLevel", (count > 0) ? (levels / count) : 0L); + totals.put("kills", kills); + totals.put("exp", exp); + totals.put("pvpKills", pvp); + totals.put("deaths", deaths); + totals.put("loot", loot); + totals.put("itemsSold", sold); + totals.put("adenaEarned", earned); + totals.put("adenaSpent", spent); + totals.put("stores", stores); + totals.put("skillsCast", casts); + totals.put("rests", rests); + totals.put("lines", lines); + totals.put("replies", replies); + totals.put("parties", parties); + totals.put("migrations", migrations); + totals.put("professions", professions); + totals.put("sieges", sieges); + totals.put("uptimeMin", (System.currentTimeMillis() - _startedAt) / 60000); + return totals; + } + + private void logSummary() + { + try + { + final Map totals = totals(); + final StringBuilder sb = new StringBuilder(256); + sb.append(getClass().getSimpleName()).append(": last ").append(FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES).append(" min ->"); + for (Map.Entry entry : totals.entrySet()) + { + final String key = entry.getKey(); + if (key.equals("bots") || key.equals("alive") || key.equals("fighting") || key.equals("sitting") || key.equals("trading") || key.equals("inParty") || key.equals("besieging") || key.equals("avgLevel") || key.equals("uptimeMin")) + { + continue; + } + final long delta = entry.getValue() - _lastTotals.getOrDefault(key, 0L); + sb.append(' ').append(key).append("=+").append(delta); + } + sb.append(" | now: alive ").append(totals.get("alive")).append('/').append(totals.get("bots")).append(", fighting ").append(totals.get("fighting")).append(", sitting ").append(totals.get("sitting")).append(", trading ").append(totals.get("trading")).append(", in party ").append(totals.get("inParty")).append(", besieging ").append(totals.get("besieging")).append(", avg lvl ").append(totals.get("avgLevel")); + LOGGER.info(sb.toString()); + _lastTotals = totals; + } + catch (Exception e) + { + // Logging must never break anything. + } + } + + /** + * @return totals as JSON for the dashboard. + */ + public String statsJson() + { + final StringBuilder sb = new StringBuilder(512); + sb.append('{'); + boolean first = true; + for (Map.Entry entry : totals().entrySet()) + { + if (!first) + { + sb.append(','); + } + first = false; + sb.append('"').append(entry.getKey()).append("\":").append(entry.getValue()); + } + return sb.append('}').toString(); + } + + /** + * @return human readable totals (do=stats). + */ + public String statsText() + { + final StringBuilder sb = new StringBuilder(512); + for (Map.Entry entry : totals().entrySet()) + { + sb.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + } + return sb.toString(); + } + + /** + * @param bot a bot. + * @param state its state. + * @return one bot's counters (do=stats&name=). + */ + public String statsText(Player bot, FakePlayerBotState state) + { + final StringBuilder sb = new StringBuilder(256); + sb.append(bot.getName()).append(" lvl").append(bot.getLevel()).append(' ').append(bot.getPlayerClass()).append(" [").append(state.personality).append("] zone=").append(state.zone).append(" plan=").append(FakePlayerBotContext.planOf(state.seedId)).append('\n'); + sb.append(" last action: ").append(state.lastAction).append(" (").append((System.currentTimeMillis() - state.lastActionAt) / 1000).append("s ago)\n"); + sb.append(" kills=").append(state.kills.get()).append(" exp=").append(state.expGained.get()).append(" pvp=").append(state.pvpKills.get()).append(" deaths=").append(state.deaths.get()).append('\n'); + sb.append(" loot=").append(state.lootPicked.get()).append(" sold=").append(state.itemsSold.get()).append(" earned=").append(state.adenaEarned.get()).append(" spent=").append(state.adenaSpent.get()).append(" adena=").append(bot.getAdena()).append(" stores=").append(state.storesOpened.get()).append('\n'); + sb.append(" casts=").append(state.skillsCast.get()).append(" rests=").append(state.rests.get()).append(" lines=").append(state.linesSaid.get()).append(" replies=").append(state.repliesGiven.get()).append(" parties=").append(state.partiesJoined.get()).append('\n'); + sb.append(" migrations=").append(state.migrations.get()).append(" professions=").append(state.professions.get()).append(" sieges=").append(state.siegesJoined.get()).append(" siegeSide=").append(state.siegeSide).append('\n'); + sb.append(" shots=").append(bot.getAutoSoulShot()).append(" inventory=").append(bot.getInventory().getSize()).append(" hp=").append((int) bot.getCurrentHp()).append('/').append(bot.getMaxHp()); + return sb.toString(); + } + + public static FakePlayerTelemetry getInstance() + { + return SingletonHolder.INSTANCE; + } + + private static class SingletonHolder + { + protected static final FakePlayerTelemetry INSTANCE = new FakePlayerTelemetry(); + } +} diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java new file mode 100644 index 0000000..81f4fa8 --- /dev/null +++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java @@ -0,0 +1,411 @@ +/* + * 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.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.l2jmobius.commons.util.Rnd; +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.clan.Clan; +import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding; + +/** + * The one place where a bot understands what was said to it. Shared by NPC + * fake players and headless players through {@link FakePlayerBotRef}, so both + * get the whole pipeline: intent lexicon with typo tolerance, memory facts + * (asker = stranger / killed_me / helped_me / clanmate / enemy_war), stance + * memory (a decision on duel / party / help / insults holds for 15 minutes), + * silence towards a stranger's first message, reactions to caps lock, style + * mirroring, the bot's own context facts (plan, event) and grounding slots + * (zone, level, mob, town, crowd). + * @author Mobius SPP + */ +public class FakePlayerUnderstanding +{ + private static final long STANCE_TTL = 15 * 60 * 1000; + private static final Set STANCE_INTENTS = Set.of("duel", "ask_party", "ask_help", "insult"); + + 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 STANCES = new ConcurrentHashMap<>(); + private static final Map FIRST_CONTACT = new ConcurrentHashMap<>(); + + private FakePlayerUnderstanding() + { + } + + /** + * Memory based relation of the speaker to the bot. + * @param bot the bot. + * @param speaker the speaking player (real or another bot). + * @return asker fact value. + */ + public static String askerOf(FakePlayerBotRef bot, Player speaker) + { + final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(bot.botId, speaker.getName()); + if ((memory != null) && (memory.kills > 0)) + { + return "killed_me"; + } + if ((memory != null) && (memory.helps > 0)) + { + return "helped_me"; + } + final int clanId = bot.getClanId(); + if ((clanId > 0) && (speaker.getClanId() == clanId)) + { + return "clanmate"; + } + final Clan botClan = bot.getClan(); + if ((botClan != null) && (speaker.getClan() != null) && (botClan.isAtWarWith(speaker.getClanId()) || speaker.getClan().isAtWarWith(botClan.getId()))) + { + return "enemy_war"; + } + return "stranger"; + } + + /** + * Base facts of a bot for the seam lottery (personality + relation to the speaker). + * @param bot the bot. + * @param speaker the speaker, may be null. + * @return facts map. + */ + public static Map facts(FakePlayerBotRef bot, Player speaker) + { + final Map facts = new HashMap<>(3); + facts.put("personality", bot.personality); + if (speaker != null) + { + facts.put("asker", askerOf(bot, speaker)); + } + return facts; + } + + /** + * Grounding slots that are always available for a bot line. + * @param bot the bot. + * @param speaker the speaker, may be null. + * @return slots map. + */ + public static Map slots(FakePlayerBotRef bot, Player speaker) + { + final Map slots = new HashMap<>(6); + if (speaker != null) + { + slots.put("player", speaker.getName()); + } + final String zone = bot.getZone(); + slots.put("zone", ((zone == null) || zone.isEmpty()) ? "поле" : zone); + slots.put("level", String.valueOf(bot.getLevel())); + final Clan clan = bot.getClan(); + if (clan != null) + { + slots.put("clan", clan.getName()); + } + if ((clan != null) && (speaker != null) && (speaker.getClan() != null) && (clan.isAtWarWith(speaker.getClanId()) || speaker.getClan().isAtWarWith(clan.getId()))) + { + slots.put("enemyclan", speaker.getClan().getName()); + } + return slots; + } + + /** + * Understands an incoming message: matches an intent, applies world effects + * (memory, aggression, running to help, teaming up) and prepares a template + * response in the bot's own voice. + * @param bot the addressed bot. + * @param speaker the speaking player (a real one or another headless bot). + * @param message the raw message. + * @param privateChannel true for whispers (no global template cooldown). + * @return understanding or null when no intent matched. + */ + public static Understanding understand(FakePlayerBotRef bot, Player speaker, String message, boolean privateChannel) + { + final int botId = bot.botId; + final String personality = bot.personality; + final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId); + final FakePlayerIdiolect.Mirror mirror = FakePlayerIdiolect.Mirror.of(message); + final Map facts = facts(bot, speaker); + final String asker = facts.get("asker"); + final Map slots = slots(bot, speaker); + final boolean speakerIsBot = speaker.isHeadlessBot(); + + final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message); + final long now = System.currentTimeMillis(); + final String contactKey = botId + "|" + speaker.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) && !speakerIsBot && ((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 = line(botId, "reply_caps", facts, slots, mirror, privateChannel); + 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 = FakePlayerChatManager.intentHint(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 againSlots = new HashMap<>(slots); + againSlots.put("prev", stance.text); + final String again = line(botId, "reply_again", facts, againSlots, mirror, privateChannel); + result.responseLine = (again != null) ? again : stance.text; + return result; + } + } + + switch (intent.key) + { + case "insult": + { + FakePlayerMemoryManager.getInstance().adjustScore(botId, speaker.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"; + } + final boolean fights = "ganker".equals(personality) || ("pkk".equals(personality) && bot.isHeadless() && (Rnd.get(100) < 50)); + if (fights && !speakerIsBot && (bot.distanceTo(speaker) < 2000)) + { + bot.attack(speaker, 45); + } + 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, speaker.getName(), 1); + break; + } + case "ask_party": + { + final boolean willing = !"ganker".equals(personality) && !"killed_me".equals(asker) && !bot.isInCombat() && (bot.distanceTo(speaker) < 2500); + if (willing) + { + if (!speakerIsBot) + { + bot.teamUp(speaker); + } + seam = "party_accept"; + } + break; + } + case "yes_ok": + { + if (!speakerIsBot && bot.hasPendingInvite(speaker)) + { + bot.teamUp(speaker); + seam = "party_accept"; + } + break; + } + case "ask_help": + { + final boolean willing = (!"ganker".equals(personality) || "helped_me".equals(asker) || "clanmate".equals(asker)) && !"killed_me".equals(asker) && !"enemy_war".equals(asker) && !bot.isInCombat() && (bot.distanceTo(speaker) < 3000); + seam = willing ? "reply_help_yes" : "reply_help_no"; + if (willing && !speakerIsBot) + { + final WorldObject playerTarget = speaker.getTarget(); + if ((playerTarget instanceof Monster monster) && !monster.isDead()) + { + bot.assist(monster); + } + else + { + bot.moveTo(speaker); + } + } + break; + } + case "ask_rumors": + { + final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(botId); + if (story != null) + { + result.responseLine = "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story); + } + break; + } + case "duel": + { + if ("enemy_war".equals(asker) || "killed_me".equals(asker)) + { + seam = "reply_insult_aggro"; + } + break; + } + } + + if ((result.responseLine == null) && !seam.isEmpty()) + { + result.responseLine = line(botId, seam, facts, slots, mirror, privateChannel); + } + + // 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 static String line(int botId, String seam, Map facts, Map slots, FakePlayerIdiolect.Mirror mirror, boolean privateChannel) + { + if (privateChannel) + { + return FakePlayerChatLines.getInstance().speakPrivate(botId, seam, facts, slots, mirror); + } + return FakePlayerChatLines.getInstance().speak(botId, seam, facts, slots, false, mirror); + } + + /** + * Composes an event line for a bot with its full facts (personality, relation to the player about). + * @param bot the bot. + * @param seam seam key. + * @param about the player the event is about (may be null). + * @param extraSlots extra slots (may be null). + * @return the line or null. + */ + public static String eventLine(FakePlayerBotRef bot, String seam, Player about, Map extraSlots) + { + final Map slots = slots(bot, about); + if (extraSlots != null) + { + slots.putAll(extraSlots); + } + return FakePlayerChatLines.getInstance().speak(bot.botId, seam, facts(bot, about), slots, false, null); + } + + /** + * Optional background line (silence beats repetition). + * @param bot the bot. + * @param seam seam key. + * @param about the player nearby (may be null). + * @param extraSlots extra slots (may be null). + * @return the line or null. + */ + public static String optionalLine(FakePlayerBotRef bot, String seam, Player about, Map extraSlots) + { + final Map slots = slots(bot, about); + if (extraSlots != null) + { + slots.putAll(extraSlots); + } + return FakePlayerChatLines.getInstance().speak(bot.botId, seam, facts(bot, about), slots, true, null); + } + + /** + * @param creature a creature. + * @return true when it is a bot of either kind. + */ + public static boolean isBot(Creature creature) + { + return FakePlayerBotRef.of(creature) != null; + } +} diff --git a/tools/build.sh b/tools/build.sh new file mode 100644 index 0000000..754586d --- /dev/null +++ b/tools/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Builds GameServer.jar / LoginServer.jar from src_mobius with ant (JDK 25). +# Usage: tools/build.sh -> build/dist/libs/GameServer.jar +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude" +ant -q jar +ls -la "$ROOT/src_mobius/mobius/build/dist/libs/" diff --git a/tools/check_data.py b/tools/check_data.py new file mode 100644 index 0000000..0d2897b --- /dev/null +++ b/tools/check_data.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Static checks of the bot data files (run from anywhere, no server needed). + +- FakePlayerChatLines.xml / FakePlayerPools.xml: every #pool# exists, no cycles, + every {slot} is one the code can fill, morph brackets are balanced; +- every seam the Java code or the intent lexicon refers to exists in the XML; +- FakePlayerCombat.xml: every skill id is learnable by that class (or one of its + parent classes) according to the datapack skill trees, and every class id + used by the seeded characters has a profile. + +Usage: python tools/check_data.py [--skilltrees PATH] [--seed PATH] +Exit code 1 on errors. +""" +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude' +DATA = SRC / 'dist' / 'game' / 'data' +JAVA = SRC / 'java' / 'org' / 'l2jmobius' / 'gameserver' + +KNOWN_SLOTS = {'player', 'target', 'level', 'boss', 'zone', 'town', 'msg', 'prev', 'enemyclan', 'mob', 'castle', + 'clan', 'class', 'drop', 'item', 'price', 'crowd', 'daytime'} +# Seams the code calls by a literal name (kept in sync with the harness list). +CODE_SEAMS = { + 'greeting', 'whisper_default', 'party_accept', 'reply_insult_soft', 'reply_insult_aggro', 'reply_caps', + 'reply_again', 'revenge_meet', 'friendly_meet', 'gank_start', 'pkk_start', 'war_start', 'assist', + 'companion_done', 'invite_player', 'ambient', 'lfp', 'levelup', 'grats', 'overheard', 'overheard_q', + 'killed_by', 'died_mob', 'migrate', 'profession', 'store_title', 'trade_shout', 'call_help', 'raid_start', + 'bot_talk', 'siege_start', 'siege_defend', 'siege_gate', 'siege_engrave', 'siege_win', 'siege_lose', + 'reply_help_yes', 'reply_help_no', 'loot_brag', +} +# Class transfer chains (Interlude): child -> parent. +PARENT = {1: 0, 2: 1, 3: 1, 4: 0, 5: 4, 6: 4, 7: 0, 8: 7, 9: 7, 11: 10, 12: 11, 13: 11, 14: 11, 15: 10, 16: 15, + 17: 15, 19: 18, 20: 19, 21: 19, 22: 18, 23: 22, 24: 22, 26: 25, 27: 26, 28: 26, 29: 25, 30: 29, 32: 31, + 33: 32, 34: 32, 35: 31, 36: 35, 37: 35, 39: 38, 40: 39, 41: 39, 42: 38, 43: 42, 45: 44, 46: 45, 47: 44, + 48: 47, 50: 49, 51: 50, 52: 50, 54: 53, 55: 54, 56: 53, 57: 56, + 88: 2, 89: 3, 90: 5, 91: 6, 92: 9, 93: 8, 94: 12, 95: 13, 96: 14, 97: 16, 98: 17, 99: 20, 100: 21, + 101: 23, 102: 24, 103: 27, 104: 28, 105: 30, 106: 33, 107: 34, 108: 36, 109: 37, 110: 40, 111: 41, + 112: 43, 113: 46, 114: 48, 115: 51, 116: 52, 117: 55, 118: 57} + +errors = [] +warnings = [] + + +def err(msg): + errors.append(msg) + + +def warn(msg): + warnings.append(msg) + + +def check_chat(): + lines_xml = (DATA / 'FakePlayerChatLines.xml').read_text(encoding='utf-8') + pools_xml = (DATA / 'FakePlayerPools.xml').read_text(encoding='utf-8') + seams = {} + for m in re.finditer(r']*>(.*?)', lines_xml, re.S): + seams[m.group(1)] = re.findall(r']*>(.*?)', m.group(2), re.S) + pools = {} + for m in re.finditer(r']*>(.*?)', pools_xml, re.S): + pools[m.group(1)] = re.findall(r']*>(.*?)', m.group(2), re.S) + total_lines = sum(len(v) for v in seams.values()) + total_pool = sum(len(v) for v in pools.values()) + print(f'seams: {len(seams)} ({total_lines} lines), pools: {len(pools)} ({total_pool} lines)') + + def scan(text, where): + for ref in re.findall(r'#([^#\s]+)#', text): + if ref not in pools: + err(f'{where}: unknown pool #{ref}#') + for slot in re.findall(r'\{([a-z_]+)\}', text): + if slot not in KNOWN_SLOTS: + err(f'{where}: unknown slot {{{slot}}}') + for open_, close in (('[', ']'), ('(', ')')): + if text.count(open_) != text.count(close): + err(f'{where}: unbalanced {open_}{close} in "{text}"') + if '{msg}' in text and where.split(':')[0] not in ('overheard', 'overheard_q', 'reply_again'): + pass + + for key, lines in seams.items(): + if not lines: + err(f'seam {key} is empty') + for line in lines: + scan(line, f'{key}:line') + for key, lines in pools.items(): + if not lines: + err(f'pool {key} is empty') + for line in lines: + scan(line, f'pool {key}') + + # Pool cycles. + graph = {k: set(re.findall(r'#([^#\s]+)#', ' '.join(v))) for k, v in pools.items()} + + def cyclic(node, stack): + if node in stack: + return True + for nxt in graph.get(node, ()): + if cyclic(nxt, stack | {node}): + return True + return False + + for key in pools: + if cyclic(key, set()): + err(f'pool cycle through {key}') + + # Seams referenced by code and intents. + intents_xml = (DATA / 'FakePlayerIntents.xml').read_text(encoding='utf-8') + intent_seams = set(re.findall(r'seam="([^"]+)"', intents_xml)) - {''} + for seam in sorted(CODE_SEAMS | intent_seams): + if seam not in seams: + err(f'seam referenced by code/intents but missing in XML: {seam}') + # Seams referenced as literals in the Java sources (best effort). + literal = set() + for java in JAVA.rglob('FakePlayer*.java'): + text = java.read_text(encoding='utf-8', errors='replace') + for m in re.finditer(r'(?:eventLine|optionalLine|compose|speak|speakPrivate|pick|pickFor)\([^;]*?"([a-z_]+)"', text): + literal.add(m.group(1)) + for seam in sorted(literal): + if seam not in seams and seam not in ('personality', 'player', 'target', 'level', 'msg', 'boss', 'zone', 'neutral', 'ganker', 'item', 'price'): + warn(f'literal "{seam}" used with the chat engine but not a seam (check the call)') + unused = sorted(set(seams) - CODE_SEAMS - intent_seams) + if unused: + warn(f'seams not referenced by code or intents: {", ".join(unused)}') + + +def check_combat(skilltrees, seed): + combat_xml = (DATA / 'FakePlayerCombat.xml').read_text(encoding='utf-8') + profiles = {} + for m in re.finditer(r']*>(.*?)', combat_xml, re.S): + profiles[int(m.group(1))] = [int(x) for x in re.findall(r'id="(\d+)"', m.group(2))] + print(f'combat profiles: {len(profiles)} classes') + if skilltrees and Path(skilltrees).is_dir(): + learnable = {} + for f in Path(skilltrees).rglob('*.xml'): + text = f.read_text(encoding='utf-8', errors='replace') + for tree in re.finditer(r']*>(.*?)', text, re.S): + cid = int(tree.group(1)) + learnable.setdefault(cid, set()).update(int(x) for x in re.findall(r'skillId="(\d+)"', tree.group(2))) + + def chain(cid): + out = set() + while cid is not None: + out |= learnable.get(cid, set()) + cid = PARENT.get(cid) + return out + + for cid, skills in profiles.items(): + available = chain(cid) + for sid in skills: + if sid not in available: + err(f'combat profile class {cid}: skill {sid} is not learnable by this class chain') + else: + warn('skill trees not found, combat skill ids not verified (pass --skilltrees)') + if seed and Path(seed).is_file(): + text = Path(seed).read_text(encoding='utf-8', errors='replace') + classes = set() + for m in re.finditer(r"'sppbots', '[^']*', (?:-?\d+, ){12}(\d+), \d+, \d+", text): + classes.add(int(m.group(1))) + missing = sorted(c for c in classes if c not in profiles) + if missing: + warn(f'seed classes without a combat profile (default profile is used): {missing}') + else: + print(f'all {len(classes)} seed classes have a combat profile') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--skilltrees', default=str(ROOT / 'server' / 'game' / 'data' / 'stats' / 'players' / 'skillTrees')) + parser.add_argument('--seed', default=str(SRC / 'dist' / 'db_installer' / 'sql' / 'spp_clan_seed.sql')) + args = parser.parse_args() + check_chat() + check_combat(args.skilltrees, args.seed) + for w in warnings: + print('WARN', w) + for e in errors: + print('ERROR', e) + print(f'{len(errors)} errors, {len(warnings)} warnings') + sys.exit(1 if errors else 0) + + +if __name__ == '__main__': + main() diff --git a/tools/deploy.sh b/tools/deploy.sh new file mode 100644 index 0000000..fa112c1 --- /dev/null +++ b/tools/deploy.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Deploys the SPP overlay (bot data, configs, chat handlers, seeds) and the built jars +# into a server directory (default: ../server). The datapack itself is vanilla Mobius, +# only the files under src_mobius/.../dist are ours. +# Usage: tools/deploy.sh [server_dir] [GameServer.jar] +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude" +TARGET="${1:-$ROOT/server}" +JAR="${2:-$ROOT/src_mobius/mobius/build/dist/libs/GameServer.jar}" +if [ ! -d "$TARGET/game" ]; then + echo "server dir not found: $TARGET" >&2 + exit 1 +fi +# Overlay: everything under dist except libs (vendored build deps) and the ini files that +# the docker entrypoint patches in place (Database.ini / Server.ini are not in the overlay anyway). +rsync -a --no-perms --no-owner --no-group --exclude 'libs/' "$SRC/dist/" "$TARGET/" +if [ -f "$JAR" ]; then + cp -f "$JAR" "$TARGET/libs/GameServer.jar" + LOGIN="$(dirname "$JAR")/LoginServer.jar" + [ -f "$LOGIN" ] && cp -f "$LOGIN" "$TARGET/libs/LoginServer.jar" + echo "jars -> $TARGET/libs" +else + echo "no jar at $JAR (build first: tools/build.sh); overlay deployed only" +fi +echo "overlay -> $TARGET (data, config, scripts, sql seeds)" diff --git a/tools/fix_zones.py b/tools/fix_zones.py new file mode 100644 index 0000000..b7bebee --- /dev/null +++ b/tools/fix_zones.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Re-checks the bot farming zones (FakePlayerProgression.xml) against the real +monster spawns of the datapack and keeps only spots where the monsters match +the zone level band. Bots are real players now: a level 15 fighter dropped +next to a level 34 Turek Orc Elder simply dies. + +For every zone point the monsters spawned within RADIUS are collected (point +spawns and territory spawns), the point is kept when the median monster level +fits the band and no dangerous pack (p75) sits above band+2. Zones that end +up with too few points get their band re-labelled to the real monster levels. + +Usage: python tools/fix_zones.py [--apply] [--datapack server/game/data] +Without --apply only the report is printed. +""" +import argparse +import math +import re +import statistics +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +XML = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude' / 'dist' / 'game' / 'data' / 'FakePlayerProgression.xml' +RADIUS = 1400 +MIN_POINTS = 10 +BAND = 4 # Width of a level band a spot is sorted into. +# Names for auto generated zones (grid cell of 4000 units -> area name), used to fill level holes. +CELL_NAMES = { + (-2, 27): 'RuinsOfAgony', (-5, 35): 'RuinsOfDespair', (-4, 37): 'RuinsOfDespair', + (-12, 11): 'Swampland', (-13, 11): 'Swampland', (-3, 12): 'DarkForest', (-4, 12): 'DarkForest', + (3, 19): 'SpiderNest', (4, 19): 'SpiderNest', (-17, 26): 'WindmillHill', (-15, 27): 'AbandonedCamp', + (-14, 27): 'AbandonedCamp', (36, -44): 'AbandonedCoalMines', (37, -43): 'AbandonedCoalMines', +} + + +def load_npc_levels(datapack): + levels = {} + for f in (datapack / 'stats' / 'npcs').rglob('*.xml'): + text = f.read_text(encoding='utf-8', errors='replace') + for m in re.finditer(r']*>(.*?)', text, re.S): + body = g.group(1) + nodes = [(int(a), int(b)) for a, b in re.findall(r']*\sx=)[^>]*?count="(\d+)"', body): + level = levels.get(int(npc_id)) + if level: + for _ in range(min(int(count), 6)): + blobs.append((cx, cy, level, radius)) + return blobs + + +def near(blobs, x, y): + out = [] + for bx, by, level, radius in blobs: + if math.hypot(bx - x, by - y) <= RADIUS + radius: + out.append(level) + return out + + +def percentile(values, p): + values = sorted(values) + k = (len(values) - 1) * p + lo = math.floor(k) + hi = math.ceil(k) + return values[lo] + (values[hi] - values[lo]) * (k - lo) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--apply', action='store_true') + parser.add_argument('--datapack', default=str(ROOT / 'server' / 'game' / 'data')) + args = parser.parse_args() + datapack = Path(args.datapack) + levels = load_npc_levels(datapack) + blobs = load_spawns(datapack, levels) + print(f'monster templates: {len(levels)}, spawn blobs: {len(blobs)}') + text = XML.read_text(encoding='utf-8') + new_text = text + for zm in re.finditer(r'(]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)()', text, re.S): + header, name, extra, lo, hi, body, tail = zm.groups() + lo = int(lo) + hi = int(hi) + points = re.findall(r'', body) + # Local danger of every point: the strongest packs around it (p90 of monster levels within RADIUS). + groups = {} + dropped = 0 + for x, y, z in points: + around = near(blobs, int(x), int(y)) + if len(around) < 3: + dropped += 1 + continue + danger = int(percentile(around, 0.9)) + groups.setdefault((danger - 1) // BAND, []).append((x, y, z, danger)) + pieces = '' + summary = [] + for band_index in sorted(groups): + pts = groups[band_index] + if len(pts) < MIN_POINTS: + dropped += len(pts) + continue + dangers = [p[3] for p in pts] + band_lo = max(1, min(dangers) - 3) + band_hi = max(band_lo + 2, max(dangers)) + summary.append(f'{band_lo}-{band_hi}:{len(pts)}') + pieces += f'\t\n' + ''.join(f'\t\t\n' for p in pts) + '\t\n' + if not pieces: + # Nothing safe here at all: keep the ten least dangerous points under the old band. + ranked = sorted(((x, y, z, int(percentile(near(blobs, int(x), int(y)) or [hi], 0.9))) for x, y, z in points), key=lambda p: p[3])[:MIN_POINTS] + pieces = f'\t\n' + ''.join(f'\t\t\n' for p in ranked) + '\t\n' + summary.append(f'{lo}-{hi}:{len(ranked)} (least dangerous)') + print(f'{name:22s} was {lo}-{hi}: {len(points)} points -> bands {", ".join(summary)}; dropped {dropped}') + new_text = new_text.replace('\t' + header + body + tail + '\n', pieces) + # Every level 1..76 must have a zone: fill holes with zones built from the spawns themselves. + # Racial starting areas only serve their race, so the holes are computed over the shared zones. + bands = [(int(a), int(b)) for a, b in re.findall(r'', new_text)] + holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)] + if holes: + print(f'levels without a shared zone: {holes} -> building zones from spawns') + existing = set(re.findall(r'\n' + ''.join(f'\t\t\n' for x, y, z in zpoints) + '\t\n' + print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points') + if added: + new_text = new_text.replace('\t', new_text)] + holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)] + if holes: + print(f'still without a shared zone (nearest zone is used): {holes}') + if args.apply: + XML.write_text(new_text, encoding='utf-8') + print(f'written {XML}') + else: + print('dry run, add --apply to write') + + +if __name__ == '__main__': + main() diff --git a/tools/harness/ChatHarness.java b/tools/harness/ChatHarness.java new file mode 100644 index 0000000..7cf95c9 --- /dev/null +++ b/tools/harness/ChatHarness.java @@ -0,0 +1,221 @@ +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.l2jmobius.gameserver.managers.FakePlayerBotContext; +import org.l2jmobius.gameserver.managers.FakePlayerChatLines; +import org.l2jmobius.gameserver.managers.FakePlayerHeardManager; +import org.l2jmobius.gameserver.managers.FakePlayerIdiolect; +import org.l2jmobius.gameserver.managers.FakePlayerIntentParser; + +/** + * Offline harness for the bot chat engine: runs the real server classes + * (FakePlayerChatLines, FakePlayerIntentParser, FakePlayerIdiolect, + * FakePlayerBotContext, FakePlayerHeardManager) against the real XML data + * without a game server. Run from a directory that contains data/ (see + * tools/run_harness.sh). Exit code 1 on any failed check. + */ +public class ChatHarness +{ + private static int failures = 0; + private static int checks = 0; + + private static void check(boolean condition, String what) + { + checks++; + if (!condition) + { + failures++; + System.out.println(" FAIL: " + what); + } + } + + public static void main(String[] args) + { + final FakePlayerChatLines lines = FakePlayerChatLines.getInstance(); + final FakePlayerIntentParser intents = FakePlayerIntentParser.getInstance(); + + // 1. Every seam the code uses must exist and produce a line with full slots. + final String[] codeSeams = + { + "greeting", "whisper_default", "reply_where", "reply_price", "reply_help_yes", "reply_help_no", "reply_party_no", "party_accept", "reply_insult_soft", "reply_insult_aggro", "reply_thanks", "reply_botcheck", "reply_duel", "reply_rumor_none", "reply_bye", "reply_yes_ok", "reply_how", "reply_caps", "reply_again", "overheard_q", "reply_level", "gank_start", "pkk_start", "war_start", "killed_by", "revenge_meet", "friendly_meet", "ambient", "levelup", "grats", "migrate", "raid_start", "trade_shout", "invite_player", "call_help", "lfp", "overheard", "assist", "companion_done", "store_title", "bot_talk", "reply_drop", "siege_start", "siege_defend", "siege_gate", "siege_engrave", "siege_win", "siege_lose", "profession", "died_mob", "loot_brag", "reply_clan", "reply_solo", "reply_long", "reply_pk" + }; + final Map slots = new HashMap<>(); + slots.put("player", "Vanger"); + slots.put("target", "Vanger"); + slots.put("level", "42"); + slots.put("boss", "Core"); + slots.put("zone", "Cruma"); + slots.put("town", "Giran"); + slots.put("msg", "продам дроп"); + slots.put("prev", "не пойду"); + slots.put("enemyclan", "RedCrew"); + slots.put("mob", "Cruma Marshlands Traitor"); + slots.put("castle", "Giran"); + slots.put("clan", "IronPact"); + slots.put("class", "Gladiator"); + slots.put("drop", "Oriharukon Ore"); + slots.put("item", "соски D"); + slots.put("price", "50к"); + slots.put("crowd", "6"); + System.out.println("== seams used by code (" + codeSeams.length + ") =="); + final Map samples = new TreeMap<>(); + for (String seam : codeSeams) + { + String ok = null; + for (int botId = 1; (botId <= 12) && (ok == null); botId++) + { + final Map facts = new HashMap<>(); + facts.put("personality", (botId % 4 == 0) ? "ganker" : (botId % 4 == 1) ? "helper" : (botId % 4 == 2) ? "pkk" : "neutral"); + facts.put("asker", (botId % 3 == 0) ? "stranger" : (botId % 3 == 1) ? "clanmate" : "killed_me"); + ok = lines.speak(botId, seam, facts, slots, false, null); + } + check(ok != null, "seam produces nothing: " + seam); + if (ok != null) + { + samples.put(seam, ok); + } + } + for (Map.Entry entry : samples.entrySet()) + { + System.out.println(" " + entry.getKey() + ": " + entry.getValue()); + } + + // 2. Context facts change what a bot says (plan / event / grounding slots). + System.out.println("== context =="); + FakePlayerBotContext.setPlan(77, "siege", 60000); + FakePlayerBotContext.setSlot(77, "castle", "Aden"); + int siegeMentions = 0; + for (int i = 0; i < 40; i++) + { + final String line = lines.speak(77, "ambient", Map.of("personality", "neutral"), null, false, null); + if ((line != null) && (line.contains("осад") || line.contains("Aden"))) + { + siegeMentions++; + } + } + System.out.println(" plan=siege -> siege mentions in 40 ambient lines: " + siegeMentions); + check(siegeMentions >= 8, "plan=siege rule should fire regularly (got " + siegeMentions + ")"); + FakePlayerBotContext.setPlan(78, "farm", 0); + FakePlayerBotContext.setEvent(78, "profession"); + int professionMentions = 0; + for (int i = 0; i < 40; i++) + { + final String line = lines.speak(78, "ambient", Map.of("personality", "neutral"), null, false, null); + if ((line != null) && line.contains("проф")) + { + professionMentions++; + } + } + System.out.println(" event=profession -> mentions in 40 ambient lines: " + professionMentions); + check(professionMentions >= 2, "event=profession rule should fire (got " + professionMentions + ")"); + FakePlayerBotContext.setSlot(79, "drop", "Adamantite Nugget"); + FakePlayerBotContext.setSlot(79, "mob", "Dion Grizzly"); + final String dropLine = lines.speak(79, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null); + System.out.println(" reply_drop with grounding slots: " + dropLine); + check(dropLine != null, "reply_drop with slots from context"); + final String noDrop = lines.speak(80, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null); + System.out.println(" reply_drop without drop slot: " + noDrop); + check(noDrop != null, "reply_drop must have slot-free lines"); + + // 3. Bot to bot openers are understood by the listener (intent) or fall back to overheard. + System.out.println("== bot_talk -> intents =="); + final Map distribution = new LinkedHashMap<>(); + int understood = 0; + final int openers = 200; + for (int i = 0; i < openers; i++) + { + final int speaker = 100 + (i % 20); + final String opener = lines.speak(speaker, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Kolyan", "level", "35"), false, null); + if (opener == null) + { + continue; + } + final FakePlayerIntentParser.Intent intent = intents.parse(opener); + final String key = (intent != null) ? intent.key : (FakePlayerHeardManager.isQuestionLine(opener) ? "(overheard_q)" : "(overheard)"); + distribution.merge(key, 1, Integer::sum); + if (intent != null) + { + understood++; + } + if (i < 12) + { + System.out.println(" \"" + opener + "\" -> " + key); + } + } + System.out.println(" intents: " + distribution); + check(understood >= (openers / 2), "at least half of bot_talk openers should map to an intent (got " + understood + "/" + openers + ")"); + + // 4. Sample dialogue as the social layer would run it (opener -> reply seam of the intent). + System.out.println("== sample dialogues =="); + for (int i = 0; i < 6; i++) + { + final int a = 300 + i; + final int b = 400 + i; + final String opener = lines.speak(a, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Zheka"), false, null); + final FakePlayerIntentParser.Intent intent = (opener != null) ? intents.parse(opener) : null; + String reply; + if ((intent != null) && !intent.seam.isEmpty()) + { + final Map replySlots = new HashMap<>(slots); + replySlots.put("player", "Vovan"); + reply = lines.speak(b, intent.seam, Map.of("personality", "helper", "asker", "clanmate"), replySlots, false, FakePlayerIdiolect.Mirror.of(opener)); + } + else + { + reply = lines.speak(b, (opener != null) && FakePlayerHeardManager.isQuestionLine(opener) ? "overheard_q" : "overheard", Map.of("personality", "helper"), Map.of("msg", FakePlayerHeardManager.echoOf(opener != null ? opener : ""), "player", "Vovan"), true, null); + } + System.out.println(" Vovan: " + opener); + System.out.println(" Zheka: " + reply + " [" + ((intent != null) ? intent.key : "overheard") + "]"); + } + + // 5. Idiolect: two bots never sound the same. + System.out.println("== idiolect =="); + final FakePlayerIdiolect one = FakePlayerIdiolect.of(1001); + final FakePlayerIdiolect two = FakePlayerIdiolect.of(1002); + check(!(one.laugh.equals(two.laugh) && one.filler.equals(two.filler) && (one.chattiness == two.chattiness) && (one.slang == two.slang)), "idiolects should differ"); + System.out.println(" 1001: laugh=" + one.laugh + " filler=" + one.filler + " slang=" + one.slang + " mat=" + one.traitMat); + System.out.println(" 1002: laugh=" + two.laugh + " filler=" + two.filler + " slang=" + two.slang + " mat=" + two.traitMat); + + // 6. Heard classification helpers. + check(FakePlayerHeardManager.isQuestionLine("кто знает где рб"), "cyrillic question detection"); + check(!FakePlayerHeardManager.isQuestionLine("фарм идет норм"), "statement is not a question"); + check(FakePlayerHeardManager.isCommercialLine("продам соски дешево"), "commercial detection"); + check("продам соски дешево".startsWith(FakePlayerHeardManager.echoOf("продам соски дешево налетай")), "echo takes first words"); + + // 7. Intents that sieges / economy rely on. + final FakePlayerIntentParser.Intent drop = intents.parse("дроп есть?"); + check((drop != null) && "ask_drop".equals(drop.key), "ask_drop intent"); + final FakePlayerIntentParser.Intent level = intents.parse("какой лвл"); + check((level != null) && "ask_level".equals(level.key), "ask_level intent"); + final FakePlayerIntentParser.Intent party = intents.parse("го в пати"); + check((party != null) && "ask_party".equals(party.key), "ask_party intent"); + final FakePlayerIntentParser.Intent how = intents.parse("как фарм?"); + check((how != null) && "how_are_you".equals(how.key), "how_are_you intent"); + final String[][] regression = + { + { "го пвп", "duel" }, { "помгите", "ask_help" }, { "дуель?", "duel" }, { "ты бот?", "botcheck" }, { "спс бро", "thanks" }, + { "лох", "insult" }, { "где ты", "ask_where" }, { "что нового", "ask_rumors" }, { "пока", "bye" }, { "прив", "greeting" }, + { "ок", "yes_ok" }, { "сколько стоит", "ask_price" }, { "клан есть?", "ask_clan" }, { "ты соло?", "ask_solo" }, + { "давно тут стоишь?", "ask_long" }, { "пк ходят?", "ask_pk" }, { "здарова, как жизнь молодая?", "how_are_you" }, + { "где качаться думаешь", "ask_where" } + }; + for (String[] pair : regression) + { + final FakePlayerIntentParser.Intent parsed = intents.parse(pair[0]); + final String got = (parsed != null) ? parsed.key : "null"; + check(got.equals(pair[1]), "intent of \"" + pair[0] + "\": expected " + pair[1] + ", got " + got); + } + final String[] mustBeNull = { "думаешь тут фармить?", "молодая", "стоишь тут" }; + for (String text : mustBeNull) + { + final FakePlayerIntentParser.Intent parsed = intents.parse(text); + check((parsed == null) || "ask_long".equals(parsed.key) || "ask_where".equals(parsed.key), "no false positive for \"" + text + "\" (got " + ((parsed != null) ? parsed.key : "null") + ")"); + } + + System.out.println("== " + (checks - failures) + "/" + checks + " checks passed =="); + System.exit(failures == 0 ? 0 : 1); + } +} diff --git a/tools/run_harness.sh b/tools/run_harness.sh new file mode 100644 index 0000000..b1dccca --- /dev/null +++ b/tools/run_harness.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Offline chat harness: real engine classes + real XML, no game server. +# Usage: tools/run_harness.sh [path/to/GameServer.jar] +# Needs JAVA_HOME with JDK 25 (or java/javac on PATH). +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude" +JAR="${1:-$ROOT/build/dist/libs/GameServer.jar}" +if [ ! -f "$JAR" ]; then + echo "GameServer.jar not found: $JAR (build first: tools/build.sh)" >&2 + exit 1 +fi +WORK="$(mktemp -d)" +mkdir -p "$WORK/data/xsd" +cp "$SRC"/dist/game/data/FakePlayer*.xml "$WORK/data/" +cp "$SRC"/dist/game/data/xsd/FakePlayer*.xsd "$WORK/data/xsd/" +JAVAC="${JAVA_HOME:+$JAVA_HOME/bin/}javac" +JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java" +"$JAVAC" -nowarn -d "$WORK" -cp "$JAR:$SRC/dist/libs/*" "$ROOT/tools/harness/ChatHarness.java" +cd "$WORK" +"$JAVA" -Dfile.encoding=UTF-8 -cp "$WORK:$JAR:$SRC/dist/libs/*" ChatHarness +STATUS=$? +rm -rf "$WORK" +exit $STATUS