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)

This commit is contained in:
2026-09-03 20:39:50 +02:00
parent dcbecd2843
commit 165820c041
40 changed files with 7634 additions and 2699 deletions
+46
View File
@@ -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
}
}
@@ -99,5 +99,35 @@ FakePlayerTradeChance = 30
# #
# Headless players: real Player instances without a client. They can join # Headless players: real Player instances without a client. They can join
# real parties (invite them - they auto accept), share exp, fight beside you. # real parties (invite them - they auto accept), share exp, fight beside you.
# Prototype: they farm near Giran. 0 = disabled. # 0 = disabled.
FakePlayerHeadlessCount = 500 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
@@ -1157,6 +1157,20 @@
<line trait="eng">{player}, гг тогда было</line> <line trait="eng">{player}, гг тогда было</line>
</seam> </seam>
<seam key="ambient"> <seam key="ambient">
<rule like="plan=siege:5">
<line>осада [идет], не до фарма</line>
<line>на осаде [сейчас], потом [поболтаем]</line>
<line>{castle} [сегодня] берем[, весь клан там]</line>
</rule>
<rule like="event=siege_finished:3">
<line>[только] с осады[, устал]</line>
<line>осада была [жесть][, еле выжил]</line>
<line>после осады [опять] на спот[, скучно]</line>
</rule>
<rule like="event=profession:2">
<line>профу [только] взял[, привыкаю к скиллам]</line>
<line>новые скиллы [после профы] [прям] огонь</line>
</rule>
<line>#smalltalk#</line> <line>#smalltalk#</line>
<line>#grounded_talk#</line> <line>#grounded_talk#</line>
<line>кто спот застолбил тут</line> <line>кто спот застолбил тут</line>
@@ -1580,6 +1594,219 @@
<line>все на сегодня, бывай</line> <line>все на сегодня, бывай</line>
<line trait="old">бывай, братва, го дальше</line> <line trait="old">бывай, братва, го дальше</line>
</seam> </seam>
<!-- ===== SPP 3: боты между собой, осады, профы, экономика ===== -->
<seam key="bot_talk">
<line>{player} как фарм[, идет]?</line>
<line>{player} [ты] давно тут [стоишь]?</line>
<line>{player} го (в пати|вместе)[, быстрее будет]</line>
<line>{player} дроп есть [какой-нибудь]?</line>
<line>{player} какой лвл [у тебя]?</line>
<line>{player} где рб видел[, не знаешь]?</line>
<line>слышь {player}, [а] ты (соло|один) [качаешься]?</line>
<line>{player} как дела[, что нового]?</line>
<line>{player} почем [сейчас] соски [берешь]?</line>
<line>{player} [ты] откуда [сам]?</line>
<line>{player} не видел [тут] пкшеров?</line>
<line>{player} сколько [еще] фармить будешь?</line>
<line>{player} где качаться [дальше] думаешь?</line>
<line>{player} #ask_back#</line>
<line>эй {player}, скучно [тут] одному[, поговорим]</line>
<line trait="mat">{player} бля, как оно [вообще]?</line>
<line trait="eng">{player} хай, как фарм гоес?</line>
<line trait="old">{player} здарова, как жизнь молодая?</line>
<rule like="event=levelup:3">
<line>{player} я {level} взял [только что][, кайф]</line>
<line>{player} видел? {level} [лвл] уже</line>
</rule>
<rule like="event=ganked:3">
<line>{player} тут пк ходит[, осторожно]</line>
<line>{player} меня [только что] гангнули[, видел кто]?</line>
</rule>
<rule like="plan=trade:2">
<line>{player} [а] ты не продаешь [ничего]?</line>
<line>{player} у меня лавка [стоит], зацени</line>
</rule>
</seam>
<seam key="reply_drop">
<line>[пока] пусто[, {player}]</line>
<line>{drop} [упал] [только что]</line>
<line>дроп так себе[, {drop} и все]</line>
<line>с {mob} ничего [толком] не падает</line>
<line>норм капает[, {drop} вот]</line>
<line>ничего [особенного], мусор [один]</line>
<line>{drop} только[, и то один]</line>
<line>ресы [в основном][, на продажу]</line>
<line>[да] ничего[, ты как]?</line>
<line>дроп есть, [но] не скажу [какой] ~lol~</line>
<line trait="mat">хрен [там] а не дроп</line>
</seam>
<seam key="siege_start">
<line>{clan} [идет] на {castle}[, всем сбор]</line>
<line>осада {castle}, [ну] погнали[, {clan}]</line>
<line>{castle} [сегодня] наш [будет]</line>
<line>{clan} у ворот {castle}[, готовьте печать]</line>
<line>на {castle}[, все] за мной</line>
<line>ворота {castle} [сейчас] ломаем</line>
<line>{castle}, [мы] пришли за тобой</line>
<line>{clan} на осаде[, го го]</line>
<line>кто держит {castle}, выходите [драться]</line>
<line>осада началась, {clan} [в деле]</line>
<line>ну что, {castle}, [сейчас] посмотрим кто сильнее</line>
<line trait="mat">{castle} наш, [нахрен] всех у ворот</line>
<line trait="eng">гоу гоу {castle}, {clan} атакует</line>
<line trait="old">за {clan}, братва, {castle} берем</line>
</seam>
<seam key="siege_defend">
<line>{castle} [никому] не отдадим</line>
<line>{clan} держит {castle}[, подходите]</line>
<line>защита {castle}[, все] к артефакту</line>
<line>ворота [пока] стоят, {castle} наш</line>
<line>кто [там] лезет на {castle}[, идите домой]</line>
<line>{clan} на стенах[, ждем гостей]</line>
<line>{castle} держим до конца</line>
<line>[все] к печати, [никого] не пускаем</line>
<line>осада {castle}, защищаем [свое]</line>
<line trait="mat">{castle} хрен [вам] а не замок</line>
<line trait="old">за {clan}, [пацаны], держим {castle}</line>
</seam>
<seam key="siege_gate">
<line>ворота [почти] лежат[, го дальше]</line>
<line>бьем ворота [все вместе]</line>
<line>ворота {castle} [сейчас] упадут</line>
<line>[все] по воротам, [не] отвлекаемся</line>
<line>дожимаем ворота[, потом печать]</line>
<line>ворота [еще] стоят[, бьем]</line>
<line>{clan} [все] на ворота</line>
<line>ломаем [и] заходим</line>
<line trait="mat">ворота [бля] крепкие, бьем [дальше]</line>
</seam>
<seam key="siege_engrave">
<line>печать [пошла], прикройте [меня]</line>
<line>кастую печать, [никого] не пускать</line>
<line>три минуты, держите [артефакт]</line>
<line>печать [на артефакте], {castle} [почти] наш</line>
<line>лидер кастует[, все] вокруг него</line>
<line>прикрываем печать[, {castle} наш]</line>
</seam>
<seam key="siege_win">
<line>{castle} наш[, гг]</line>
<line>{clan} взял {castle}[, красавцы]</line>
<line>гг [всем], {castle} [теперь] наш</line>
<line>[ну все], {castle} у {clan}</line>
<line>отстояли {castle}[, гг]</line>
<line>{castle} остается [за нами][, гг]</line>
<line>победа[, {castle}]</line>
<line>гг, [хорошая] осада</line>
<line trait="mat">{castle} наш [нахрен], гг</line>
<line trait="eng">изи, {castle} out[, гг]</line>
<line trait="old">{castle} наш, братва[, гг]</line>
</seam>
<seam key="siege_lose">
<line>[ну] не вышло [с {castle}]</line>
<line>{castle} не взяли[, в следующий раз]</line>
<line>гг, [слили] осаду</line>
<line>проиграли {castle}[, обидно]</line>
<line>[все], {castle} [пока] не наш</line>
<line>отдали {castle}[, позор]</line>
<line>гг [хоть] подрались</line>
<line>{castle} слили[, через две недели вернемся]</line>
<line trait="mat">{castle} [бля] слили[, гг]</line>
<line trait="eng">гг вп, {castle} лост</line>
</seam>
<seam key="profession">
<line>[все], профу взял[, теперь {class}]</line>
<line>{class} [теперь][, кайф]</line>
<line>профу [наконец] сделал</line>
<line>[ееее] я {class}[, го дальше]</line>
<line>квест на профу [наконец] закрыт</line>
<line>[ну вот], {class}[, теперь] качаемся дальше</line>
<line>профа [есть], скиллы новые[, зацените]</line>
<line>с профой [меня][, {class}]</line>
<line trait="mat">[бля] наконец профа, {class}</line>
<line trait="eng">гц ми, {class} нау</line>
</seam>
<seam key="died_mob">
<line>[ну и] {mob} [меня] сложил</line>
<line>помер [от] {mob}[, позор]</line>
<line>{mob} [это] что-то [с чем-то]</line>
<line>слился [от] {mob}[, не заметил]</line>
<line>{mob} [меня] вынес[, хилок не было]</line>
<line>[все], [я] труп[, {mob} сильный]</line>
<line>{mob} [нафармил] меня ~lol~</line>
<line>рес есть [у кого]? {mob} [меня] убил</line>
<line trait="mat">{mob} [сука] убил [меня]</line>
</seam>
<seam key="loot_brag">
<line>{drop} [упал][, зацените]</line>
<line>о, {drop}[, наконец]</line>
<line>{drop} [с {mob}][, повезло]</line>
<line>[ееее] {drop}</line>
<line>{drop} выбил[, продам]</line>
<line trait="mat">{drop} [бля] выпал[, наконец]</line>
</seam>
<seam key="reply_clan">
<line>[да], {clan}[, а что]</line>
<line>{clan} [у меня][, норм клан]</line>
<line>в {clan} состою[, {player}]</line>
<line>{clan}, [мы] тут все [стоим]</line>
<line>[ага], {clan}[, зайди к нам]</line>
<line>клан есть, {clan}[, а ты]?</line>
<line>без клана [пока][, ищу]</line>
<line>[пока] нет [клана][, зовут - не иду]</line>
<line>ищу клан[, если что]</line>
<line trait="mat">{clan} [бля], лучший клан</line>
<rule need="asker=clanmate">
<line>~lol~ [ты чего], мы ж в одном [клане]</line>
<line>{player}, [ты] в нашем клане [вообще-то]</line>
</rule>
</seam>
<seam key="reply_solo">
<line>соло[, да][, {player}]</line>
<line>[пока] один[, пати не нашел]</line>
<line>соло фармлю[, так быстрее]</line>
<line>один[, а что][, го вместе]?</line>
<line>с кланом [обычно], сейчас [вот] один</line>
<line>соло, [тут] пати не нужна</line>
<line>[ну] один, [и] что?</line>
<line>одному [тут] норм[, никто не мешает]</line>
<line>в пати [сейчас][, но народ афк]</line>
<line trait="mat">один [бля], [все] разбежались</line>
</seam>
<seam key="reply_long">
<line>[уже] пару часов [тут]</line>
<line>с утра [стою][, {player}]</line>
<line>[да] только пришел</line>
<line>[минут] сорок [примерно]</line>
<line>давно[, {level} тут взял]</line>
<line>с {level} [лвла] тут [фармлю]</line>
<line>[ну] час где-то</line>
<line>[уже] не помню[, долго]</line>
<line>недавно[, а что]?</line>
<line>[еще] пару лвлов [и] уйду [отсюда]</line>
</seam>
<seam key="reply_pk">
<line>[пока] тихо[, никого]</line>
<line>не видел [сегодня][, вроде чисто]</line>
<line>[был] один [красный], ушел [в сторону {zone}]</line>
<line>ходит [тут] кто-то[, осторожнее]</line>
<line>тут [всегда] спокойно[, фармь]</line>
<line>[вроде] чисто[, но ты] смотри [по сторонам]</line>
<line>пкшеры [обычно] вечером [приходят]</line>
<line>если что, [я] рядом[, кричи]</line>
<rule like="event=ganked:4">
<line>[только что] был[, меня и убил]</line>
<line>да[, меня] гангнули [только что][, {player}]</line>
<line>есть один [урод][, ищу его]</line>
</rule>
<rule like="personality=pkk:3">
<line>[если] увидишь - скажи[, я их ловлю]</line>
<line>[я] за этим и стою [тут]</line>
</rule>
<rule like="personality=ganker:3">
<line>[а] что, боишься? ~lol~</line>
<line>[тут] только я ~lol~</line>
</rule>
</seam>
<seam key="store_title"> <seam key="store_title">
<line>#store_goods#[85:, #store_price_word#][45:, #store_mood#]</line> <line>#store_goods#[85:, #store_price_word#][45:, #store_mood#]</line>
</seam> </seam>
@@ -2,95 +2,342 @@
<!-- <!--
Боевые наборы ботов. Правится без пересборки. Боевые наборы ботов. Правится без пересборки.
role - fighter | archer | mage | healer | support (влияет на дистанцию и приоритеты) role - fighter | archer | mage | healer | support | spoiler (влияет на дистанцию и приоритеты;
spoiler ещё спойлит цель и свипает труп)
offensive - атакующий скилл. priority: меньше = важнее. offensive - атакующий скилл. priority: меньше = важнее.
maxTargetHp - кастовать, только если у цели HP ниже этого % (добивание) maxTargetHp - кастовать, только если у цели HP ниже этого % (добивание)
minMp - не кастовать, если своего MP меньше этого % minMp - не кастовать, если своего MP меньше этого %
self - самобаф/усиление, вешается вне боя self - самобаф/усиление, вешается вне боя
heal - лечение: minHp = кастовать, если HP цели/своё ниже этого % heal - лечение: minHp = кастовать, если HP цели/своё ниже этого %
Скиллы, которых у бота нет по уровню, молча пропускаются. Скиллы, которых у бота нет по уровню, молча пропускаются. Id сверены с stats/players/skillTrees.
Третьи профессии наследуют скиллы вторых, поэтому у них те же наборы.
--> -->
<combat> <combat xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerCombat.xsd">
<!-- ===== Бойцы ближнего боя ===== --> <!-- ===== Базовые классы (1-19) ===== -->
<class id="1" role="fighter" name="Warrior"> <class id="0" role="fighter" name="Human Fighter">
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" /> <!-- Mortal Blow --> <offensive id="16" priority="1" maxTargetHp="100" minMp="15" /> <!-- Mortal Blow -->
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Strike --> <offensive id="3" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Strike -->
<self id="139" /> <!-- War Cry -->
</class> </class>
<class id="2" role="fighter" name="Gladiator"> <class id="18" role="fighter" name="Elven Fighter">
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" /> <!-- Triple Sonic Slash --> <offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="6" priority="2" maxTargetHp="100" minMp="15" /> <!-- Sonic Blaster --> <offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
<offensive id="1" priority="3" maxTargetHp="100" minMp="10" /> <!-- Triple Slash --> <heal id="58" priority="1" minHp="50" /> <!-- Elemental Heal -->
<self id="8" /> <!-- Sonic Focus -->
<self id="139" />
</class> </class>
<class id="3" role="fighter" name="Warlord"> <class id="31" role="fighter" name="Dark Fighter">
<offensive id="245" priority="1" maxTargetHp="100" minMp="20" /> <offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="4" priority="2" maxTargetHp="100" minMp="15" /> <offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
<self id="139" /> <offensive id="70" priority="3" maxTargetHp="100" minMp="20" /> <!-- Drain Health -->
</class> </class>
<class id="4" role="fighter" name="Knight"> <class id="44" role="fighter" name="Orc Fighter">
<offensive id="263" priority="1" maxTargetHp="100" minMp="20" /> <offensive id="29" priority="1" maxTargetHp="100" minMp="15" /> <!-- Iron Punch -->
<offensive id="1" priority="2" maxTargetHp="100" minMp="10" /> <offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
<self id="98" /> <!-- Shield Fortress -->
</class> </class>
<class id="7" role="fighter" name="Rogue"> <class id="53" role="spoiler" name="Dwarven Fighter">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" /> <!-- Backstab: добивание --> </class>
<offensive id="16" priority="2" maxTargetHp="100" minMp="15" /> <class id="10" role="mage" name="Human Mystic">
<self id="111" /> <!-- Rapid Shot --> <offensive id="1177" priority="2" maxTargetHp="100" minMp="10" /> <!-- Wind Strike -->
<offensive id="1184" priority="1" maxTargetHp="100" minMp="20" /> <!-- Ice Bolt -->
<heal id="1216" priority="1" minHp="55" /> <!-- Self Heal -->
<self id="1040" /> <!-- Shield -->
</class>
<class id="25" role="mage" name="Elven Mystic">
<offensive id="1177" priority="2" maxTargetHp="100" minMp="10" />
<offensive id="1184" priority="1" maxTargetHp="100" minMp="20" />
<heal id="1216" priority="1" minHp="55" />
<self id="1040" />
</class>
<class id="38" role="mage" name="Dark Mystic">
<offensive id="1177" priority="2" maxTargetHp="100" minMp="10" />
<offensive id="1184" priority="1" maxTargetHp="100" minMp="20" />
<heal id="1216" priority="1" minHp="55" />
<self id="1040" />
</class> </class>
<!-- ===== Лучники ===== --> <!-- ===== Первая профессия (20-39) ===== -->
<class id="1" role="fighter" name="Warrior">
<offensive id="100" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stun Attack -->
<offensive id="255" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Smash -->
<offensive id="16" priority="3" maxTargetHp="100" minMp="15" />
<self id="78" /> <!-- War Cry -->
</class>
<class id="4" role="fighter" name="Knight">
<offensive id="92" priority="1" maxTargetHp="100" minMp="20" /> <!-- Shield Stun -->
<offensive id="70" priority="2" maxTargetHp="100" minMp="15" /> <!-- Drain Health -->
<offensive id="3" priority="3" maxTargetHp="100" minMp="10" />
<heal id="45" priority="1" minHp="50" /> <!-- Divine Heal -->
<self id="82" /> <!-- Majesty -->
</class>
<class id="7" role="fighter" name="Rogue">
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="56" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Shot -->
<self id="312" /> <!-- Vicious Stance -->
</class>
<class id="11" role="mage" name="Wizard">
<offensive id="1220" priority="1" maxTargetHp="100" minMp="25" /> <!-- Blaze -->
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" /> <!-- Flame Strike -->
<offensive id="1184" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="1177" priority="4" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" /> <!-- Concentration -->
</class>
<class id="15" role="healer" name="Cleric">
<heal id="1015" priority="1" minHp="45" /> <!-- Battle Heal -->
<heal id="1011" priority="2" minHp="65" /> <!-- Heal -->
<offensive id="1184" priority="4" maxTargetHp="100" minMp="30" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="20" />
<self id="1040" />
<self id="1068" /> <!-- Might -->
<self id="1204" /> <!-- Wind Walk -->
</class>
<class id="22" role="archer" name="Elven Scout">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stunning Shot -->
<offensive id="56" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Shot -->
<heal id="58" priority="1" minHp="50" />
<self id="312" />
<self id="99" /> <!-- Rapid Shot -->
</class>
<class id="26" role="mage" name="Elven Wizard">
<offensive id="1175" priority="1" maxTargetHp="100" minMp="25" /> <!-- Aqua Swirl -->
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="1184" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="1177" priority="4" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
</class>
<class id="35" role="fighter" name="Assassin">
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="223" priority="2" maxTargetHp="100" minMp="15" /> <!-- Sting -->
<offensive id="70" priority="3" maxTargetHp="100" minMp="15" />
<self id="312" />
</class>
<class id="39" role="mage" name="Dark Wizard">
<offensive id="1178" priority="1" maxTargetHp="100" minMp="25" /> <!-- Twister -->
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="1184" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="1177" priority="4" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
</class>
<class id="45" role="fighter" name="Orc Raider">
<offensive id="100" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="255" priority="2" maxTargetHp="100" minMp="15" />
<offensive id="29" priority="3" maxTargetHp="100" minMp="15" />
<self id="94" /> <!-- Rage -->
<heal id="34" priority="1" minHp="40" /> <!-- Bandage -->
</class>
<class id="54" role="spoiler" name="Scavenger">
<offensive id="100" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="245" priority="2" maxTargetHp="100" minMp="15" /> <!-- Wild Sweep -->
<heal id="34" priority="1" minHp="40" />
</class>
<!-- ===== Вторая профессия (40+) и третья (76+) ===== -->
<class id="2" role="fighter" name="Gladiator">
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" /> <!-- Triple Sonic Slash -->
<offensive id="5" priority="2" maxTargetHp="100" minMp="20" /> <!-- Double Sonic Slash -->
<offensive id="6" priority="3" maxTargetHp="100" minMp="15" /> <!-- Sonic Blaster -->
<offensive id="1" priority="4" maxTargetHp="100" minMp="10" /> <!-- Triple Slash -->
<offensive id="100" priority="5" maxTargetHp="100" minMp="10" />
<self id="8" /> <!-- Sonic Focus -->
<self id="78" />
</class>
<class id="88" role="fighter" name="Duelist">
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="5" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="6" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="1" priority="4" maxTargetHp="100" minMp="10" />
<self id="8" />
<self id="78" />
</class>
<class id="5" role="fighter" name="Paladin">
<offensive id="92" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="49" priority="2" maxTargetHp="100" minMp="20" /> <!-- Holy Strike -->
<offensive id="70" priority="3" maxTargetHp="100" minMp="15" />
<heal id="45" priority="1" minHp="50" />
<self id="196" /> <!-- Holy Blade -->
<self id="197" /> <!-- Holy Armor -->
<self id="82" />
</class>
<class id="90" role="fighter" name="Phoenix Knight">
<offensive id="92" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="49" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="70" priority="3" maxTargetHp="100" minMp="15" />
<heal id="45" priority="1" minHp="50" />
<self id="196" />
<self id="197" />
<self id="82" />
</class>
<class id="8" role="fighter" name="Treasure Hunter">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" /> <!-- Backstab: добивание -->
<offensive id="263" priority="2" maxTargetHp="100" minMp="20" /> <!-- Deadly Blow -->
<offensive id="16" priority="3" maxTargetHp="100" minMp="15" />
<self id="312" />
<self id="137" /> <!-- Critical Chance -->
</class>
<class id="93" role="fighter" name="Adventurer">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" />
<offensive id="263" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="16" priority="3" maxTargetHp="100" minMp="15" />
<self id="312" />
<self id="137" />
</class>
<class id="9" role="archer" name="Hawkeye"> <class id="9" role="archer" name="Hawkeye">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stun Shot --> <offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stunning Shot -->
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" /> <!-- Double Shot --> <offensive id="19" priority="2" maxTargetHp="100" minMp="25" /> <!-- Double Shot -->
<self id="111" /> <offensive id="24" priority="3" maxTargetHp="100" minMp="25" /> <!-- Burst Shot -->
<offensive id="56" priority="4" maxTargetHp="100" minMp="10" />
<self id="312" />
<self id="99" /> <!-- Rapid Shot -->
</class>
<class id="92" role="archer" name="Sagittarius">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="19" priority="2" maxTargetHp="100" minMp="25" />
<offensive id="24" priority="3" maxTargetHp="100" minMp="25" />
<offensive id="56" priority="4" maxTargetHp="100" minMp="10" />
<self id="312" />
<self id="99" />
</class>
<class id="12" role="mage" name="Sorcerer">
<offensive id="1230" priority="1" maxTargetHp="100" minMp="30" /> <!-- Prominence -->
<offensive id="1231" priority="2" maxTargetHp="100" minMp="25" /> <!-- Aura Flare -->
<offensive id="1220" priority="3" maxTargetHp="100" minMp="20" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
<self id="1232" /> <!-- Blazing Skin -->
</class>
<class id="94" role="mage" name="Archmage">
<offensive id="1230" priority="1" maxTargetHp="100" minMp="30" />
<offensive id="1231" priority="2" maxTargetHp="100" minMp="25" />
<offensive id="1220" priority="3" maxTargetHp="100" minMp="20" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
<self id="1232" />
</class>
<class id="16" role="healer" name="Bishop">
<heal id="1218" priority="1" minHp="45" /> <!-- Greater Battle Heal -->
<heal id="1217" priority="2" minHp="70" /> <!-- Greater Heal -->
<heal id="1015" priority="3" minHp="60" />
<offensive id="1028" priority="4" maxTargetHp="100" minMp="40" /> <!-- Might of Heaven -->
<offensive id="1184" priority="5" maxTargetHp="100" minMp="30" />
<self id="1040" />
<self id="1068" />
<self id="1204" />
<self id="1085" /> <!-- Acumen -->
</class>
<class id="97" role="healer" name="Cardinal">
<heal id="1218" priority="1" minHp="45" />
<heal id="1217" priority="2" minHp="70" />
<heal id="1015" priority="3" minHp="60" />
<offensive id="1028" priority="4" maxTargetHp="100" minMp="40" />
<offensive id="1184" priority="5" maxTargetHp="100" minMp="30" />
<self id="1040" />
<self id="1068" />
<self id="1204" />
<self id="1085" />
</class> </class>
<class id="24" role="archer" name="Silver Ranger"> <class id="24" role="archer" name="Silver Ranger">
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <offensive id="101" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" /> <offensive id="19" priority="2" maxTargetHp="100" minMp="25" />
<self id="111" /> <offensive id="24" priority="3" maxTargetHp="100" minMp="25" />
<offensive id="56" priority="4" maxTargetHp="100" minMp="10" />
<heal id="58" priority="1" minHp="50" />
<self id="312" />
<self id="99" />
</class> </class>
<class id="102" role="archer" name="Moonlight Sentinel">
<!-- ===== Маги ===== --> <offensive id="101" priority="1" maxTargetHp="100" minMp="20" />
<class id="12" role="mage" name="Sorcerer"> <offensive id="19" priority="2" maxTargetHp="100" minMp="25" />
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" /> <!-- Wind Strike --> <offensive id="24" priority="3" maxTargetHp="100" minMp="25" />
<offensive id="1230" priority="1" maxTargetHp="100" minMp="30" /> <!-- Prominence --> <offensive id="56" priority="4" maxTargetHp="100" minMp="10" />
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" /> <heal id="58" priority="1" minHp="50" />
<self id="1085" /> <!-- Acumen --> <self id="312" />
</class> <self id="99" />
<class id="13" role="mage" name="Necromancer">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
<offensive id="1234" priority="1" maxTargetHp="100" minMp="30" />
<self id="1085" />
</class> </class>
<class id="27" role="mage" name="Spellsinger"> <class id="27" role="mage" name="Spellsinger">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
<offensive id="1235" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hydro Blast --> <offensive id="1235" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hydro Blast -->
<self id="1085" /> <offensive id="1231" priority="2" maxTargetHp="100" minMp="25" />
<offensive id="1175" priority="3" maxTargetHp="100" minMp="20" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
<self id="1238" /> <!-- Freezing Skin -->
</class> </class>
<class id="34" role="mage" name="Spellhowler"> <class id="103" role="mage" name="Mystic Muse">
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" /> <offensive id="1235" priority="1" maxTargetHp="100" minMp="30" />
<offensive id="1231" priority="2" maxTargetHp="100" minMp="25" />
<offensive id="1175" priority="3" maxTargetHp="100" minMp="20" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
<self id="1238" />
</class>
<class id="36" role="fighter" name="Abyss Walker">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" />
<offensive id="263" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="223" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="16" priority="4" maxTargetHp="100" minMp="10" />
<self id="312" />
</class>
<class id="108" role="fighter" name="Ghost Hunter">
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" />
<offensive id="263" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="223" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="16" priority="4" maxTargetHp="100" minMp="10" />
<self id="312" />
</class>
<class id="40" role="mage" name="Spellhowler">
<offensive id="1239" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hurricane --> <offensive id="1239" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hurricane -->
<self id="1085" /> <offensive id="1234" priority="2" maxTargetHp="100" minMp="25" /> <!-- Vampiric Claw -->
<offensive id="1178" priority="3" maxTargetHp="100" minMp="20" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<heal id="1216" priority="1" minHp="50" />
<self id="1078" />
</class> </class>
<class id="110" role="mage" name="Storm Screamer">
<!-- ===== Лекари и саппорт ===== --> <offensive id="1239" priority="1" maxTargetHp="100" minMp="30" />
<class id="15" role="healer" name="Cleric"> <offensive id="1234" priority="2" maxTargetHp="100" minMp="25" />
<heal id="1217" priority="1" minHp="70" /> <!-- Group Heal? нет - Heal --> <offensive id="1178" priority="3" maxTargetHp="100" minMp="20" />
<heal id="1011" priority="2" minHp="55" /> <!-- Heal --> <offensive id="1177" priority="5" maxTargetHp="100" minMp="5" />
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" /> <heal id="1216" priority="1" minHp="50" />
<self id="1040" /> <!-- Shield --> <self id="1078" />
</class> </class>
<class id="16" role="support" name="Prophet"> <class id="46" role="fighter" name="Destroyer">
<self id="1040" /> <offensive id="190" priority="1" maxTargetHp="100" minMp="20" /> <!-- Fatal Strike -->
<self id="1085" /> <offensive id="260" priority="2" maxTargetHp="100" minMp="20" /> <!-- Hammer Crush -->
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" /> <offensive id="36" priority="3" maxTargetHp="100" minMp="15" /> <!-- Whirlwind -->
<offensive id="100" priority="4" maxTargetHp="100" minMp="10" />
<self id="121" /> <!-- Battle Roar -->
<self id="139" /> <!-- Guts -->
<heal id="34" priority="1" minHp="40" />
</class>
<class id="113" role="fighter" name="Titan">
<offensive id="190" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="260" priority="2" maxTargetHp="100" minMp="20" />
<offensive id="36" priority="3" maxTargetHp="100" minMp="15" />
<offensive id="100" priority="4" maxTargetHp="100" minMp="10" />
<self id="121" />
<self id="139" />
<heal id="34" priority="1" minHp="40" />
</class>
<class id="55" role="spoiler" name="Bounty Hunter">
<offensive id="260" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="36" priority="2" maxTargetHp="100" minMp="15" />
<offensive id="100" priority="3" maxTargetHp="100" minMp="10" />
<heal id="34" priority="1" minHp="40" />
</class>
<class id="117" role="spoiler" name="Fortune Seeker">
<offensive id="260" priority="1" maxTargetHp="100" minMp="20" />
<offensive id="36" priority="2" maxTargetHp="100" minMp="15" />
<offensive id="100" priority="3" maxTargetHp="100" minMp="10" />
<heal id="34" priority="1" minHp="40" />
</class> </class>
<!-- Значения по умолчанию, если класса нет в списке --> <!-- Значения по умолчанию, если класса нет в списке -->
<default role="fighter"> <default role="fighter">
<offensive id="3" priority="1" maxTargetHp="100" minMp="15" /> <offensive id="3" priority="1" maxTargetHp="100" minMp="15" />
<offensive id="1" priority="2" maxTargetHp="100" minMp="10" /> <offensive id="16" priority="2" maxTargetHp="100" minMp="15" />
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
</default> </default>
</combat> </combat>
@@ -2,19 +2,24 @@
<!-- Intent lexicon: an intent matches when EVERY <m> group has at least one <!-- Intent lexicon: an intent matches when EVERY <m> group has at least one
alternative that prefix-matches a token of the normalized message. alternative that prefix-matches a token of the normalized message.
maxTokens limits the intent to short messages. Order = priority. --> maxTokens limits the intent to short messages. Order = priority. -->
<intents> <intents xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerIntents.xsd">
<intent key="botcheck" seam="reply_botcheck"><m>бот|боты|скрипт|макрос|программ</m></intent> <intent key="botcheck" seam="reply_botcheck"><m>бот|боты|скрипт|макрос|программ</m></intent>
<intent key="insult" seam=""><m>лох|лош|нуб|дурак|дура|дебил|идиот|туп|мусор|чмо|клоун|днище|бомж|крыса|слаб|отстой|пшел|пшла|заткни</m></intent> <intent key="insult" seam=""><m>лох|лош|нуб|дурак|дура|дебил|идиот|туп|мусор|чмо|клоун|днище|бомж|крыса|слаб|отстой|пшел|пшла|заткни</m></intent>
<intent key="thanks" seam="reply_thanks"><m>спс|спасиб|пасиб|благодар|сенкс|thx|ty|респект|красав</m></intent> <intent key="thanks" seam="reply_thanks"><m>спс|спасиб|пасиб|благодар|сенкс|thx|ty|респект|красав</m></intent>
<intent key="duel" seam="reply_duel"><m>дуэль|дюль|1в1|1x1|1х1|соло|выйдем|стыкнемся|пвп</m></intent> <intent key="duel" seam="reply_duel"><m>дуэль|дюль|1в1|1x1|1х1|выйдем|стыкнемся|=пвп|=дуель</m></intent>
<intent key="ask_help" seam=""><m>помо|хелп|помощ|хиль|отхил|хил|рес|ресни|спаси|выруч|бафни|баф|подсоб</m></intent> <intent key="ask_help" seam=""><m>помо|помг|помаг|памаг|хелп|хэлп|help|помощ|хиль|отхил|=хил|=рес|ресни|спаси|выруч|бафни|=баф|подсоб</m></intent>
<intent key="ask_party" seam="reply_party_no"><m>пати|парти|групп|пл|инвайт|прими|возьми</m></intent> <intent key="ask_party" seam="reply_party_no"><m>пати|парти|групп|пл|инвайт|прими|возьми</m></intent>
<intent key="ask_level" seam="reply_level"><m>какой|скок|сколько|че|чо</m><m>лвл|левел|уровен|лева</m></intent> <intent key="ask_level" seam="reply_level"><m>какой|скок|сколько|че|чо</m><m>лвл|левел|уровен|лева</m></intent>
<intent key="ask_price" seam="reply_price"><m>почем|цена|цену|купи|продай|продаш|скупа|стоит|аден</m></intent> <intent key="ask_price" seam="reply_price"><m>почем|цена|цену|купи|продай|продаш|скупа|=стоит|=стоят|аден|=цен</m></intent>
<intent key="ask_rumors" seam="reply_rumor_none"><m>что|чо|че|какие|слыш</m><m>нов|слыш|происход|творится|интересн</m></intent> <intent key="ask_rumors" seam="reply_rumor_none"><m>что|чо|че|какие|слыхал|слышал|слышно|=есть</m><m>=нов|слух|происход|творится|интересн|расскаж|=новост</m></intent>
<intent key="ask_drop" seam="reply_drop"><m>дроп|падает|капает|выпал|выбил|лут</m></intent>
<intent key="ask_where" seam="reply_where"><m>где|куда|далеко</m></intent> <intent key="ask_where" seam="reply_where"><m>где|куда|далеко</m></intent>
<intent key="ask_clan" seam="reply_clan"><m>клан|кланы|=кп|гильд</m></intent>
<intent key="ask_solo" seam="reply_solo" maxTokens="6"><m>=соло|один|одна|=сам|=сама|одиноч</m></intent>
<intent key="ask_long" seam="reply_long" maxTokens="6"><m>давно|=долго|сколько</m><m>=тут|=здесь|играешь|стоишь|фармишь|=на серв|качаешь</m></intent>
<intent key="ask_pk" seam="reply_pk" maxTokens="7"><m>=пк|пкшер|ганк|гангер|=красн|убийц|опасно</m></intent>
<intent key="bye" seam="reply_bye"><m>пока|бб|бай|досвид|бывай|споки|удачи</m></intent> <intent key="bye" seam="reply_bye"><m>пока|бб|бай|досвид|бывай|споки|удачи</m></intent>
<intent key="how_are_you" seam="reply_how" maxTokens="5"><m>как|че|чо|что</m><m>дела|сам|оно|как|жизнь|делаешь|поживаешь|фарм|настрой</m></intent> <intent key="how_are_you" seam="reply_how" maxTokens="5"><m>как|че|чо|что</m><m>дела|сам|оно|как|жизнь|делаешь|поживаешь|фарм|настрой</m></intent>
<intent key="greeting" seam="greeting" maxTokens="3"><m>привет|прив|хай|здаров|здоров|ку|йо|хелло|дратути|салют|даров</m></intent> <intent key="greeting" seam="greeting" maxTokens="3"><m>привет|превет|прив|хай|здаров|здоров|здраст|=ку|=йо|хелло|дратути|салют|даров|дароу|=хи|hi|hello</m></intent>
<intent key="yes_ok" seam="reply_yes_ok" maxTokens="2"><m>да|ага|ок|окей|норм|пон|ясн|угу|лан|ладно</m></intent> <intent key="yes_ok" seam="reply_yes_ok" maxTokens="2"><m>да|ага|ок|окей|норм|пон|ясн|угу|лан|ладно</m></intent>
</intents> </intents>
@@ -6,7 +6,7 @@
Pool lookup tries "pool.personality" first, then "pool". Pool lookup tries "pool.personality" first, then "pool".
When a phrase key is missing here, the flat seam with the same key is used. When a phrase key is missing here, the flat seam with the same key is used.
--> -->
<phrases> <phrases xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerPhrases.xsd">
<phrase key="greeting"> <phrase key="greeting">
<part pool="hello" chance="100" skip="ganker" /> <part pool="hello" chance="100" skip="ganker" />
<part pool="taunt" chance="70" only="ganker" /> <part pool="taunt" chance="70" only="ganker" />
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<pools> <pools xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerPools.xsd">
<pool key="threat"> <pool key="threat">
<line>#threat_verb#[ #threat_when#]</line> <line>#threat_verb#[ #threat_when#]</line>
<line>#threat_verb#[, #threat_when#]</line> <line>#threat_verb#[, #threat_when#]</line>
File diff suppressed because it is too large Load Diff
@@ -124,14 +124,10 @@ public class ChatGeneral implements IChatHandler
activeChar.sendPacket(cs); activeChar.sendPacket(cs);
// Headless bots nearby may react to general chat. // Headless bots nearby may react to general chat (one addressed by name always does).
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE)) 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()); org.l2jmobius.gameserver.managers.FakePlayerSocial.getInstance().onPlayerSaid(activeChar, text);
if (headless != null)
{
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onLocalChat(activeChar, headless, text);
}
} }
// Fake players may react to nearby general chat. // Fake players may react to nearby general chat.
@@ -0,0 +1,20 @@
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="list">
<xs:complexType>
<xs:sequence>
<xs:element name="fakePlayerChat" maxOccurs="unbounded" minOccurs="0">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute type="xs:string" name="fpcName" use="required" />
<xs:attribute type="xs:string" name="searchMethod" use="required" />
<xs:attribute type="xs:string" name="searchText" use="required" />
<xs:attribute type="xs:string" name="answers" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- Permissive schema: the file is validated for well-formedness only, the loader checks the content. -->
<xs:element name="combat">
<xs:complexType>
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- Permissive schema: the file is validated for well-formedness only, the loader checks the content. -->
<xs:element name="intents">
<xs:complexType>
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- Permissive schema: the file is validated for well-formedness only, the loader checks the content. -->
<xs:element name="phrases">
<xs:complexType>
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- Permissive schema: the file is validated for well-formedness only, the loader checks the content. -->
<xs:element name="pools">
<xs:complexType>
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- Permissive schema: the file is validated for well-formedness only, the loader checks the content. -->
<xs:element name="list">
<xs:complexType>
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -130,6 +130,8 @@ import org.l2jmobius.gameserver.managers.FakePlayerDashboard;
import org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager; import org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager;
import org.l2jmobius.gameserver.managers.FakePlayerIntentParser; import org.l2jmobius.gameserver.managers.FakePlayerIntentParser;
import org.l2jmobius.gameserver.managers.FakePlayerRumorManager; 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.FakePlayerProgressionManager;
import org.l2jmobius.gameserver.managers.FishingChampionshipManager; import org.l2jmobius.gameserver.managers.FishingChampionshipManager;
import org.l2jmobius.gameserver.managers.GlobalVariablesManager; import org.l2jmobius.gameserver.managers.GlobalVariablesManager;
@@ -355,6 +357,8 @@ public class GameServer
SpawnData.getInstance(); SpawnData.getInstance();
FakePlayerProgressionManager.getInstance(); FakePlayerProgressionManager.getInstance();
FakePlayerHeadlessManager.getInstance(); FakePlayerHeadlessManager.getInstance();
FakePlayerSiegeManager.getInstance();
FakePlayerTelemetry.getInstance();
DayNightSpawnManager.getInstance().trim().notifyChangeMode(); DayNightSpawnManager.getInstance().trim().notifyChangeMode();
DimensionalRiftManager.getInstance(); DimensionalRiftManager.getInstance();
RaidBossSpawnManager.getInstance(); RaidBossSpawnManager.getInstance();
@@ -60,6 +60,15 @@ public class FakePlayersConfig
public static int FAKE_PLAYER_TRADE_COOLDOWN; public static int FAKE_PLAYER_TRADE_COOLDOWN;
public static int FAKE_PLAYER_TRADE_SHOUT_INTERVAL; public static int FAKE_PLAYER_TRADE_SHOUT_INTERVAL;
public static int FAKE_PLAYER_TRADE_CHANCE; 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() public static void load()
{ {
@@ -92,5 +101,14 @@ public class FakePlayersConfig
FAKE_PLAYER_TRADE_COOLDOWN = config.getInt("FakePlayerTradeCooldown", 120); FAKE_PLAYER_TRADE_COOLDOWN = config.getInt("FakePlayerTradeCooldown", 120);
FAKE_PLAYER_TRADE_SHOUT_INTERVAL = config.getInt("FakePlayerTradeShoutInterval", 180); FAKE_PLAYER_TRADE_SHOUT_INTERVAL = config.getInt("FakePlayerTradeShoutInterval", 180);
FAKE_PLAYER_TRADE_CHANCE = config.getInt("FakePlayerTradeChance", 30); 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);
} }
} }
@@ -331,6 +331,16 @@ public class Attackable extends Npc
FakePlayerProgressionManager.getInstance().onBotKilled(this, killer.asPlayer()); 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. // Delayed notification.
if (killer != null) if (killer != null)
{ {
@@ -5032,10 +5032,10 @@ public class Player extends Playable
return false; return false;
} }
// Headless bot memory: remember the player who killed it. // Headless bot: memory of the killer, death cry, rumor seed, telemetry.
if (_headlessBot && (killer != null) && (killer.asPlayer() != null) && (killer != this)) 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()) if (isMounted())
@@ -43,6 +43,7 @@ public class FakePlayerBotContext
volatile String event = ""; volatile String event = "";
volatile long eventUntil; volatile long eventUntil;
volatile String boss = ""; volatile String boss = "";
final Map<String, String> grounding = new ConcurrentHashMap<>();
} }
private static final Map<Integer, Context> CONTEXTS = new ConcurrentHashMap<>(); private static final Map<Integer, Context> 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. * @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<String, String> slots(int botId) public static Map<String, String> slots(int botId)
{ {
final Map<String, String> slots = new HashMap<>(1); final Map<String, String> slots = new HashMap<>(4);
final Context context = (botId > 0) ? CONTEXTS.get(botId) : null; 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); slots.put("boss", context.boss);
} }
@@ -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);
}
}
@@ -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<Integer, Long> 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();
}
}
@@ -20,32 +20,41 @@
*/ */
package org.l2jmobius.gameserver.managers; package org.l2jmobius.gameserver.managers;
import java.util.List;
import org.l2jmobius.commons.util.Rnd; import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.entity.Location;
import org.l2jmobius.gameserver.entity.World; import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.WorldObject; 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.Creature;
import org.l2jmobius.gameserver.entity.actor.Player; import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.actor.instance.Monster; import org.l2jmobius.gameserver.entity.actor.instance.Monster;
import org.l2jmobius.gameserver.entity.groups.Party; import org.l2jmobius.gameserver.entity.groups.Party;
import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType; 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.BotSkill;
import org.l2jmobius.gameserver.managers.FakePlayerCombatData.Profile; import org.l2jmobius.gameserver.managers.FakePlayerCombatData.Profile;
import org.l2jmobius.gameserver.mechanics.skill.Skill; import org.l2jmobius.gameserver.mechanics.skill.Skill;
/** /**
* Game sense for headless bots: what to do right now. * Game sense for headless bots: what to do right now.
* Priority order (first match wins), inspired by how real farming bots are built: * Priority order (first match wins), the way real farming bots are built:
* survival, upkeep, party role, combat, loot, farming. * survival, upkeep (rest / self buffs), party role, combat rotation, sweeping,
* loot, farming (target choice with a leash to the spot).
* @author Mobius SPP * @author Mobius SPP
*/ */
public class FakePlayerBrain 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 MELEE_RANGE = 80;
private static final int BOW_RANGE = 500; 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. * Runtime combat memory of a bot.
*/ */
@@ -55,40 +64,46 @@ public class FakePlayerBrain
public long restingUntil = 0; public long restingUntil = 0;
public long lastPotion = 0; public long lastPotion = 0;
public long fleeingUntil = 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. * Tries to act. Returns true when the brain took control this tick.
* @param bot the headless bot. * @param bot the headless bot.
* @param state its combat state. * @param botState its state.
* @param aggressive true when the bot may pick fights on its own. * @param hunts true when the bot may pick monsters on its own.
* @return true when an action was taken. * @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 long now = System.currentTimeMillis();
final CombatState state = botState.combat;
final Profile profile = FakePlayerCombatData.getInstance().getProfile(bot.getPlayerClass().getId()); final Profile profile = FakePlayerCombatData.getInstance().getProfile(bot.getPlayerClass().getId());
final int hpPercent = (int) ((bot.getCurrentHp() * 100) / Math.max(1, bot.getMaxHp())); final int hpPercent = (int) ((bot.getCurrentHp() * 100) / Math.max(1, bot.getMaxHp()));
final int mpPercent = (int) ((bot.getCurrentMp() * 100) / Math.max(1, bot.getMaxMp())); final int mpPercent = (int) ((bot.getCurrentMp() * 100) / Math.max(1, bot.getMaxMp()));
// 1. Survival: heal, drink, run away. // 1. Survival: heal, drink, run away.
if (hpPercent < 55) if (hpPercent < 55)
{ {
if (castHeal(bot, bot, profile, hpPercent)) if (castHeal(bot, bot, profile, hpPercent, botState))
{ {
return true; return true;
} }
if ((hpPercent < 40) && ((now - state.lastPotion) > 15000) && drinkPotion(bot)) if ((hpPercent < 45) && ((now - state.lastPotion) > 8000) && drinkPotion(bot))
{ {
state.lastPotion = now; state.lastPotion = now;
botState.action("potion");
return true; return true;
} }
if (hpPercent < 25) if (hpPercent < 25)
{ {
final Creature threat = nearestThreat(bot); final Creature threat = nearestThreat(bot);
if (threat != null) if ((threat != null) && (threat.isMonster() || (threat.getLevel() > (bot.getLevel() + 3))))
{ {
state.fleeingUntil = now + 12000; state.fleeingUntil = now + 12000;
botState.action("flee");
flee(bot, threat); flee(bot, threat);
return true; return true;
} }
@@ -98,7 +113,7 @@ public class FakePlayerBrain
{ {
return true; // Keep running. return true; // Keep running.
} }
// 2. Upkeep: rest when safe and low, refresh self buffs. // 2. Upkeep: rest when safe and low, refresh self buffs.
if (state.restingUntil > now) if (state.restingUntil > now)
{ {
@@ -107,23 +122,31 @@ public class FakePlayerBrain
state.restingUntil = 0; state.restingUntil = 0;
bot.standUp(); bot.standUp();
} }
else if (nearestThreat(bot) != null)
{
state.restingUntil = 0;
bot.standUp();
return false;
}
return true; 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; state.restingUntil = now + 120000;
botState.rests.incrementAndGet();
botState.action("rest");
bot.sitDown(); bot.sitDown();
return true; return true;
} }
if ((now > state.nextSelfBuff) && !bot.isInCombat()) if ((now > state.nextSelfBuff) && !bot.isInCombat())
{ {
state.nextSelfBuff = now + 180000; state.nextSelfBuff = now + 180000;
if (castSelfBuffs(bot, profile)) if (castSelfBuffs(bot, profile, botState))
{ {
return true; return true;
} }
} }
// 3. Party role: healers keep the party alive. // 3. Party role: healers keep the party alive.
final Party party = bot.getParty(); final Party party = bot.getParty();
if ((party != null) && "healer".equals(profile.role)) if ((party != null) && "healer".equals(profile.role))
@@ -139,58 +162,175 @@ public class FakePlayerBrain
wounded = member; 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; return true;
} }
} }
// 4. Combat: use skills by condition, keep proper distance. // 4. Combat: use skills by condition, keep proper distance.
final WorldObject target = bot.getTarget(); 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())); 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; return true;
} }
keepDistance(bot, victim, profile); keepDistance(bot, victim, profile);
return true; return true;
} }
// 5. Farming: pick a sensible monster. // 4b. Under attack by something we are not fighting: fight back (or run from a monster far above us).
if (aggressive && !bot.isInCombat()) 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); final Monster prey = pickPrey(bot);
if (prey != null) if (prey != null)
{ {
bot.setTarget(prey); bot.setTarget(prey);
bot.setRunning();
bot.getAI().setIntentionAttack(prey); bot.getAI().setIntentionAttack(prey);
botState.action("hunt " + prey.getName());
return true; 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; 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. * @param bot the bot.
* @return the prey or null. * @return the prey or null.
*/ */
private static Monster pickPrey(Player bot) private static Monster pickPrey(Player bot)
{ {
final int level = bot.getLevel(); final int level = bot.getLevel();
return World.getNearestVisibleObjectInRange(bot, Monster.class, 1100, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() // final java.util.List<Monster> candidates = World.getVisibleObjectsInRange(bot, Monster.class, PREY_RANGE, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() //
&& (Math.abs(monster.getLevel() - level) <= 8) // не бьём слишком сильных и не тратим время на слабых && (monster.getLevel() <= (level + 1)) && (monster.getLevel() >= (level - 9)) // не бьём слишком сильных и не тратим время на серых
&& (Math.abs(monster.getZ() - bot.getZ()) < 200) // не пытаемся достать тех, кто выше/ниже && (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) 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) private static void flee(Player bot, Creature threat)
{ {
final int dx = bot.getX() - threat.getX(); 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 targetX = bot.getX() + (int) ((dx / length) * 900);
final int targetY = bot.getY() + (int) ((dy / length) * 900); final int targetY = bot.getY() + (int) ((dy / length) * 900);
bot.setRunning(); 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) 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) if (potion == null)
{ {
return false; return false;
} }
bot.useEquippableItem(potion, false); // Potions are used through their item handler, like a click in the inventory.
return true; 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) for (BotSkill entry : profile.self)
{ {
@@ -231,12 +382,17 @@ public class FakePlayerBrain
continue; continue;
} }
bot.setTarget(bot); 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; 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) for (BotSkill entry : profile.heal)
{ {
@@ -250,12 +406,37 @@ public class FakePlayerBrain
continue; continue;
} }
bot.setTarget(patient); 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; 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) for (BotSkill entry : profile.offensive)
{ {
@@ -275,12 +456,13 @@ public class FakePlayerBrain
bot.setTarget(victim); bot.setTarget(victim);
if (bot.useMagic(skill, true, false)) if (bot.useMagic(skill, true, false))
{ {
botState.skillsCast.incrementAndGet();
return true; return true;
} }
} }
return false; return false;
} }
/** /**
* Archers and mages should not stand in melee; fighters should close in. * Archers and mages should not stand in melee; fighters should close in.
* @param bot the bot. * @param bot the bot.
@@ -289,31 +471,23 @@ public class FakePlayerBrain
*/ */
private static void keepDistance(Player bot, Creature victim, Profile profile) 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 boolean ranged = "archer".equals(profile.role) || "mage".equals(profile.role) || "healer".equals(profile.role);
final double distance = bot.calculateDistance2D(victim); final double distance = bot.calculateDistance2D(victim);
if (ranged) if (ranged)
{ {
if (distance < 250) // отходим, чтобы стрелять if ((distance > BOW_RANGE) || (!bot.isAttackingNow() && !bot.isCastingNow()))
{
final int dx = bot.getX() - victim.getX();
final int dy = bot.getY() - victim.getY();
final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy)));
bot.setRunning();
bot.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(bot.getX() + (int) ((dx / length) * 400), bot.getY() + (int) ((dy / length) * 400), bot.getZ()));
return;
}
if (distance > BOW_RANGE)
{ {
bot.getAI().setIntentionAttack(victim); bot.getAI().setIntentionAttack(victim);
} }
return; return;
} }
if (distance > MELEE_RANGE) if ((distance > MELEE_RANGE) || !bot.isAttackingNow())
{ {
bot.getAI().setIntentionAttack(victim); bot.getAI().setIntentionAttack(victim);
} }
} }
/** /**
* @param bot the bot. * @param bot the bot.
* @return short human readable description of what the brain would do now. * @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); final Skill skill = bot.getKnownSkill(entry.id);
sb.append(entry.id).append(skill != null ? "+" : "-").append(' '); 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 (+ есть у бота, - не выучен)"); sb.append("\n (+ есть у бота, - не выучен)");
return sb.toString(); return sb.toString();
} }
private FakePlayerBrain() private FakePlayerBrain()
{ {
} }
@@ -215,7 +215,7 @@ public class FakePlayerChatManager implements IXmlReader
return; return;
} }
final Understanding understanding = understand(player, npc, message); final Understanding understanding = understand(player, npc, message, false);
if ((understanding != null) && understanding.silent) if ((understanding != null) && understanding.silent)
{ {
return; return;
@@ -529,234 +529,23 @@ public class FakePlayerChatManager implements IXmlReader
* @param message the raw message. * @param message the raw message.
* @return understanding or null when no intent matched. * @return understanding or null when no intent matched.
*/ */
// --- Stance memory: a decision (duel/party/help/insult mode) sticks to the pair bot+player. --- public Understanding understand(Player player, Npc npc, String message)
private static final long STANCE_TTL = 15 * 60 * 1000;
private static class Stance
{ {
final String seam; return understand(player, npc, message, true);
final String text;
final long timestamp = System.currentTimeMillis();
Stance(String seam, String text)
{
this.seam = seam;
this.text = text;
}
} }
private static final Map<String, Stance> STANCES = new java.util.concurrent.ConcurrentHashMap<>(); /**
private static final Map<String, Long> FIRST_CONTACT = new java.util.concurrent.ConcurrentHashMap<>(); * Understands an incoming message through the shared understanding layer
private static final java.util.Set<String> STANCE_INTENTS = java.util.Set.of("duel", "ask_party", "ask_help", "insult"); * (intents, memory facts, stance memory, silence, caps, mirroring, context).
* @param player the speaking player.
public Understanding understand(org.l2jmobius.gameserver.entity.actor.Player player, Npc npc, String message) * @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()); return FakePlayerUnderstanding.understand(FakePlayerBotRef.ofNpc(npc), player, message, privateChannel);
final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
final FakePlayerIdiolect.Mirror mirror = FakePlayerIdiolect.Mirror.of(message);
final String personality = npc.getTemplate().getParameters().getString("fpcPersonality", "neutral");
final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(botId, player.getName());
final String asker = (memory == null) ? "stranger" : ((memory.kills > 0) ? "killed_me" : ((memory.helps > 0) ? "helped_me" : "stranger"));
final Map<String, String> facts = new HashMap<>();
facts.put("personality", personality);
facts.put("asker", asker);
final Map<String, String> slots = new HashMap<>();
slots.put("player", player.getName());
final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
final long now = System.currentTimeMillis();
final String contactKey = botId + "|" + player.getName();
// Silence: only a stranger's FIRST message can be ignored, and never a trade question.
final Long firstSeen = FIRST_CONTACT.putIfAbsent(contactKey, now);
if ((firstSeen == null) && "stranger".equals(asker) && ((intent == null) || !"ask_price".equals(intent.key)) && (Rnd.get(1000) < (int) (idiolect.silence * 1000)))
{
final Understanding ignored = new Understanding();
ignored.silent = true;
ignored.llmHint = "";
return ignored;
}
if (FIRST_CONTACT.size() > 5000)
{
FIRST_CONTACT.entrySet().removeIf(entry -> (now - entry.getValue()) > STANCE_TTL);
}
// Caps-lock: sometimes the reaction is to the shouting itself.
int letters = 0;
int upper = 0;
for (int i = 0; i < message.length(); i++)
{
final char ch = message.charAt(i);
if (Character.isLetter(ch))
{
letters++;
if (Character.isUpperCase(ch))
{
upper++;
}
}
}
if ((letters > 3) && (upper > (letters * 0.7)) && (Rnd.get(100) < 35))
{
final String capsReply = FakePlayerChatLines.getInstance().speakPrivate(botId, "reply_caps", facts, slots, mirror);
if (capsReply != null)
{
final Understanding result = new Understanding();
result.intentKey = "caps";
result.llmHint = "он пишет капсом";
result.responseLine = capsReply;
return result;
}
}
if (intent == null)
{
return null;
}
final Understanding result = new Understanding();
result.intentKey = intent.key;
result.llmHint = INTENT_HINTS.getOrDefault(intent.key, "");
String seam = intent.seam;
// Stance memory: a repeated question gets the same position, not a new roll.
final String stanceKey = contactKey + "|" + intent.key;
Stance stance = STANCE_INTENTS.contains(intent.key) ? STANCES.get(stanceKey) : null;
if ((stance != null) && ((now - stance.timestamp) > STANCE_TTL))
{
STANCES.remove(stanceKey);
stance = null;
}
if (stance != null)
{
if ("insult".equals(intent.key))
{
seam = stance.seam; // Same reaction mode, fresh wording.
}
else
{
final Map<String, String> againSlots = new HashMap<>(slots);
againSlots.put("prev", stance.text);
final String again = FakePlayerChatLines.getInstance().speakPrivate(botId, "reply_again", facts, againSlots, mirror);
result.responseLine = (again != null) ? again : stance.text;
return result;
}
}
switch (intent.key)
{
case "insult":
{
FakePlayerMemoryManager.getInstance().adjustScore(botId, player.getName(), -1);
if (stance == null)
{
final boolean aggressive = "ganker".equals(personality) || "pkk".equals(personality) || (Rnd.get(100) < 50);
seam = aggressive ? "reply_insult_aggro" : "reply_insult_soft";
}
if ("ganker".equals(personality) && (npc instanceof org.l2jmobius.gameserver.entity.actor.Attackable attackable) && (npc.calculateDistance2D(player) < 2000))
{
attackable.addDamageHate(player, 0, 500);
npc.setRunning();
npc.getAI().setIntentionAttack(player);
}
break;
}
case "greeting":
{
if ("killed_me".equals(asker) && (Rnd.get(100) < 60))
{
seam = "revenge_meet";
}
else if ("helped_me".equals(asker) && (Rnd.get(100) < 60))
{
seam = "friendly_meet";
}
break;
}
case "thanks":
{
FakePlayerMemoryManager.getInstance().adjustScore(botId, player.getName(), 1);
break;
}
case "ask_party":
{
final boolean willing = !"ganker".equals(personality) && !npc.isInCombat() && (npc.calculateDistance2D(player) < 2500);
if (willing)
{
FakePlayerAiTaskManager.getInstance().startCompanion(npc, player, 15);
seam = "party_accept";
}
break;
}
case "yes_ok":
{
if (FakePlayerAiTaskManager.getInstance().hasPendingInvite(npc, player))
{
FakePlayerAiTaskManager.getInstance().startCompanion(npc, player, 15);
seam = "party_accept";
}
break;
}
case "ask_help":
{
final boolean willing = (!"ganker".equals(personality) || "helped_me".equals(asker)) && !npc.isInCombat() && (npc.calculateDistance2D(player) < 3000) && !"killed_me".equals(asker);
seam = willing ? "reply_help_yes" : "reply_help_no";
if (willing && (npc instanceof org.l2jmobius.gameserver.entity.actor.Attackable attackable))
{
final org.l2jmobius.gameserver.entity.WorldObject playerTarget = player.getTarget();
if ((playerTarget instanceof org.l2jmobius.gameserver.entity.actor.instance.Monster monster) && !monster.isDead())
{
attackable.addDamageHate(monster, 0, 200);
npc.setRunning();
npc.getAI().setIntentionAttack(monster);
}
else
{
npc.setRunning();
npc.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(player.getX(), player.getY(), player.getZ()));
}
}
break;
}
case "ask_rumors":
{
final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(botId);
if (story != null)
{
result.responseLine = "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story);
}
break;
}
case "ask_where":
{
final String zone = FakePlayerProgressionManager.getInstance().getZoneName(npc.getObjectId());
slots.put("zone", zone.isEmpty() ? "поле" : zone);
break;
}
case "ask_level":
{
final int level = FakePlayerProgressionManager.getInstance().getLevel(npc.getObjectId());
slots.put("level", String.valueOf(level > 0 ? level : npc.getTemplate().getLevel()));
break;
}
}
if ((result.responseLine == null) && !seam.isEmpty())
{
result.responseLine = FakePlayerChatLines.getInstance().speakPrivate(botId, seam, facts, slots, mirror);
}
// A fresh decision on duel/party/help/insult becomes the bot's stance.
if ((stance == null) && (result.responseLine != null) && STANCE_INTENTS.contains(intent.key))
{
STANCES.put(stanceKey, new Stance(seam, result.responseLine));
if (STANCES.size() > 5000)
{
STANCES.entrySet().removeIf(entry -> (now - entry.getValue().timestamp) > STANCE_TTL);
}
}
return result;
} }
private Npc findNpc(String fpcName) private Npc findNpc(String fpcName)
@@ -69,6 +69,8 @@ public class FakePlayerDashboard
respond(exchange, "application/json", merged); respond(exchange, "application/json", merged);
}); });
server.createContext("/api/seams", exchange -> respond(exchange, "application/json", seamsJson())); 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/phrase", exchange -> respond(exchange, "application/json", phraseJson(exchange)));
server.createContext("/api/cmd", exchange -> respond(exchange, "application/json", cmdJson(exchange))); server.createContext("/api/cmd", exchange -> respond(exchange, "application/json", cmdJson(exchange)));
server.createContext("/api/test", exchange -> respond(exchange, "application/json", testJson(exchange))); server.createContext("/api/test", exchange -> respond(exchange, "application/json", testJson(exchange)));
@@ -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<Item> 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<PlayerClass> 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<Item> storeGoods(Player bot)
{
final List<Item> 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<String, String> storeSlots(List<Item> 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<Integer, int[]> 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();
}
}
@@ -48,13 +48,15 @@ public class FakePlayerHeardManager
private static final int PICKUP_CHANCE = 10; // Percent. private static final int PICKUP_CHANCE = 10; // Percent.
private static final int GRATS_CHANCE = 12; // Percent, per neighbour. private static final int GRATS_CHANCE = 12; // Percent, per neighbour.
private static final int GRATS_MAX = 2; 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 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 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 static final Pattern LEVELUP = Pattern.compile(".*\\b\\d{1,2}\\b.*", Pattern.UNICODE_CHARACTER_CLASS);
private final AtomicLong _lastPickup = new AtomicLong(); private final AtomicLong _lastPickup = new AtomicLong();
private final Map<Integer, Long> _listenerCooldown = new java.util.concurrent.ConcurrentHashMap<>();
protected FakePlayerHeardManager() protected FakePlayerHeardManager()
{ {
@@ -101,7 +103,7 @@ public class FakePlayerHeardManager
// The seam is the ground truth; heuristics only cover seamless (LLM) lines. // The seam is the ground truth; heuristics only cover seamless (LLM) lines.
final boolean known = (seam != null) && !seam.isEmpty(); final boolean known = (seam != null) && !seam.isEmpty();
final boolean levelup = known ? "levelup".equals(seam) : isLevelupLine(text); 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) if (!levelup && commercial)
{ {
return; // Nobody "agrees" with an advertisement (or a pickup - depth 1). return; // Nobody "agrees" with an advertisement (or a pickup - depth 1).
@@ -124,14 +126,14 @@ public class FakePlayerHeardManager
final List<Creature> listeners = new ArrayList<>(); final List<Creature> listeners = new ArrayList<>();
for (Npc npc : World.getVisibleObjectsInRange(speaker, Npc.class, HEAR_RANGE)) 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); listeners.add(npc);
} }
} }
for (Player player : World.getVisibleObjectsInRange(speaker, Player.class, HEAR_RANGE)) 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); listeners.add(player);
} }
@@ -163,8 +165,30 @@ public class FakePlayerHeardManager
} }
final Creature listener = listeners.get(Rnd.get(listeners.size())); 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 boolean question = isQuestionLine(text);
final Map<String, String> slots = new HashMap<>(1); final Map<String, String> 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 String[] words = text.split("\\s+");
final StringBuilder echo = new StringBuilder(); final StringBuilder echo = new StringBuilder();
for (int i = 0; (i < words.length) && (i < 3); i++) for (int i = 0; (i < words.length) && (i < 3); i++)
@@ -175,8 +199,7 @@ public class FakePlayerHeardManager
} }
echo.append(words[i]); echo.append(words[i]);
} }
slots.put("msg", echo.toString()); return echo.toString();
scheduleReply(listener, question ? "overheard_q" : "overheard", slots);
} }
private void scheduleReply(Creature listener, String seam, Map<String, String> slots) private void scheduleReply(Creature listener, String seam, Map<String, String> slots)
@@ -173,21 +173,31 @@ public class FakePlayerIntentParser implements IXmlReader
{ {
continue; continue;
} }
// Each group needs its own token: "слышь" alone must not satisfy both halves of "что нового".
final boolean[] used = new boolean[tokens.length];
boolean all = true; boolean all = true;
for (String[] group : intent.groups) for (String[] group : intent.groups)
{ {
boolean any = false; 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; continue;
} }
for (String stem : group) for (String stem : group)
{ {
if (token.startsWith(stem) || ((stem.length() > 3) && fuzzyPrefix(token, stem))) if (matches(token, stem))
{ {
any = true; 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; break;
} }
} }
@@ -210,6 +220,26 @@ public class FakePlayerIntentParser implements IXmlReader
return null; 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 * Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein
* distance 1 of the stem ("помгите" matches stem "помо"). * distance 1 of the stem ("помгите" matches stem "помо").
@@ -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() public static FakePlayerProgressionManager getInstance()
{ {
return SingletonHolder.INSTANCE; return SingletonHolder.INSTANCE;
@@ -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:
* <ul>
* <li>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);</li>
* <li>when the siege starts, attackers gather outside the outer gate and defenders inside by the holy artifact;</li>
* <li>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;</li>
* <li>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.</li>
* </ul>
* 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<Integer> attackerClans = new HashSet<>();
final Set<Integer> defenderClans = new HashSet<>();
final List<Player> participants = new ArrayList<>();
final Map<Integer, Long> lastCry = new HashMap<>();
boolean engraveAnnounced;
}
private final Map<Integer, Battle> _battles = new ConcurrentHashMap<>();
private final Map<Integer, Long> _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<Integer, List<Player>> byClan = membersByClan();
final List<Integer> candidates = new ArrayList<>();
for (Map.Entry<Integer, List<Player>> 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<Integer, List<Player>> membersByClan()
{
final Map<Integer, List<Player>> 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<Integer, Integer> 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<Integer> 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<Integer> 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<String, String> 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();
}
}
@@ -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.
* <ul>
* <li>personality triggers: gankers hunt players, pk hunters hunt flagged/red ones, helpers assist, clan wars;</li>
* <li>calls for help when losing a fight, clanmates answer;</li>
* <li>ambient talk near real players (memory, rumors, invitations, party search);</li>
* <li>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;</li>
* <li>real parties between clanmates, party invitations to players;</li>
* <li>grounding slots for every line the bot says: zone, level, town, mob, crowd, last drop.</li>
* </ul>
* @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<Integer, long[]> _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<String, String> 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<String, String> 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<String, String> 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<Player> 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<String, String> 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();
}
}
@@ -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<String, Long> _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<String, Long> totals()
{
final Map<String, Long> 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<String, Long> 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<String, Long> 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<String, Long> 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<String, Long> 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();
}
}
@@ -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<String> 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<String, Stance> STANCES = new ConcurrentHashMap<>();
private static final Map<String, Long> 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<String, String> facts(FakePlayerBotRef bot, Player speaker)
{
final Map<String, String> 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<String, String> slots(FakePlayerBotRef bot, Player speaker)
{
final Map<String, String> 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<String, String> facts = facts(bot, speaker);
final String asker = facts.get("asker");
final Map<String, String> 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<String, String> 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<String, String> facts, Map<String, String> 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<String, String> extraSlots)
{
final Map<String, String> 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<String, String> extraSlots)
{
final Map<String, String> 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;
}
}
+8
View File
@@ -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/"
+184
View File
@@ -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'<seam key="([^"]+)"[^>]*>(.*?)</seam>', lines_xml, re.S):
seams[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', m.group(2), re.S)
pools = {}
for m in re.finditer(r'<pool key="([^"]+)"[^>]*>(.*?)</pool>', pools_xml, re.S):
pools[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', 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'<class id="(\d+)"[^>]*>(.*?)</class>', 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'<skillTree type="classSkillTree" classId="(\d+)"[^>]*>(.*?)</skillTree>', 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()
+26
View File
@@ -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)"
+194
View File
@@ -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'<npc id="(\d+)" level="(\d+)" type="([A-Za-z]+)"', text):
if m.group(3) in ('Monster', 'FeedableBeast', 'Chest', 'FriendlyMob'):
levels[int(m.group(1))] = int(m.group(2))
return levels
point_spawns = []
def load_spawns(datapack, levels):
"""Returns list of (x, y, level, radius) monster spawn blobs."""
blobs = []
for f in (datapack / 'spawns').rglob('*.xml'):
text = f.read_text(encoding='utf-8', errors='replace')
if 'enabled="false"' in text[:300]:
continue
for m in re.finditer(r'<npc id="(\d+)" x="(-?\d+)" y="(-?\d+)" z="(-?\d+)"', text):
level = levels.get(int(m.group(1)))
if level:
blobs.append((int(m.group(2)), int(m.group(3)), level, 0))
point_spawns.append((int(m.group(2)), int(m.group(3)), int(m.group(4)), level))
for g in re.finditer(r'<spawn[^>]*>(.*?)</spawn>', text, re.S):
body = g.group(1)
nodes = [(int(a), int(b)) for a, b in re.findall(r'<node x="(-?\d+)" y="(-?\d+)"', body)]
if not nodes:
continue
cx = sum(n[0] for n in nodes) / len(nodes)
cy = sum(n[1] for n in nodes) / len(nodes)
radius = max(math.hypot(n[0] - cx, n[1] - cy) for n in nodes)
for npc_id, count in re.findall(r'<npc id="(\d+)"(?![^>]*\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'(<zone name="([^"]+)"([^>]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)(</zone>)', text, re.S):
header, name, extra, lo, hi, body, tail = zm.groups()
lo = int(lo)
hi = int(hi)
points = re.findall(r'<point x="(-?\d+)" y="(-?\d+)" z="(-?\d+)" />', 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<zone name="{name}"{extra}minLevel="{band_lo}" maxLevel="{band_hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in pts) + '\t</zone>\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<zone name="{name}"{extra}minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in ranked) + '\t</zone>\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'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', 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'<zone name="([^"]+)"', new_text))
grid = {}
for bx, by, level, radius in blobs:
if (min(holes) - 1) <= level <= (max(holes) + 2) and radius < 2500:
grid.setdefault((int(bx // 4000), int(by // 4000)), []).append((bx, by, level))
zones = {}
for cell, entries in grid.items():
if len(entries) < 12 or cell not in CELL_NAMES:
continue
zones.setdefault(CELL_NAMES[cell], []).extend(entries)
added = ''
for name, entries in zones.items():
if name in existing:
continue
medians = sorted(e[2] for e in entries)
lo = max(1, int(percentile(medians, 0.2)) - 2)
hi = max(lo + 3, int(percentile(medians, 0.9)))
# Points: spawn positions (spread), z from the nearest point spawn with a real z.
points = []
seen = set()
for bx, by, level in entries:
key = (int(bx // 300), int(by // 300))
if key in seen:
continue
seen.add(key)
points.append((int(bx), int(by)))
zpoints = []
for px, py in points[:60]:
best = None
for x, y, z, lv in point_spawns:
d = math.hypot(x - px, y - py)
if best is None or d < best[0]:
best = (d, z)
zpoints.append((px, py, best[1] if best and best[0] < 1500 else -3400))
added += f'\t<zone name="{name}" minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{x}" y="{y}" z="{z}" />\n' for x, y, z in zpoints) + '\t</zone>\n'
print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points')
if added:
new_text = new_text.replace('\t<town name=', added + '\t<town name=', 1)
bands = [(int(a), int(b)) for a, b in re.findall(r'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', 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()
+221
View File
@@ -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<String, String> 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<String, String> samples = new TreeMap<>();
for (String seam : codeSeams)
{
String ok = null;
for (int botId = 1; (botId <= 12) && (ok == null); botId++)
{
final Map<String, String> 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<String, String> 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<String, Integer> 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<String, String> 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);
}
}
+24
View File
@@ -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