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:
@@ -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
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -99,5 +99,35 @@ FakePlayerTradeChance = 30
|
||||
#
|
||||
# Headless players: real Player instances without a client. They can join
|
||||
# real parties (invite them - they auto accept), share exp, fight beside you.
|
||||
# Prototype: they farm near Giran. 0 = disabled.
|
||||
# 0 = disabled.
|
||||
FakePlayerHeadlessCount = 500
|
||||
|
||||
# Who answers first when a player talks to a bot: "seams" - the template engine
|
||||
# (idiolect, memory, stance; the LLM is used only for messages no intent matched),
|
||||
# "llm" - the LLM answers everything, templates are the fallback.
|
||||
FakePlayerLlmPriority = seams
|
||||
|
||||
# Bots talk to each other (greetings, small talk, gossip) when a real player can see it.
|
||||
FakePlayerBotDialogues = True
|
||||
|
||||
# Real economy: sell junk to the merchant, buy shots / arrows / potions with own adena.
|
||||
FakePlayerEconomy = True
|
||||
|
||||
# Bots take their first / second / third profession at 20 / 40 / 76 like players.
|
||||
FakePlayerProfessionChange = True
|
||||
|
||||
# Bot clans register for castle sieges and fight them (attackers break the gates,
|
||||
# the clan leader engraves the artifact; defenders hold it).
|
||||
FakePlayerSieges = True
|
||||
|
||||
# How many bot clans attack each castle siege.
|
||||
FakePlayerSiegeAttackerClans = 2
|
||||
|
||||
# Also besiege castles owned by real players' clans.
|
||||
FakePlayerSiegeAttackPlayerCastles = False
|
||||
|
||||
# Print bot activity totals (kills, exp, loot, trades, chat...) to the log every N minutes. 0 = off.
|
||||
FakePlayerStatsLogMinutes = 10
|
||||
|
||||
# Write every line the bots say (channel, name, seam, text) to the server log.
|
||||
FakePlayerChatLog = False
|
||||
|
||||
+227
@@ -1157,6 +1157,20 @@
|
||||
<line trait="eng">{player}, гг тогда было</line>
|
||||
</seam>
|
||||
<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>#grounded_talk#</line>
|
||||
<line>кто спот застолбил тут</line>
|
||||
@@ -1580,6 +1594,219 @@
|
||||
<line>все на сегодня, бывай</line>
|
||||
<line trait="old">бывай, братва, го дальше</line>
|
||||
</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">
|
||||
<line>#store_goods#[85:, #store_price_word#][45:, #store_mood#]</line>
|
||||
</seam>
|
||||
|
||||
+306
-59
@@ -2,95 +2,342 @@
|
||||
<!--
|
||||
Боевые наборы ботов. Правится без пересборки.
|
||||
|
||||
role - fighter | archer | mage | healer | support (влияет на дистанцию и приоритеты)
|
||||
role - fighter | archer | mage | healer | support | spoiler (влияет на дистанцию и приоритеты;
|
||||
spoiler ещё спойлит цель и свипает труп)
|
||||
offensive - атакующий скилл. priority: меньше = важнее.
|
||||
maxTargetHp - кастовать, только если у цели HP ниже этого % (добивание)
|
||||
minMp - не кастовать, если своего MP меньше этого %
|
||||
self - самобаф/усиление, вешается вне боя
|
||||
heal - лечение: minHp = кастовать, если HP цели/своё ниже этого %
|
||||
Скиллы, которых у бота нет по уровню, молча пропускаются.
|
||||
Скиллы, которых у бота нет по уровню, молча пропускаются. Id сверены с stats/players/skillTrees.
|
||||
Третьи профессии наследуют скиллы вторых, поэтому у них те же наборы.
|
||||
-->
|
||||
<combat>
|
||||
<!-- ===== Бойцы ближнего боя ===== -->
|
||||
<class id="1" role="fighter" name="Warrior">
|
||||
<combat xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerCombat.xsd">
|
||||
<!-- ===== Базовые классы (1-19) ===== -->
|
||||
<class id="0" role="fighter" name="Human Fighter">
|
||||
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" /> <!-- Mortal Blow -->
|
||||
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" /> <!-- Power Strike -->
|
||||
<self id="139" /> <!-- War Cry -->
|
||||
</class>
|
||||
<class id="2" role="fighter" name="Gladiator">
|
||||
<offensive id="261" priority="1" maxTargetHp="100" minMp="20" /> <!-- Triple Sonic Slash -->
|
||||
<offensive id="6" priority="2" maxTargetHp="100" minMp="15" /> <!-- Sonic Blaster -->
|
||||
<offensive id="1" priority="3" maxTargetHp="100" minMp="10" /> <!-- Triple Slash -->
|
||||
<self id="8" /> <!-- Sonic Focus -->
|
||||
<self id="139" />
|
||||
<class id="18" role="fighter" name="Elven Fighter">
|
||||
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
|
||||
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
|
||||
<heal id="58" priority="1" minHp="50" /> <!-- Elemental Heal -->
|
||||
</class>
|
||||
<class id="3" role="fighter" name="Warlord">
|
||||
<offensive id="245" priority="1" maxTargetHp="100" minMp="20" />
|
||||
<offensive id="4" priority="2" maxTargetHp="100" minMp="15" />
|
||||
<self id="139" />
|
||||
<class id="31" role="fighter" name="Dark Fighter">
|
||||
<offensive id="16" priority="1" maxTargetHp="100" minMp="15" />
|
||||
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
|
||||
<offensive id="70" priority="3" maxTargetHp="100" minMp="20" /> <!-- Drain Health -->
|
||||
</class>
|
||||
<class id="4" role="fighter" name="Knight">
|
||||
<offensive id="263" priority="1" maxTargetHp="100" minMp="20" />
|
||||
<offensive id="1" priority="2" maxTargetHp="100" minMp="10" />
|
||||
<self id="98" /> <!-- Shield Fortress -->
|
||||
<class id="44" role="fighter" name="Orc Fighter">
|
||||
<offensive id="29" priority="1" maxTargetHp="100" minMp="15" /> <!-- Iron Punch -->
|
||||
<offensive id="3" priority="2" maxTargetHp="100" minMp="15" />
|
||||
</class>
|
||||
<class id="7" role="fighter" name="Rogue">
|
||||
<offensive id="30" priority="1" maxTargetHp="60" minMp="20" /> <!-- Backstab: добивание -->
|
||||
<offensive id="16" priority="2" maxTargetHp="100" minMp="15" />
|
||||
<self id="111" /> <!-- Rapid Shot -->
|
||||
<class id="53" role="spoiler" name="Dwarven Fighter">
|
||||
</class>
|
||||
<class id="10" role="mage" name="Human Mystic">
|
||||
<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>
|
||||
|
||||
<!-- ===== Лучники ===== -->
|
||||
<!-- ===== Первая профессия (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">
|
||||
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stun Shot -->
|
||||
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" /> <!-- Double Shot -->
|
||||
<self id="111" />
|
||||
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" /> <!-- Stunning Shot -->
|
||||
<offensive id="19" priority="2" maxTargetHp="100" minMp="25" /> <!-- Double Shot -->
|
||||
<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 id="24" role="archer" name="Silver Ranger">
|
||||
<offensive id="101" priority="1" maxTargetHp="100" minMp="20" />
|
||||
<offensive id="343" priority="2" maxTargetHp="100" minMp="25" />
|
||||
<self id="111" />
|
||||
<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" />
|
||||
<heal id="58" priority="1" minHp="50" />
|
||||
<self id="312" />
|
||||
<self id="99" />
|
||||
</class>
|
||||
|
||||
<!-- ===== Маги ===== -->
|
||||
<class id="12" role="mage" name="Sorcerer">
|
||||
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" /> <!-- Wind Strike -->
|
||||
<offensive id="1230" priority="1" maxTargetHp="100" minMp="30" /> <!-- Prominence -->
|
||||
<offensive id="1181" priority="2" maxTargetHp="100" minMp="20" />
|
||||
<self id="1085" /> <!-- Acumen -->
|
||||
</class>
|
||||
<class id="13" role="mage" name="Necromancer">
|
||||
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
|
||||
<offensive id="1234" priority="1" maxTargetHp="100" minMp="30" />
|
||||
<self id="1085" />
|
||||
<class id="102" role="archer" name="Moonlight Sentinel">
|
||||
<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" />
|
||||
<heal id="58" priority="1" minHp="50" />
|
||||
<self id="312" />
|
||||
<self id="99" />
|
||||
</class>
|
||||
<class id="27" role="mage" name="Spellsinger">
|
||||
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
|
||||
<offensive id="1235" priority="1" maxTargetHp="100" minMp="30" /> <!-- Hydro Blast -->
|
||||
<self id="1085" />
|
||||
<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 id="34" role="mage" name="Spellhowler">
|
||||
<offensive id="1177" priority="3" maxTargetHp="100" minMp="10" />
|
||||
<class id="103" role="mage" name="Mystic Muse">
|
||||
<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 -->
|
||||
<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 id="15" role="healer" name="Cleric">
|
||||
<heal id="1217" priority="1" minHp="70" /> <!-- Group Heal? нет - Heal -->
|
||||
<heal id="1011" priority="2" minHp="55" /> <!-- Heal -->
|
||||
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" />
|
||||
<self id="1040" /> <!-- Shield -->
|
||||
<class id="110" role="mage" name="Storm Screamer">
|
||||
<offensive id="1239" priority="1" maxTargetHp="100" minMp="30" />
|
||||
<offensive id="1234" priority="2" maxTargetHp="100" minMp="25" />
|
||||
<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 id="16" role="support" name="Prophet">
|
||||
<self id="1040" />
|
||||
<self id="1085" />
|
||||
<offensive id="1177" priority="5" maxTargetHp="100" minMp="40" />
|
||||
<class id="46" role="fighter" name="Destroyer">
|
||||
<offensive id="190" priority="1" maxTargetHp="100" minMp="20" /> <!-- Fatal Strike -->
|
||||
<offensive id="260" priority="2" maxTargetHp="100" minMp="20" /> <!-- Hammer Crush -->
|
||||
<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>
|
||||
|
||||
<!-- Значения по умолчанию, если класса нет в списке -->
|
||||
<default role="fighter">
|
||||
<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>
|
||||
</combat>
|
||||
|
||||
+11
-6
@@ -2,19 +2,24 @@
|
||||
<!-- Intent lexicon: an intent matches when EVERY <m> group has at least one
|
||||
alternative that prefix-matches a token of the normalized message.
|
||||
maxTokens limits the intent to short messages. Order = priority. -->
|
||||
<intents>
|
||||
<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="insult" seam=""><m>лох|лош|нуб|дурак|дура|дебил|идиот|туп|мусор|чмо|клоун|днище|бомж|крыса|слаб|отстой|пшел|пшла|заткни</m></intent>
|
||||
<intent key="thanks" seam="reply_thanks"><m>спс|спасиб|пасиб|благодар|сенкс|thx|ty|респект|красав</m></intent>
|
||||
<intent key="duel" seam="reply_duel"><m>дуэль|дюль|1в1|1x1|1х1|соло|выйдем|стыкнемся|пвп</m></intent>
|
||||
<intent key="ask_help" seam=""><m>помо|хелп|помощ|хиль|отхил|хил|рес|ресни|спаси|выруч|бафни|баф|подсоб</m></intent>
|
||||
<intent key="duel" seam="reply_duel"><m>дуэль|дюль|1в1|1x1|1х1|выйдем|стыкнемся|=пвп|=дуель</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_level" seam="reply_level"><m>какой|скок|сколько|че|чо</m><m>лвл|левел|уровен|лева</m></intent>
|
||||
<intent key="ask_price" seam="reply_price"><m>почем|цена|цену|купи|продай|продаш|скупа|стоит|аден</m></intent>
|
||||
<intent key="ask_rumors" seam="reply_rumor_none"><m>что|чо|че|какие|слыш</m><m>нов|слыш|происход|творится|интересн</m></intent>
|
||||
<intent key="ask_price" seam="reply_price"><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_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="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>
|
||||
</intents>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
Pool lookup tries "pool.personality" first, then "pool".
|
||||
When a phrase key is missing here, the flat seam with the same key is used.
|
||||
-->
|
||||
<phrases>
|
||||
<phrases xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="xsd/FakePlayerPhrases.xsd">
|
||||
<phrase key="greeting">
|
||||
<part pool="hello" chance="100" skip="ganker" />
|
||||
<part pool="taunt" chance="70" only="ganker" />
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<?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">
|
||||
<line>#threat_verb#[ #threat_when#]</line>
|
||||
<line>#threat_verb#[, #threat_when#]</line>
|
||||
|
||||
+1841
-1558
File diff suppressed because it is too large
Load Diff
+3
-7
@@ -124,14 +124,10 @@ public class ChatGeneral implements IChatHandler
|
||||
|
||||
activeChar.sendPacket(cs);
|
||||
|
||||
// Headless bots nearby may react to general chat.
|
||||
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE))
|
||||
// Headless bots nearby may react to general chat (one addressed by name always does).
|
||||
if (FakePlayersConfig.FAKE_PLAYERS_ENABLED && FakePlayersConfig.FAKE_PLAYER_CHAT && !activeChar.isHeadlessBot())
|
||||
{
|
||||
final Player headless = World.getNearestVisibleObjectInRange(activeChar, Player.class, 1250, nearby -> nearby.isHeadlessBot() && !nearby.isDead());
|
||||
if (headless != null)
|
||||
{
|
||||
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onLocalChat(activeChar, headless, text);
|
||||
}
|
||||
org.l2jmobius.gameserver.managers.FakePlayerSocial.getInstance().onPlayerSaid(activeChar, text);
|
||||
}
|
||||
|
||||
// Fake players may react to nearby general chat.
|
||||
|
||||
+20
@@ -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>
|
||||
+12
@@ -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>
|
||||
+12
@@ -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>
|
||||
+12
@@ -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>
|
||||
+12
@@ -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>
|
||||
+12
@@ -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>
|
||||
+4
@@ -130,6 +130,8 @@ import org.l2jmobius.gameserver.managers.FakePlayerDashboard;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerIntentParser;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerRumorManager;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerSiegeManager;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerTelemetry;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerProgressionManager;
|
||||
import org.l2jmobius.gameserver.managers.FishingChampionshipManager;
|
||||
import org.l2jmobius.gameserver.managers.GlobalVariablesManager;
|
||||
@@ -355,6 +357,8 @@ public class GameServer
|
||||
SpawnData.getInstance();
|
||||
FakePlayerProgressionManager.getInstance();
|
||||
FakePlayerHeadlessManager.getInstance();
|
||||
FakePlayerSiegeManager.getInstance();
|
||||
FakePlayerTelemetry.getInstance();
|
||||
DayNightSpawnManager.getInstance().trim().notifyChangeMode();
|
||||
DimensionalRiftManager.getInstance();
|
||||
RaidBossSpawnManager.getInstance();
|
||||
|
||||
+18
@@ -60,6 +60,15 @@ public class FakePlayersConfig
|
||||
public static int FAKE_PLAYER_TRADE_COOLDOWN;
|
||||
public static int FAKE_PLAYER_TRADE_SHOUT_INTERVAL;
|
||||
public static int FAKE_PLAYER_TRADE_CHANCE;
|
||||
public static String FAKE_PLAYER_LLM_PRIORITY;
|
||||
public static boolean FAKE_PLAYER_BOT_DIALOGUES;
|
||||
public static boolean FAKE_PLAYER_ECONOMY;
|
||||
public static boolean FAKE_PLAYER_PROFESSION_CHANGE;
|
||||
public static boolean FAKE_PLAYER_SIEGES;
|
||||
public static int FAKE_PLAYER_SIEGE_ATTACKER_CLANS;
|
||||
public static boolean FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES;
|
||||
public static int FAKE_PLAYER_STATS_LOG_MINUTES;
|
||||
public static boolean FAKE_PLAYER_CHAT_LOG;
|
||||
|
||||
public static void load()
|
||||
{
|
||||
@@ -92,5 +101,14 @@ public class FakePlayersConfig
|
||||
FAKE_PLAYER_TRADE_COOLDOWN = config.getInt("FakePlayerTradeCooldown", 120);
|
||||
FAKE_PLAYER_TRADE_SHOUT_INTERVAL = config.getInt("FakePlayerTradeShoutInterval", 180);
|
||||
FAKE_PLAYER_TRADE_CHANCE = config.getInt("FakePlayerTradeChance", 30);
|
||||
FAKE_PLAYER_LLM_PRIORITY = config.getString("FakePlayerLlmPriority", "seams").trim().toLowerCase();
|
||||
FAKE_PLAYER_BOT_DIALOGUES = config.getBoolean("FakePlayerBotDialogues", true);
|
||||
FAKE_PLAYER_ECONOMY = config.getBoolean("FakePlayerEconomy", true);
|
||||
FAKE_PLAYER_PROFESSION_CHANGE = config.getBoolean("FakePlayerProfessionChange", true);
|
||||
FAKE_PLAYER_SIEGES = config.getBoolean("FakePlayerSieges", true);
|
||||
FAKE_PLAYER_SIEGE_ATTACKER_CLANS = config.getInt("FakePlayerSiegeAttackerClans", 2);
|
||||
FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = config.getBoolean("FakePlayerSiegeAttackPlayerCastles", false);
|
||||
FAKE_PLAYER_STATS_LOG_MINUTES = config.getInt("FakePlayerStatsLogMinutes", 10);
|
||||
FAKE_PLAYER_CHAT_LOG = config.getBoolean("FakePlayerChatLog", false);
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -331,6 +331,16 @@ public class Attackable extends Npc
|
||||
FakePlayerProgressionManager.getInstance().onBotKilled(this, killer.asPlayer());
|
||||
}
|
||||
|
||||
// Headless bot kill counter (proof that bots really farm).
|
||||
if ((killer != null) && !isFakePlayer())
|
||||
{
|
||||
final Player headless = killer.asPlayer();
|
||||
if ((headless != null) && headless.isHeadlessBot())
|
||||
{
|
||||
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onKilledMonster(headless, this);
|
||||
}
|
||||
}
|
||||
|
||||
// Delayed notification.
|
||||
if (killer != null)
|
||||
{
|
||||
|
||||
+3
-3
@@ -5032,10 +5032,10 @@ public class Player extends Playable
|
||||
return false;
|
||||
}
|
||||
|
||||
// Headless bot memory: remember the player who killed it.
|
||||
if (_headlessBot && (killer != null) && (killer.asPlayer() != null) && (killer != this))
|
||||
// Headless bot: memory of the killer, death cry, rumor seed, telemetry.
|
||||
if (_headlessBot && (killer != this))
|
||||
{
|
||||
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onBotKilled(this, killer.asPlayer());
|
||||
org.l2jmobius.gameserver.managers.FakePlayerHeadlessManager.getInstance().onBotDied(this, killer);
|
||||
}
|
||||
|
||||
if (isMounted())
|
||||
|
||||
+43
-3
@@ -43,6 +43,7 @@ public class FakePlayerBotContext
|
||||
volatile String event = "";
|
||||
volatile long eventUntil;
|
||||
volatile String boss = "";
|
||||
final Map<String, String> grounding = 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.
|
||||
* @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)
|
||||
{
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
+263
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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();
|
||||
}
|
||||
}
|
||||
+245
-45
@@ -20,31 +20,40 @@
|
||||
*/
|
||||
package org.l2jmobius.gameserver.managers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.l2jmobius.commons.util.Rnd;
|
||||
import org.l2jmobius.gameserver.entity.Location;
|
||||
import org.l2jmobius.gameserver.entity.World;
|
||||
import org.l2jmobius.gameserver.entity.WorldObject;
|
||||
import org.l2jmobius.gameserver.entity.actor.Attackable;
|
||||
import org.l2jmobius.gameserver.entity.actor.Creature;
|
||||
import org.l2jmobius.gameserver.entity.actor.Player;
|
||||
import org.l2jmobius.gameserver.entity.actor.instance.Monster;
|
||||
import org.l2jmobius.gameserver.entity.groups.Party;
|
||||
import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType;
|
||||
import org.l2jmobius.gameserver.entity.item.instance.Item;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerCombatData.BotSkill;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerCombatData.Profile;
|
||||
import org.l2jmobius.gameserver.mechanics.skill.Skill;
|
||||
|
||||
/**
|
||||
* Game sense for headless bots: what to do right now.
|
||||
* Priority order (first match wins), inspired by how real farming bots are built:
|
||||
* survival, upkeep, party role, combat, loot, farming.
|
||||
* Priority order (first match wins), the way real farming bots are built:
|
||||
* survival, upkeep (rest / self buffs), party role, combat rotation, sweeping,
|
||||
* loot, farming (target choice with a leash to the spot).
|
||||
* @author Mobius SPP
|
||||
*/
|
||||
public class FakePlayerBrain
|
||||
{
|
||||
private static final int HEAL_POTION = 1061; // Healing Potion
|
||||
private static final int HEAL_POTION = 1061;
|
||||
private static final int GREATER_HEAL_POTION = 1539;
|
||||
private static final int SPOIL = 254;
|
||||
private static final int SWEEPER = 42;
|
||||
private static final int MELEE_RANGE = 80;
|
||||
private static final int BOW_RANGE = 500;
|
||||
private static final int PREY_RANGE = 1100;
|
||||
private static final int LOOT_RANGE = 300;
|
||||
private static final int LEASH = 1500; // Wander this far from the spot before walking back.
|
||||
private static final int LEASH_TELEPORT = 7000; // Too far to walk (fell off a cliff, chased someone) - teleport back.
|
||||
|
||||
/**
|
||||
* Runtime combat memory of a bot.
|
||||
@@ -55,18 +64,22 @@ public class FakePlayerBrain
|
||||
public long restingUntil = 0;
|
||||
public long lastPotion = 0;
|
||||
public long fleeingUntil = 0;
|
||||
public int sweepCorpseId = 0;
|
||||
public long nextLeashCheck = 0;
|
||||
public long stuckSince = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to act. Returns true when the brain took control this tick.
|
||||
* @param bot the headless bot.
|
||||
* @param state its combat state.
|
||||
* @param aggressive true when the bot may pick fights on its own.
|
||||
* @param botState its state.
|
||||
* @param hunts true when the bot may pick monsters on its own.
|
||||
* @return true when an action was taken.
|
||||
*/
|
||||
public static boolean act(Player bot, CombatState state, boolean aggressive)
|
||||
public static boolean act(Player bot, FakePlayerBotState botState, boolean hunts)
|
||||
{
|
||||
final long now = System.currentTimeMillis();
|
||||
final CombatState state = botState.combat;
|
||||
final Profile profile = FakePlayerCombatData.getInstance().getProfile(bot.getPlayerClass().getId());
|
||||
final int hpPercent = (int) ((bot.getCurrentHp() * 100) / Math.max(1, bot.getMaxHp()));
|
||||
final int mpPercent = (int) ((bot.getCurrentMp() * 100) / Math.max(1, bot.getMaxMp()));
|
||||
@@ -74,21 +87,23 @@ public class FakePlayerBrain
|
||||
// 1. Survival: heal, drink, run away.
|
||||
if (hpPercent < 55)
|
||||
{
|
||||
if (castHeal(bot, bot, profile, hpPercent))
|
||||
if (castHeal(bot, bot, profile, hpPercent, botState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if ((hpPercent < 40) && ((now - state.lastPotion) > 15000) && drinkPotion(bot))
|
||||
if ((hpPercent < 45) && ((now - state.lastPotion) > 8000) && drinkPotion(bot))
|
||||
{
|
||||
state.lastPotion = now;
|
||||
botState.action("potion");
|
||||
return true;
|
||||
}
|
||||
if (hpPercent < 25)
|
||||
{
|
||||
final Creature threat = nearestThreat(bot);
|
||||
if (threat != null)
|
||||
if ((threat != null) && (threat.isMonster() || (threat.getLevel() > (bot.getLevel() + 3))))
|
||||
{
|
||||
state.fleeingUntil = now + 12000;
|
||||
botState.action("flee");
|
||||
flee(bot, threat);
|
||||
return true;
|
||||
}
|
||||
@@ -107,18 +122,26 @@ public class FakePlayerBrain
|
||||
state.restingUntil = 0;
|
||||
bot.standUp();
|
||||
}
|
||||
else if (nearestThreat(bot) != null)
|
||||
{
|
||||
state.restingUntil = 0;
|
||||
bot.standUp();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!bot.isInCombat() && (nearestThreat(bot) == null) && ((hpPercent < 45) || (mpPercent < 35)))
|
||||
if (!bot.isInCombat() && !bot.isInStoreMode() && (nearestThreat(bot) == null) && ((hpPercent < 45) || (mpPercent < 35)))
|
||||
{
|
||||
state.restingUntil = now + 120000;
|
||||
botState.rests.incrementAndGet();
|
||||
botState.action("rest");
|
||||
bot.sitDown();
|
||||
return true;
|
||||
}
|
||||
if ((now > state.nextSelfBuff) && !bot.isInCombat())
|
||||
{
|
||||
state.nextSelfBuff = now + 180000;
|
||||
if (castSelfBuffs(bot, profile))
|
||||
if (castSelfBuffs(bot, profile, botState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -139,7 +162,7 @@ public class FakePlayerBrain
|
||||
wounded = member;
|
||||
}
|
||||
}
|
||||
if ((wounded != null) && (worst < 75) && castHeal(bot, wounded, profile, worst))
|
||||
if ((wounded != null) && (worst < 75) && castHeal(bot, wounded, profile, worst, botState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -147,10 +170,18 @@ public class FakePlayerBrain
|
||||
|
||||
// 4. Combat: use skills by condition, keep proper distance.
|
||||
final WorldObject target = bot.getTarget();
|
||||
if ((target instanceof Creature victim) && !victim.isDead() && bot.isInCombat())
|
||||
if ((target instanceof Creature victim) && !victim.isDead() && (bot.isInCombat() || bot.isAttackingNow() || (victim.getTarget() == bot)))
|
||||
{
|
||||
final int targetHp = (int) ((victim.getCurrentHp() * 100) / Math.max(1, victim.getMaxHp()));
|
||||
if (castOffensive(bot, victim, profile, targetHp, mpPercent))
|
||||
if ("spoiler".equals(profile.role) && (victim instanceof Attackable attackable) && !attackable.isSpoiled() && (targetHp > 30) && castSkill(bot, victim, SPOIL, botState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if ((victim instanceof Attackable attackable) && attackable.isSpoiled() && (attackable.getSpoilerObjectId() == bot.getObjectId()))
|
||||
{
|
||||
state.sweepCorpseId = victim.getObjectId();
|
||||
}
|
||||
if (castOffensive(bot, victim, profile, targetHp, mpPercent, botState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -158,37 +189,146 @@ public class FakePlayerBrain
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Farming: pick a sensible monster.
|
||||
if (aggressive && !bot.isInCombat())
|
||||
// 4b. Under attack by something we are not fighting: fight back (or run from a monster far above us).
|
||||
final Creature aggressor = nearestThreat(bot);
|
||||
if ((aggressor != null) && (aggressor != target))
|
||||
{
|
||||
if (aggressor.isMonster() && (aggressor.getLevel() > (bot.getLevel() + 7)) && (hpPercent < 70))
|
||||
{
|
||||
state.fleeingUntil = now + 10000;
|
||||
botState.action("flee " + aggressor.getName());
|
||||
flee(bot, aggressor);
|
||||
return true;
|
||||
}
|
||||
bot.setTarget(aggressor);
|
||||
bot.getAI().setIntentionAttack(aggressor);
|
||||
botState.action("fight back " + aggressor.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Sweep the spoiled corpse before it decays.
|
||||
if (state.sweepCorpseId != 0)
|
||||
{
|
||||
final WorldObject corpse = World.findObject(state.sweepCorpseId);
|
||||
state.sweepCorpseId = 0;
|
||||
if ((corpse instanceof Attackable attackable) && attackable.isDead() && attackable.isSweepActive() && (bot.calculateDistance2D(corpse) < 400))
|
||||
{
|
||||
if (castSkill(bot, attackable, SWEEPER, botState))
|
||||
{
|
||||
botState.action("sweep");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Loot: pick up what dropped from our kills (a real inventory, a real store later).
|
||||
if (!bot.isInCombat() && (bot.getInventory().getSize() < 70))
|
||||
{
|
||||
final Item loot = World.getNearestVisibleObjectInRange(bot, Item.class, LOOT_RANGE, item -> item.isSpawned() && ((item.getOwnerId() == 0) || (item.getOwnerId() == bot.getObjectId()) || ((party != null) && (World.findObject(item.getOwnerId()) instanceof Player owner) && party.getMembers().contains(owner))));
|
||||
if (loot != null)
|
||||
{
|
||||
if (bot.calculateDistance2D(loot) > 40)
|
||||
{
|
||||
bot.getAI().setIntentionMoveTo(loot.getLocation());
|
||||
}
|
||||
else
|
||||
{
|
||||
bot.doPickupItem(loot);
|
||||
FakePlayerHeadlessManager.getInstance().onLootPicked(bot, loot);
|
||||
botState.action("loot " + loot.getName());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Farming: pick a sensible monster, stay on the spot. Only when fit for a fight.
|
||||
if (hunts && !bot.isInCombat())
|
||||
{
|
||||
if ((hpPercent < 60) || (mpPercent < 25))
|
||||
{
|
||||
if (nearestThreat(bot) == null)
|
||||
{
|
||||
state.restingUntil = now + 90000;
|
||||
botState.rests.incrementAndGet();
|
||||
botState.action("rest");
|
||||
bot.sitDown();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
final Monster prey = pickPrey(bot);
|
||||
if (prey != null)
|
||||
{
|
||||
bot.setTarget(prey);
|
||||
bot.setRunning();
|
||||
bot.getAI().setIntentionAttack(prey);
|
||||
botState.action("hunt " + prey.getName());
|
||||
return true;
|
||||
}
|
||||
// Nothing worth fighting here: walk back to the spot if we drifted (or teleport if far away).
|
||||
if ((botState.spot != null) && (now > state.nextLeashCheck))
|
||||
{
|
||||
state.nextLeashCheck = now + 15000;
|
||||
final double distance = bot.calculateDistance2D(botState.spot);
|
||||
if (distance > LEASH_TELEPORT)
|
||||
{
|
||||
FakePlayerHeadlessManager.getInstance().teleport(bot, botState.spot.getX() + Rnd.get(-300, 300), botState.spot.getY() + Rnd.get(-300, 300), botState.spot.getZ());
|
||||
botState.action("back to spot (tp)");
|
||||
return true;
|
||||
}
|
||||
if (distance > LEASH)
|
||||
{
|
||||
bot.setRunning();
|
||||
bot.getAI().setIntentionMoveTo(new Location(botState.spot.getX() + Rnd.get(-300, 300), botState.spot.getY() + Rnd.get(-300, 300), botState.spot.getZ()));
|
||||
botState.action("back to spot");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monster worth attacking: close to our level, reachable, not a raid.
|
||||
* Monster worth attacking: close to our level, reachable, not a raid, nobody else's.
|
||||
* @param bot the bot.
|
||||
* @return the prey or null.
|
||||
*/
|
||||
private static Monster pickPrey(Player bot)
|
||||
{
|
||||
final int level = bot.getLevel();
|
||||
return World.getNearestVisibleObjectInRange(bot, Monster.class, 1100, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() //
|
||||
&& (Math.abs(monster.getLevel() - level) <= 8) // не бьём слишком сильных и не тратим время на слабых
|
||||
final java.util.List<Monster> candidates = World.getVisibleObjectsInRange(bot, Monster.class, PREY_RANGE, monster -> !monster.isDead() && !monster.isRaid() && !monster.isRaidMinion() && !monster.isFakePlayer() //
|
||||
&& (monster.getLevel() <= (level + 1)) && (monster.getLevel() >= (level - 9)) // не бьём слишком сильных и не тратим время на серых
|
||||
&& (Math.abs(monster.getZ() - bot.getZ()) < 200) // не пытаемся достать тех, кто выше/ниже
|
||||
&& ((monster.getTarget() == null) || (monster.getTarget() == bot))); // не воруем чужую цель
|
||||
&& ((monster.getTarget() == null) || (monster.getTarget() == bot) || ((bot.getParty() != null) && bot.getParty().getMembers().contains(monster.getTarget())))); // не воруем чужую цель
|
||||
if (candidates.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// A player pulls the lonely one, not the one in the middle of a pack: score = distance + 350 per neighbour.
|
||||
Monster best = null;
|
||||
double bestScore = Double.MAX_VALUE;
|
||||
for (Monster monster : candidates)
|
||||
{
|
||||
int neighbours = 0;
|
||||
for (Monster other : candidates)
|
||||
{
|
||||
if ((other != monster) && (other.calculateDistance2D(monster) < 350))
|
||||
{
|
||||
neighbours++;
|
||||
}
|
||||
}
|
||||
final double score = bot.calculateDistance2D(monster) + (neighbours * 350) + ((monster.getLevel() > level) ? 200 : 0);
|
||||
if (score < bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = monster;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Creature nearestThreat(Player bot)
|
||||
{
|
||||
return World.getNearestVisibleObjectInRange(bot, Creature.class, 600, creature -> !creature.isDead() && (creature.getTarget() == bot) && (creature.isMonster() || creature.isPlayer()));
|
||||
return World.getNearestVisibleObjectInRange(bot, Creature.class, 600, creature -> !creature.isDead() && (creature.getTarget() == bot) && (creature.isMonster() || creature.isPlayer()) && creature.isInCombat());
|
||||
}
|
||||
|
||||
private static void flee(Player bot, Creature threat)
|
||||
@@ -199,25 +339,36 @@ public class FakePlayerBrain
|
||||
final int targetX = bot.getX() + (int) ((dx / length) * 900);
|
||||
final int targetY = bot.getY() + (int) ((dy / length) * 900);
|
||||
bot.setRunning();
|
||||
bot.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(targetX, targetY, bot.getZ()));
|
||||
bot.getAI().setIntentionMoveTo(new Location(targetX, targetY, bot.getZ()));
|
||||
}
|
||||
|
||||
private static boolean drinkPotion(Player bot)
|
||||
{
|
||||
if (bot.getInventory().getItemByItemId(HEAL_POTION) == null)
|
||||
Item potion = bot.getInventory().getItemByItemId(GREATER_HEAL_POTION);
|
||||
if (potion == null)
|
||||
{
|
||||
bot.addItem(ItemProcessType.NONE, HEAL_POTION, 20, bot, false);
|
||||
potion = bot.getInventory().getItemByItemId(HEAL_POTION);
|
||||
}
|
||||
if (potion == null)
|
||||
{
|
||||
// Out of potions in the field: the cheapest one is always in a real player's bag.
|
||||
bot.addItem(ItemProcessType.NONE, HEAL_POTION, 5, bot, false);
|
||||
potion = bot.getInventory().getItemByItemId(HEAL_POTION);
|
||||
}
|
||||
final org.l2jmobius.gameserver.entity.item.instance.Item potion = bot.getInventory().getItemByItemId(HEAL_POTION);
|
||||
if (potion == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bot.useEquippableItem(potion, false);
|
||||
return true;
|
||||
// Potions are used through their item handler, like a click in the inventory.
|
||||
final org.l2jmobius.gameserver.handler.IItemHandler handler = org.l2jmobius.gameserver.handler.ItemHandler.getInstance().getHandler(potion.getEtcItem());
|
||||
if (handler == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return handler.onItemUse(bot, potion, false);
|
||||
}
|
||||
|
||||
private static boolean castSelfBuffs(Player bot, Profile profile)
|
||||
private static boolean castSelfBuffs(Player bot, Profile profile, FakePlayerBotState botState)
|
||||
{
|
||||
for (BotSkill entry : profile.self)
|
||||
{
|
||||
@@ -231,12 +382,17 @@ public class FakePlayerBrain
|
||||
continue;
|
||||
}
|
||||
bot.setTarget(bot);
|
||||
return bot.useMagic(skill, true, false);
|
||||
if (bot.useMagic(skill, true, false))
|
||||
{
|
||||
botState.skillsCast.incrementAndGet();
|
||||
botState.action("buff");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean castHeal(Player bot, Player patient, Profile profile, int patientHp)
|
||||
private static boolean castHeal(Player bot, Player patient, Profile profile, int patientHp, FakePlayerBotState botState)
|
||||
{
|
||||
for (BotSkill entry : profile.heal)
|
||||
{
|
||||
@@ -250,12 +406,37 @@ public class FakePlayerBrain
|
||||
continue;
|
||||
}
|
||||
bot.setTarget(patient);
|
||||
return bot.useMagic(skill, true, false);
|
||||
if (bot.useMagic(skill, true, false))
|
||||
{
|
||||
botState.skillsCast.incrementAndGet();
|
||||
botState.action((patient == bot) ? "heal self" : ("heal " + patient.getName()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean castOffensive(Player bot, Creature victim, Profile profile, int targetHp, int mpPercent)
|
||||
private static boolean castSkill(Player bot, Creature target, int skillId, FakePlayerBotState botState)
|
||||
{
|
||||
final Skill skill = bot.getKnownSkill(skillId);
|
||||
if ((skill == null) || bot.isSkillDisabled(skill) || (bot.getCurrentMp() < skill.getMpConsume()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if ((skill.getCastRange() > 0) && (bot.calculateDistance2D(target) > skill.getCastRange()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bot.setTarget(target);
|
||||
if (bot.useMagic(skill, true, false))
|
||||
{
|
||||
botState.skillsCast.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean castOffensive(Player bot, Creature victim, Profile profile, int targetHp, int mpPercent, FakePlayerBotState botState)
|
||||
{
|
||||
for (BotSkill entry : profile.offensive)
|
||||
{
|
||||
@@ -275,6 +456,7 @@ public class FakePlayerBrain
|
||||
bot.setTarget(victim);
|
||||
if (bot.useMagic(skill, true, false))
|
||||
{
|
||||
botState.skillsCast.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -289,26 +471,18 @@ public class FakePlayerBrain
|
||||
*/
|
||||
private static void keepDistance(Player bot, Creature victim, Profile profile)
|
||||
{
|
||||
// Kiting a monster only trades hits for nothing: mages nuke point blank, archers shoot, fighters close in.
|
||||
final boolean ranged = "archer".equals(profile.role) || "mage".equals(profile.role) || "healer".equals(profile.role);
|
||||
final double distance = bot.calculateDistance2D(victim);
|
||||
if (ranged)
|
||||
{
|
||||
if (distance < 250) // отходим, чтобы стрелять
|
||||
{
|
||||
final int dx = bot.getX() - victim.getX();
|
||||
final int dy = bot.getY() - victim.getY();
|
||||
final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy)));
|
||||
bot.setRunning();
|
||||
bot.getAI().setIntentionMoveTo(new org.l2jmobius.gameserver.entity.Location(bot.getX() + (int) ((dx / length) * 400), bot.getY() + (int) ((dy / length) * 400), bot.getZ()));
|
||||
return;
|
||||
}
|
||||
if (distance > BOW_RANGE)
|
||||
if ((distance > BOW_RANGE) || (!bot.isAttackingNow() && !bot.isCastingNow()))
|
||||
{
|
||||
bot.getAI().setIntentionAttack(victim);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (distance > MELEE_RANGE)
|
||||
if ((distance > MELEE_RANGE) || !bot.isAttackingNow())
|
||||
{
|
||||
bot.getAI().setIntentionAttack(victim);
|
||||
}
|
||||
@@ -338,6 +512,32 @@ public class FakePlayerBrain
|
||||
final Skill skill = bot.getKnownSkill(entry.id);
|
||||
sb.append(entry.id).append(skill != null ? "+" : "-").append(' ');
|
||||
}
|
||||
sb.append("\n самобафы: ");
|
||||
for (BotSkill entry : profile.self)
|
||||
{
|
||||
final Skill skill = bot.getKnownSkill(entry.id);
|
||||
sb.append(entry.id).append(skill != null ? "+" : "-").append(' ');
|
||||
}
|
||||
final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
|
||||
if (state != null)
|
||||
{
|
||||
sb.append("\n действие: ").append(state.lastAction).append(" | спот: ").append(state.zone).append(" (").append((state.spot != null) ? (int) bot.calculateDistance2D(state.spot) : -1).append(" от центра)");
|
||||
final WorldObject target = bot.getTarget();
|
||||
sb.append("\n цель: ").append((target != null) ? target.getName() : "-");
|
||||
if (target instanceof Creature creature)
|
||||
{
|
||||
sb.append(" lvl").append(creature.getLevel()).append(" hp").append((int) (creature.getCurrentHp() * 100 / Math.max(1, creature.getMaxHp()))).append("% dist ").append((int) bot.calculateDistance2D(creature));
|
||||
}
|
||||
sb.append(" | в бою: ").append(bot.isInCombat()).append(" бьёт: ").append(bot.isAttackingNow()).append(" кастует: ").append(bot.isCastingNow()).append(" сидит: ").append(bot.isSitting());
|
||||
final Item weapon = bot.getActiveWeaponInstance();
|
||||
sb.append("\n оружие: ").append((weapon != null) ? weapon.getName() : "кулаки").append(" | автошоты: ").append(bot.getAutoSoulShot());
|
||||
for (int shotId : bot.getAutoSoulShot())
|
||||
{
|
||||
final Item shots = bot.getInventory().getItemByItemId(shotId);
|
||||
sb.append(" x").append((shots != null) ? shots.getCount() : 0);
|
||||
}
|
||||
sb.append(" | бафов: ").append(bot.getEffectList().getBuffCount()).append(" | hp ").append((int) bot.getCurrentHp()).append('/').append(bot.getMaxHp()).append(" p.def ").append((int) bot.getPDef(null)).append(" p.atk ").append((int) bot.getPAtk(null)).append(" m.atk ").append((int) bot.getMAtk(null, null));
|
||||
}
|
||||
sb.append("\n (+ есть у бота, - не выучен)");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
+14
-225
@@ -215,7 +215,7 @@ public class FakePlayerChatManager implements IXmlReader
|
||||
return;
|
||||
}
|
||||
|
||||
final Understanding understanding = understand(player, npc, message);
|
||||
final Understanding understanding = understand(player, npc, message, false);
|
||||
if ((understanding != null) && understanding.silent)
|
||||
{
|
||||
return;
|
||||
@@ -529,234 +529,23 @@ public class FakePlayerChatManager implements IXmlReader
|
||||
* @param message the raw message.
|
||||
* @return understanding or null when no intent matched.
|
||||
*/
|
||||
// --- Stance memory: a decision (duel/party/help/insult mode) sticks to the pair bot+player. ---
|
||||
|
||||
private static final long STANCE_TTL = 15 * 60 * 1000;
|
||||
|
||||
private static class Stance
|
||||
public Understanding understand(Player player, Npc npc, String message)
|
||||
{
|
||||
final String seam;
|
||||
final String text;
|
||||
final long timestamp = System.currentTimeMillis();
|
||||
|
||||
Stance(String seam, String text)
|
||||
{
|
||||
this.seam = seam;
|
||||
this.text = text;
|
||||
}
|
||||
return understand(player, npc, message, true);
|
||||
}
|
||||
|
||||
private static final Map<String, Stance> STANCES = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private static final Map<String, Long> FIRST_CONTACT = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private static final java.util.Set<String> STANCE_INTENTS = java.util.Set.of("duel", "ask_party", "ask_help", "insult");
|
||||
|
||||
public Understanding understand(org.l2jmobius.gameserver.entity.actor.Player player, Npc npc, String message)
|
||||
/**
|
||||
* Understands an incoming message through the shared understanding layer
|
||||
* (intents, memory facts, stance memory, silence, caps, mirroring, context).
|
||||
* @param player the speaking player.
|
||||
* @param npc the addressed bot.
|
||||
* @param message the raw message.
|
||||
* @param privateChannel true for whispers.
|
||||
* @return understanding or null when no intent matched.
|
||||
*/
|
||||
public Understanding understand(Player player, Npc npc, String message, boolean privateChannel)
|
||||
{
|
||||
final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
|
||||
final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
|
||||
final FakePlayerIdiolect.Mirror mirror = FakePlayerIdiolect.Mirror.of(message);
|
||||
final String personality = npc.getTemplate().getParameters().getString("fpcPersonality", "neutral");
|
||||
final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(botId, player.getName());
|
||||
final String asker = (memory == null) ? "stranger" : ((memory.kills > 0) ? "killed_me" : ((memory.helps > 0) ? "helped_me" : "stranger"));
|
||||
final Map<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;
|
||||
return FakePlayerUnderstanding.understand(FakePlayerBotRef.ofNpc(npc), player, message, privateChannel);
|
||||
}
|
||||
|
||||
private Npc findNpc(String fpcName)
|
||||
|
||||
+2
@@ -69,6 +69,8 @@ public class FakePlayerDashboard
|
||||
respond(exchange, "application/json", merged);
|
||||
});
|
||||
server.createContext("/api/seams", exchange -> respond(exchange, "application/json", seamsJson()));
|
||||
server.createContext("/api/stats", exchange -> respond(exchange, "application/json", FakePlayerTelemetry.getInstance().statsJson()));
|
||||
server.createContext("/api/sieges", exchange -> respond(exchange, "text/plain", FakePlayerSiegeManager.getInstance().info()));
|
||||
server.createContext("/api/phrase", exchange -> respond(exchange, "application/json", phraseJson(exchange)));
|
||||
server.createContext("/api/cmd", exchange -> respond(exchange, "application/json", cmdJson(exchange)));
|
||||
server.createContext("/api/test", exchange -> respond(exchange, "application/json", testJson(exchange)));
|
||||
|
||||
+478
@@ -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();
|
||||
}
|
||||
}
|
||||
+902
-677
File diff suppressed because it is too large
Load Diff
+30
-7
@@ -48,13 +48,15 @@ public class FakePlayerHeardManager
|
||||
private static final int PICKUP_CHANCE = 10; // Percent.
|
||||
private static final int GRATS_CHANCE = 12; // Percent, per neighbour.
|
||||
private static final int GRATS_MAX = 2;
|
||||
private static final long GLOBAL_THROTTLE = 8000; // At most one pickup per N ms server-wide.
|
||||
private static final long GLOBAL_THROTTLE = 2500; // At most one pickup per N ms server-wide.
|
||||
private static final long LISTENER_COOLDOWN = 25000; // A bot picks something up at most this often.
|
||||
|
||||
private static final Pattern QUESTION = Pattern.compile("^(кто|где|когда|скок|сколько|почем)\\b.*|.*\\?.*", Pattern.UNICODE_CHARACTER_CLASS);
|
||||
private static final Pattern COMMERCIAL = Pattern.compile(".*(лавка|продаю|продает|скупа|распродажа|налетай|торгую|акци|цены|меняю|покупай|торговец|зацени|ждет (своего )?хозяина|открыл точку|ищу (кп|пати)|лфп|нужн|формирую пати|собран|собираю|стучите|(кп|пати).{0,25}\\bго\\b|\\bго\\b.{0,25}(кп|пати)|за \\d+к|по \\d+к|\\d+ ?кк?\\b|лям|сотк|грейд|соски|шоты|бсое|свитки|кристалл|рецепт|уголь|бижа|банки|дешевле|отдам|вх\\b|под завязку|гк вызову).*", Pattern.UNICODE_CHARACTER_CLASS);
|
||||
private static final Pattern LEVELUP = Pattern.compile(".*\\b\\d{1,2}\\b.*", Pattern.UNICODE_CHARACTER_CLASS);
|
||||
|
||||
private final AtomicLong _lastPickup = new AtomicLong();
|
||||
private final Map<Integer, Long> _listenerCooldown = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
protected FakePlayerHeardManager()
|
||||
{
|
||||
@@ -101,7 +103,7 @@ public class FakePlayerHeardManager
|
||||
// The seam is the ground truth; heuristics only cover seamless (LLM) lines.
|
||||
final boolean known = (seam != null) && !seam.isEmpty();
|
||||
final boolean levelup = known ? "levelup".equals(seam) : isLevelupLine(text);
|
||||
final boolean commercial = known ? ("trade_shout".equals(seam) || "lfp".equals(seam) || "store_title".equals(seam) || "call_help".equals(seam) || "grats".equals(seam) || "overheard".equals(seam) || "overheard_q".equals(seam)) : isCommercialLine(text);
|
||||
final boolean commercial = known ? ("trade_shout".equals(seam) || "lfp".equals(seam) || "store_title".equals(seam) || "call_help".equals(seam) || "grats".equals(seam) || "overheard".equals(seam) || "overheard_q".equals(seam) || "dialogue".equals(seam) || "bot_talk".equals(seam) || "reply".equals(seam) || "invite_player".equals(seam) || "party_accept".equals(seam) || seam.startsWith("siege_") || seam.startsWith("reply_")) : isCommercialLine(text);
|
||||
if (!levelup && commercial)
|
||||
{
|
||||
return; // Nobody "agrees" with an advertisement (or a pickup - depth 1).
|
||||
@@ -124,14 +126,14 @@ public class FakePlayerHeardManager
|
||||
final List<Creature> listeners = new ArrayList<>();
|
||||
for (Npc npc : World.getVisibleObjectsInRange(speaker, Npc.class, HEAR_RANGE))
|
||||
{
|
||||
if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead())
|
||||
if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead() && !onCooldown(npc.getObjectId(), now))
|
||||
{
|
||||
listeners.add(npc);
|
||||
}
|
||||
}
|
||||
for (Player player : World.getVisibleObjectsInRange(speaker, Player.class, HEAR_RANGE))
|
||||
{
|
||||
if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead())
|
||||
if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead() && !player.isInStoreMode() && !onCooldown(player.getObjectId(), now))
|
||||
{
|
||||
listeners.add(player);
|
||||
}
|
||||
@@ -163,8 +165,30 @@ public class FakePlayerHeardManager
|
||||
}
|
||||
|
||||
final Creature listener = listeners.get(Rnd.get(listeners.size()));
|
||||
_listenerCooldown.put(listener.getObjectId(), now);
|
||||
if (_listenerCooldown.size() > 2000)
|
||||
{
|
||||
_listenerCooldown.entrySet().removeIf(entry -> (now - entry.getValue()) > LISTENER_COOLDOWN);
|
||||
}
|
||||
final boolean question = isQuestionLine(text);
|
||||
final Map<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 StringBuilder echo = new StringBuilder();
|
||||
for (int i = 0; (i < words.length) && (i < 3); i++)
|
||||
@@ -175,8 +199,7 @@ public class FakePlayerHeardManager
|
||||
}
|
||||
echo.append(words[i]);
|
||||
}
|
||||
slots.put("msg", echo.toString());
|
||||
scheduleReply(listener, question ? "overheard_q" : "overheard", slots);
|
||||
return echo.toString();
|
||||
}
|
||||
|
||||
private void scheduleReply(Creature listener, String seam, Map<String, String> slots)
|
||||
|
||||
+33
-3
@@ -173,21 +173,31 @@ public class FakePlayerIntentParser implements IXmlReader
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Each group needs its own token: "слышь" alone must not satisfy both halves of "что нового".
|
||||
final boolean[] used = new boolean[tokens.length];
|
||||
boolean all = true;
|
||||
for (String[] group : intent.groups)
|
||||
{
|
||||
boolean any = false;
|
||||
for (String token : tokens)
|
||||
for (int index = 0; index < tokens.length; index++)
|
||||
{
|
||||
if (token.isEmpty())
|
||||
final String token = tokens[index];
|
||||
if (token.isEmpty() || used[index])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (String stem : group)
|
||||
{
|
||||
if (token.startsWith(stem) || ((stem.length() > 3) && fuzzyPrefix(token, stem)))
|
||||
if (matches(token, stem))
|
||||
{
|
||||
any = true;
|
||||
used[index] = true;
|
||||
// The same word appears twice (raw and phonetic): consume both copies.
|
||||
final int twin = (index < rawTokens.length) ? (index + rawTokens.length) : (index - rawTokens.length);
|
||||
if ((twin >= 0) && (twin < tokens.length) && tokens[twin].equals(token))
|
||||
{
|
||||
used[twin] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -210,6 +220,26 @@ public class FakePlayerIntentParser implements IXmlReader
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param token message token.
|
||||
* @param stem lexicon stem; a leading "=" means exact prefix only (no typo tolerance).
|
||||
* @return true when the token matches the stem.
|
||||
*/
|
||||
private static boolean matches(String token, String stem)
|
||||
{
|
||||
if (stem.startsWith("="))
|
||||
{
|
||||
return token.startsWith(stem.substring(1));
|
||||
}
|
||||
if (token.startsWith(stem))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Typo tolerance only for longer stems and for tokens that could be the same word:
|
||||
// "молодая" is not a typo of "соло", "думаешь" is not "дурак".
|
||||
return (stem.length() >= 5) && (token.length() <= (stem.length() + 3)) && fuzzyPrefix(token, stem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein
|
||||
* distance 1 of the stem ("помгите" matches stem "помо").
|
||||
|
||||
+20
@@ -1300,6 +1300,26 @@ public class FakePlayerProgressionManager implements IXmlReader
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a town for a level and race (the town a character of that level would trade in).
|
||||
* @param level character level.
|
||||
* @param race race name.
|
||||
* @return location and town name, or null.
|
||||
*/
|
||||
public Object[] pickTownSpot(int level, String race)
|
||||
{
|
||||
final ZoneInfo town = pickTown(level, race);
|
||||
if (town == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Object[]
|
||||
{
|
||||
town.points.get(Rnd.get(town.points.size())),
|
||||
town.name
|
||||
};
|
||||
}
|
||||
|
||||
public static FakePlayerProgressionManager getInstance()
|
||||
{
|
||||
return SingletonHolder.INSTANCE;
|
||||
|
||||
+732
@@ -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();
|
||||
}
|
||||
}
|
||||
+751
@@ -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();
|
||||
}
|
||||
}
|
||||
+252
@@ -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();
|
||||
}
|
||||
}
|
||||
+411
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
@@ -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()
|
||||
@@ -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)"
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user