JEWELRY = new java.util.concurrent.ConcurrentHashMap<>();
+
+ /**
+ * The best necklace / earring / ring of a grade (by reference price), found once in the item data.
+ * @param grade crystal grade level (0 = NG ... 5 = S).
+ * @return item ids {necklace, earring, ring}.
+ */
+ private static int[] jewelryOf(int grade)
+ {
+ return JEWELRY.computeIfAbsent(grade, g ->
+ {
+ final int[] best = new int[3];
+ final int[] price = new int[3];
+ for (ItemTemplate template : ItemData.getInstance().getAllItems())
+ {
+ if ((template == null) || !(template instanceof org.l2jmobius.gameserver.entity.item.Armor) || (template.getCrystalType().getLevel() != g) || !template.isSellable() || !template.isTradeable())
+ {
+ continue;
+ }
+ final BodyPart part = template.getBodyPart();
+ final int slot = (part == BodyPart.NECK) ? 0 : ((part == BodyPart.R_EAR) || (part == BodyPart.L_EAR) || (part == BodyPart.LR_EAR)) ? 1 : ((part == BodyPart.R_FINGER) || (part == BodyPart.L_FINGER) || (part == BodyPart.LR_FINGER)) ? 2 : -1;
+ if ((slot < 0) || (template.getReferencePrice() <= price[slot]) || (template.getReferencePrice() > 50000000))
+ {
+ continue;
+ }
+ price[slot] = template.getReferencePrice();
+ best[slot] = template.getId();
+ }
+ return best;
+ });
+ }
+
+ /**
+ * Gives and wears a full jewelry set of the bot's grade (real players never farm without one).
+ * @param bot the bot.
+ * @param grade crystal grade level.
+ */
+ public void wearJewelry(Player bot, int grade)
+ {
+ final int[] set = jewelryOf(Math.max(0, Math.min(5, grade)));
+ final int[] wanted = { set[0], set[1], set[1], set[2], set[2] };
+ for (int itemId : wanted)
+ {
+ if (itemId <= 0)
+ {
+ continue;
+ }
+ int worn = 0;
+ int owned = 0;
+ for (Item item : bot.getInventory().getItems())
+ {
+ if (item.getId() == itemId)
+ {
+ owned++;
+ if (item.isEquipped())
+ {
+ worn++;
+ }
+ }
+ }
+ final int needed = (itemId == set[1]) || (itemId == set[2]) ? 2 : 1;
+ if (worn >= needed)
+ {
+ continue;
+ }
+ Item item = null;
+ for (Item candidate : bot.getInventory().getItems())
+ {
+ if ((candidate.getId() == itemId) && !candidate.isEquipped())
+ {
+ item = candidate;
+ break;
+ }
+ }
+ if ((item == null) && (owned < needed))
+ {
+ item = bot.addItem(ItemProcessType.NONE, itemId, 1, bot, false);
+ }
+ if ((item != null) && !item.isEquipped())
+ {
+ bot.useEquippableItem(item, false);
+ }
+ }
+ }
+
+ /** Town buffs (the scheme buffer everybody uses): id, level for fighters / mages. */
+ private static final int[][] FIGHTER_BUFFS = { { 1204, 2 }, { 1040, 3 }, { 1068, 3 }, { 1077, 3 }, { 1086, 2 }, { 1062, 2 }, { 1035, 4 }, { 1045, 6 }, { 1268, 4 }, { 1240, 3 }, { 1242, 3 } };
+ private static final int[][] MAGE_BUFFS = { { 1204, 2 }, { 1040, 3 }, { 1059, 3 }, { 1085, 3 }, { 1078, 6 }, { 1035, 4 }, { 1045, 6 }, { 1062, 2 }, { 1303, 2 }, { 1048, 6 } };
+
+ /**
+ * The full buff set a player takes from the town buffer before going to farm. Levels are
+ * clamped to what exists; buffs the character would not have at its level are skipped.
+ * @param bot the bot.
+ */
+ public void buffUp(Player bot)
+ {
+ final int[][] buffs = bot.isMageClass() ? MAGE_BUFFS : FIGHTER_BUFFS;
+ for (int[] entry : buffs)
+ {
+ final int maxLevel = SkillData.getInstance().getMaxLevel(entry[0]);
+ if (maxLevel <= 0)
+ {
+ continue;
+ }
+ int level = Math.min(entry[1], maxLevel);
+ // Low levels get weaker buffs, like a real buffer's level restriction.
+ if (bot.getLevel() < 20)
+ {
+ level = Math.min(level, 1);
+ }
+ else if (bot.getLevel() < 40)
+ {
+ level = Math.min(level, 2);
+ }
+ final Skill skill = SkillData.getInstance().getSkill(entry[0], level);
+ if ((skill != null) && !bot.isAffectedBySkill(entry[0]))
+ {
+ try
+ {
+ skill.applyEffects(bot, bot);
+ }
+ catch (Exception e)
+ {
+ // A buff that cannot be applied is just skipped.
+ }
+ }
+ }
+ }
+
+ public static FakePlayerEconomy getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ protected static final FakePlayerEconomy INSTANCE = new FakePlayerEconomy();
+ }
+}
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java
index dcf9a57..342995b 100644
--- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeadlessManager.java
@@ -24,54 +24,56 @@ import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.l2jmobius.commons.database.DatabaseFactory;
-import org.l2jmobius.gameserver.data.sql.ClanTable;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
+import org.l2jmobius.gameserver.data.sql.ClanTable;
+import org.l2jmobius.gameserver.data.xml.MapRegionData;
import org.l2jmobius.gameserver.entity.Location;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.WorldObject;
+import org.l2jmobius.gameserver.entity.actor.Creature;
import org.l2jmobius.gameserver.entity.actor.Player;
-import org.l2jmobius.gameserver.data.xml.MapRegionData;
import org.l2jmobius.gameserver.entity.actor.enums.player.PrivateStoreType;
import org.l2jmobius.gameserver.entity.actor.enums.player.TeleportWhereType;
import org.l2jmobius.gameserver.entity.actor.instance.Monster;
import org.l2jmobius.gameserver.entity.clan.Clan;
-import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType;
import org.l2jmobius.gameserver.entity.groups.Party;
+import org.l2jmobius.gameserver.entity.item.enums.ItemProcessType;
+import org.l2jmobius.gameserver.entity.item.instance.Item;
+import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding;
import org.l2jmobius.gameserver.network.enums.ChatType;
import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;
import org.l2jmobius.gameserver.network.serverpackets.JoinParty;
+import org.l2jmobius.gameserver.network.serverpackets.PrivateStoreMsgSell;
/**
* Headless players: real Player instances restored from the seeded characters,
- * living in the world without a network client. They can join real parties,
- * share experience and behave like actual party members.
- * Prototype: basic farm AI, auto party accept, auto revive.
+ * living in the world without a network client. For the engine they are
+ * players: they earn real experience, join real parties, own real inventories,
+ * open real private stores, fight in real clan wars and castle sieges.
+ *
+ * Each tick (3 s) a bot goes through: revive - level/profession/migration -
+ * siege - social perception and talk ({@link FakePlayerSocial}) - human party
+ * escort - gear - forced target - combat brain ({@link FakePlayerBrain}) -
+ * trading shift - idle wandering. The social layer runs before the combat
+ * brain on purpose: bots keep talking and noticing people while they farm.
* @author Mobius SPP
*/
public class FakePlayerHeadlessManager
{
private static final Logger LOGGER = Logger.getLogger(FakePlayerHeadlessManager.class.getName());
-
- private static final Location[] SPOTS =
- {
- new Location(83400, 147900, -3404), // Giran town edge
- new Location(91280, 145900, -3500), // east of Giran
- new Location(73700, 142800, -3600), // west field
- new Location(82900, 138000, -3464), // south field
- new Location(89200, 125900, -3200), // toward Alligator
- };
-
- private static final Location TRADE_SPOT = new Location(83396, 147904, -3404); // Giran center
+
+ private static final Location GIRAN = new Location(83396, 147904, -3404);
// Экипировка по тирам (уровни 8/20/30/40/50/59/68/74): грейд подобран под экспертизу,
// иначе игрок получает штраф и бегает медленно.
private static final int[] SWORDS = { 2, 143, 143, 84, 84, 141, 150, 150 };
@@ -90,7 +92,8 @@ public class FakePlayerHeadlessManager
private static final boolean[] ROBE_ONEPIECE = { true, true, true, true, true, true, true, true };
private static final int[] SOULSHOTS = { 1835, 1463, 1463, 1464, 1464, 1465, 1466, 1466 };
private static final int[] SPIRITSHOTS = { 3947, 3948, 3948, 3949, 3949, 3950, 3951, 3951 };
-
+ private static final int[] ARROWS = { 17, 1341, 1341, 1342, 1342, 1343, 1344, 1344 };
+
private static final String[] TITLES =
{
"", "", "", "", "", // most players have no title
@@ -98,40 +101,15 @@ public class FakePlayerHeadlessManager
"мимо", "новичок", "тут был", "Гиран", "Дион", "Аден", "хочу спать", "качаюсь",
"без обид", "мирный", "за кланы", "рб хантер", "спойлер", "бафер", "тащу", "нуб"
};
-
+
private static final String[] STORE_MSGS = { "продам дроп с фарма", "скупаю ресы дорого", "распродажа, налетай", "продам D грейд дешево", "куплю бижу" };
-
- /**
- * Per bot runtime state (mirrors what NPC bots keep in their managers).
- */
- private static class BotState
- {
- int seedId;
- String personality = "neutral";
- String race = "";
- String zone = "";
- int lastLevel = 1;
- long lastChat = 0;
- long lastHelpCredit = 0;
- int lastPvpTargetId = 0;
- long partyCooldown = 0;
- int forcedTargetId = 0;
- long forcedUntil = 0;
- final FakePlayerBrain.CombatState combat = new FakePlayerBrain.CombatState();
- long nextGearCheck = 0;
- int gearTier = -1;
- }
-
- private static final int NOTICE_RANGE = 1000;
- private static final long AMBIENT_COOLDOWN = 90000;
- private static final long TAUNT_COOLDOWN = 20000;
-
- private final List _bots = new ArrayList<>();
- private final Map _states = new ConcurrentHashMap<>();
+
+ private final List _bots = new CopyOnWriteArrayList<>();
+ private final List _view = Collections.unmodifiableList(_bots);
+ private final Map _states = new ConcurrentHashMap<>();
private final Map _reviveAt = new ConcurrentHashMap<>();
- private final Map _tradeUntil = new ConcurrentHashMap<>();
- private final Map _nextTradeAt = new ConcurrentHashMap<>();
-
+ private final Map _clanSpots = new ConcurrentHashMap<>();
+
protected FakePlayerHeadlessManager()
{
if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || (FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT <= 0))
@@ -139,12 +117,14 @@ public class FakePlayerHeadlessManager
LOGGER.info(getClass().getSimpleName() + ": Disabled.");
return;
}
-
+
spawnAll(FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT);
ThreadPool.scheduleAtFixedRate(this::tick, 5000, 3000);
LOGGER.info(getClass().getSimpleName() + ": Spawned " + _bots.size() + " headless players.");
}
-
+
+ // ======================== Spawning ========================
+
private void spawnAll(int count)
{
// Pick characters spread across all levels (levels 1..76 in the seed).
@@ -168,84 +148,194 @@ public class FakePlayerHeadlessManager
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not list characters.", e);
return;
}
-
+
for (int charId : charIds)
{
- try
+ spawnOne(charId);
+ }
+ }
+
+ /**
+ * Loads one seeded character into the world as a headless player.
+ * @param charId the character id.
+ * @return the player, or null when it could not be loaded.
+ */
+ private Player spawnOne(int charId)
+ {
+ try
+ {
+ final Player player = Player.load(charId);
+ if (player == null)
{
- final Player player = Player.load(charId);
- if (player == null)
+ return null;
+ }
+ player.setHeadlessBot(true);
+
+ // Identity from the seeded population: personality, race, home zone.
+ final FakePlayerBotState state = new FakePlayerBotState();
+ final FakePlayerProgressionManager.SeedInfo seed = FakePlayerProgressionManager.getInstance().getSeedInfo(player.getName());
+ if (seed != null)
+ {
+ state.seedId = seed.id;
+ state.personality = seed.personality;
+ state.race = seed.race;
+ }
+ state.lastLevel = player.getLevel();
+ state.lastExp = player.getExp();
+ state.nextTradeAt = System.currentTimeMillis() + Rnd.get(600000, 7200000);
+ _states.put(player.getObjectId(), state);
+
+ // Экипируем не сразу: сервер пересчитывает уровень из опыта после загрузки,
+ // иначе новичок может получить шмот по «бумажному» уровню из базы.
+ ThreadPool.schedule(() -> equipAndTeach(player), 3000);
+
+ // Believable player title instead of a personality label.
+ player.setTitle(TITLES[Rnd.get(TITLES.length)]);
+ // Restore clan membership so crests, clan wars and sieges work like for real players.
+ if (player.getClanId() > 0)
+ {
+ final Clan clan = ClanTable.getInstance().getClan(player.getClanId());
+ if (clan != null)
{
- continue;
+ player.setClan(clan);
+ player.setPledgeType(0);
+ player.setPowerGrade((clan.getLeaderId() == player.getObjectId()) ? 1 : 6);
}
- player.setHeadlessBot(true);
-
- // Identity from the seeded population: personality, race, home zone.
- final BotState state = new BotState();
- final FakePlayerProgressionManager.SeedInfo seed = FakePlayerProgressionManager.getInstance().getSeedInfo(player.getName());
- if (seed != null)
- {
- state.seedId = seed.id;
- state.personality = seed.personality;
- state.race = seed.race;
- }
- state.lastLevel = player.getLevel();
- _states.put(player.getObjectId(), state);
-
- // Экипируем не сразу: сервер пересчитывает уровень из опыта после загрузки,
- // иначе новичок может получить шмот по «бумажному» уровню из базы.
- final Player equipTarget = player;
- ThreadPool.schedule(() -> equipAndTeach(equipTarget), 3000);
-
- // Believable player title instead of a personality label.
- final String title = TITLES[Rnd.get(TITLES.length)];
- if (!title.isEmpty())
- {
- player.setTitle(title);
- }
- else
- {
- player.setTitle("");
- }
- // Restore clan membership so crests and clan wars work like for real players.
- if (player.getClanId() > 0)
- {
- final Clan clan = ClanTable.getInstance().getClan(player.getClanId());
- if (clan != null)
- {
- player.setClan(clan);
- player.setPledgeType(0);
- player.setPowerGrade(6);
-
- }
- }
- _nextTradeAt.put(player.getObjectId(), System.currentTimeMillis() + Rnd.get(600000, 7200000));
- // Place the bot in a level appropriate farming zone (shared zone table).
- Location spot = SPOTS[Rnd.get(SPOTS.length)];
- final BotState placedState = _states.get(player.getObjectId());
- final Object[] picked = FakePlayerProgressionManager.getInstance().pickSpot(player.getLevel(), (placedState != null) ? placedState.race : "");
+ }
+ // Place the bot in a level appropriate farming zone (shared zone table). Clanmates of a
+ // similar level start on the same spot, so real parties form between them.
+ Location spot = new Location(GIRAN.getX() + Rnd.get(-2000, 2000), GIRAN.getY() + Rnd.get(-2000, 2000), GIRAN.getZ());
+ final String clanKey = (player.getClanId() > 0) ? (player.getClanId() + ":" + (player.getLevel() / 6) + ":" + state.race) : null;
+ final Object[] shared = (clanKey != null) ? _clanSpots.get(clanKey) : null;
+ if ((shared != null) && (Rnd.get(100) < 70))
+ {
+ spot = (Location) shared[0];
+ state.zone = (String) shared[1];
+ }
+ else
+ {
+ final Object[] picked = FakePlayerProgressionManager.getInstance().pickSpot(player.getLevel(), state.race);
if (picked != null)
{
spot = (Location) picked[0];
- if (placedState != null)
+ state.zone = (String) picked[1];
+ if (clanKey != null)
{
- placedState.zone = (String) picked[1];
+ _clanSpots.put(clanKey, picked);
}
}
- player.getStatus().setCurrentHp(player.getMaxHp());
- player.getStatus().setCurrentMp(player.getMaxMp());
- player.getStatus().setCurrentCp(player.getMaxCp());
- player.spawnMe(spot.getX() + Rnd.get(-400, 400), spot.getY() + Rnd.get(-400, 400), spot.getZ());
- player.setRunning();
- _bots.add(player);
- }
- catch (Exception e)
- {
- LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not load char " + charId, e);
}
+ state.spot = spot;
+ player.getStatus().setCurrentHp(player.getMaxHp());
+ player.getStatus().setCurrentMp(player.getMaxMp());
+ player.getStatus().setCurrentCp(player.getMaxCp());
+ player.spawnMe(spot.getX() + Rnd.get(-400, 400), spot.getY() + Rnd.get(-400, 400), spot.getZ());
+ player.setRunning();
+ _bots.add(player);
+ return player;
+ }
+ catch (Exception e)
+ {
+ LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not load char " + charId, e);
+ return null;
}
}
-
+
+ /**
+ * Makes sure a seeded character (a clan leader before a siege, for example) is in the world.
+ * @param charId the character id.
+ * @return the headless player, or null.
+ */
+ public Player ensureOnline(int charId)
+ {
+ if (charId <= 0)
+ {
+ return null;
+ }
+ for (Player bot : _bots)
+ {
+ if (bot.getObjectId() == charId)
+ {
+ return bot;
+ }
+ }
+ if (World.getPlayer(charId) != null)
+ {
+ return null; // A real player (or someone else) holds this character.
+ }
+ final Player bot = spawnOne(charId);
+ if (bot != null)
+ {
+ LOGGER.info(getClass().getSimpleName() + ": " + bot.getName() + " logged in on demand (" + _bots.size() + " headless players now).");
+ }
+ return bot;
+ }
+
+ // ======================== Registry ========================
+
+ /**
+ * @return all headless bots (read only).
+ */
+ public List bots()
+ {
+ return _view;
+ }
+
+ /**
+ * @param bot a headless bot.
+ * @return its state, null for other players.
+ */
+ public FakePlayerBotState stateOf(Player bot)
+ {
+ return (bot == null) ? null : _states.get(bot.getObjectId());
+ }
+
+ /**
+ * @param player any player.
+ * @return true when the player is one of the headless bots.
+ */
+ public boolean isBot(Player player)
+ {
+ return (player != null) && _states.containsKey(player.getObjectId());
+ }
+
+ /**
+ * @param bot a headless bot.
+ * @return its seed character id (idiolect/anti-repeat identity), 0 when unknown.
+ */
+ public int seedIdOf(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ return (state != null) ? state.seedId : 0;
+ }
+
+ /**
+ * @param bot a headless bot.
+ * @return its personality, "neutral" when unknown.
+ */
+ public String personalityOf(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ return (state != null) ? state.personality : "neutral";
+ }
+
+ /**
+ * @param bot a headless bot.
+ * @return the name of its farming zone ("" when unknown).
+ */
+ public String zoneOf(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ return (state != null) ? state.zone : "";
+ }
+
+ public int size()
+ {
+ return _bots.size();
+ }
+
+ // ======================== Party ========================
+
/**
* Auto accept a party invite - mirrors RequestAnswerJoinParty (response 1).
* @param requestor the inviting player.
@@ -272,6 +362,12 @@ public class FakePlayerHeadlessManager
}
target.setActiveRequester(null);
requestor.onTransactionResponse();
+ onJoinedParty(target);
+ final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(target), "party_accept", requestor, null);
+ if (line != null)
+ {
+ ThreadPool.schedule(() -> say(target, line, "party_accept"), Rnd.get(1500, 4000));
+ }
}
catch (Exception e)
{
@@ -279,7 +375,24 @@ public class FakePlayerHeadlessManager
}
}, 1000 + Rnd.get(1500));
}
-
+
+ /**
+ * Bookkeeping when a bot ends up in a party with a real player.
+ * @param bot the bot.
+ */
+ public void onJoinedParty(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.partiesJoined.incrementAndGet();
+ state.action("party");
+ closeStore(bot);
+ }
+ }
+
+ // ======================== Tick ========================
+
private void tick()
{
final long now = System.currentTimeMillis();
@@ -295,7 +408,7 @@ public class FakePlayerHeadlessManager
}
}
}
-
+
private void handle(Player bot, long now)
{
// Safety net: a headless bot must never stay decayed after a teleport.
@@ -306,106 +419,154 @@ public class FakePlayerHeadlessManager
bot.broadcastUserInfo();
return;
}
-
- if (bot.isDead())
+
+ final FakePlayerBotState state = _states.get(bot.getObjectId());
+ if (state == null)
{
- final Long at = _reviveAt.get(bot.getObjectId());
- if (at == null)
- {
- _reviveAt.put(bot.getObjectId(), now + 15000);
- }
- else if (now >= at)
- {
- _reviveAt.remove(bot.getObjectId());
-
- // Respawn in the nearest town, exactly like a player pressing "to village".
- final Location town = MapRegionData.getInstance().getTeleToLocation(bot, TeleportWhereType.TOWN);
- if (town != null)
- {
- teleport(bot, town);
- }
- bot.doRevive();
- bot.getStatus().setCurrentHp(bot.getMaxHp() * 0.7);
- bot.getStatus().setCurrentMp(bot.getMaxMp() * 0.7);
- bot.broadcastUserInfo();
-
- // Head back to the farming zone after a short break.
- final BotState state = _states.get(bot.getObjectId());
- final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), (state != null) ? state.race : "");
- if (spot != null)
- {
- final Location back = (Location) spot[0];
- if (state != null)
- {
- state.zone = (String) spot[1];
- }
- ThreadPool.schedule(() ->
- {
- if (!bot.isDead())
- {
- teleport(bot, back);
- }
- }, 20000 + Rnd.get(40000));
- }
- }
return;
}
-
- // Party behavior: stay with the leader, assist their target.
- final Party party = bot.getParty();
- if (party != null)
+
+ if (bot.isDead())
{
- final Player leader = party.getLeader();
- if ((leader != null) && (leader != bot) && !leader.isHeadlessBot())
- {
- if (bot.calculateDistance2D(leader) > 3000)
- {
- teleport(bot, leader.getLocation());
- return;
- }
- final WorldObject leaderTarget = leader.getTarget();
- if ((leaderTarget instanceof Monster monster) && leader.isInCombat() && !monster.isDead())
- {
- if (bot.getTarget() != monster)
- {
- bot.setTarget(monster);
- }
- bot.getAI().setIntentionAttack(monster);
- return;
- }
- if (!bot.isInCombat() && (bot.calculateDistance2D(leader) > 250))
- {
- bot.getAI().setIntentionFollow(leader);
- return;
- }
- return;
- }
+ handleDead(bot, state, now);
+ return;
}
-
- // Раз в минуту сверяем экипировку с уровнем: после апа тира надо переодеться,
- // а после отката уровня - снять слишком высокий грейд.
- final BotState gearState = _states.get(bot.getObjectId());
- if ((gearState != null) && (now > gearState.nextGearCheck))
+
+ // Real level ups happen by themselves (they earn real exp) - react, take the profession, migrate.
+ final long exp = bot.getExp();
+ if (exp > state.lastExp)
{
- gearState.nextGearCheck = now + 60000;
- if ((bot.getExpertisePenaltyBonus() > 0) || (FakePlayerProgressionManager.tierOf(bot.getLevel()) != gearState.gearTier))
+ state.expGained.addAndGet(exp - state.lastExp);
+ }
+ state.lastExp = exp;
+ if (bot.getLevel() > state.lastLevel)
+ {
+ final int oldTier = FakePlayerProgressionManager.tierOf(state.lastLevel);
+ final int newTier = FakePlayerProgressionManager.tierOf(bot.getLevel());
+ state.lastLevel = bot.getLevel();
+ if (FakePlayerEconomy.getInstance().checkProfession(bot, state) != null)
{
- gearState.gearTier = FakePlayerProgressionManager.tierOf(bot.getLevel());
equipAndTeach(bot);
+ final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "profession", null, Map.of("class", String.valueOf(bot.getPlayerClass())));
+ if (line != null)
+ {
+ ThreadPool.schedule(() -> say(bot, line, "profession"), Rnd.get(2000, 6000));
+ }
}
- }
-
- // Заблокированная цель (ганг / дебаг-команда): не отвлекаемся на мобов.
- final BotState focus = _states.get(bot.getObjectId());
- if ((focus != null) && (focus.forcedTargetId != 0))
- {
- if (now >= focus.forcedUntil)
+ if ((newTier != oldTier) && (state.siegeSide == 0) && (FakePlayerSocial.humanPartyAnchor(bot) == null))
{
- focus.forcedTargetId = 0;
+ migrate(bot, state, true);
}
else
{
- final WorldObject victim = World.findObject(focus.forcedTargetId);
+ FakePlayerSocial.getInstance().onLevelUp(bot, state);
+ }
+ return;
+ }
+
+ // Castle siege: the siege logic owns the bot while the battle lasts.
+ if ((state.siegeSide != 0) && FakePlayerSiegeManager.getInstance().handle(bot, state, now))
+ {
+ return;
+ }
+
+ // Perception and talk first: a farming bot still notices people and chats.
+ if (FakePlayerSocial.getInstance().tick(bot, state, now))
+ {
+ return;
+ }
+
+ // Party with a real player: stay with them, assist their target.
+ final Player anchor = FakePlayerSocial.humanPartyAnchor(bot);
+ if (anchor != null)
+ {
+ if (bot.calculateDistance2D(anchor) > 3000)
+ {
+ teleport(bot, anchor.getLocation());
+ return;
+ }
+ final WorldObject anchorTarget = anchor.getTarget();
+ if ((anchorTarget instanceof Monster monster) && anchor.isInCombat() && !monster.isDead())
+ {
+ if (bot.getTarget() != monster)
+ {
+ bot.setTarget(monster);
+ }
+ bot.getAI().setIntentionAttack(monster);
+ state.action("assist party");
+ return;
+ }
+ if (FakePlayerBrain.act(bot, state, false))
+ {
+ return;
+ }
+ if (!bot.isInCombat() && (bot.calculateDistance2D(anchor) > 250))
+ {
+ bot.getAI().setIntentionFollow(anchor);
+ }
+ return;
+ }
+
+ // Squad (bot only party): members stay with the leader and assist its target.
+ final Party squad = bot.getParty();
+ if ((squad != null) && (squad.getLeader() != null) && (squad.getLeader() != bot) && !squad.getLeader().isDead() && (state.forcedTargetId == 0))
+ {
+ final Player leader = squad.getLeader();
+ final double distance = bot.calculateDistance2D(leader);
+ if (distance > 4000)
+ {
+ teleport(bot, leader.getX() + Rnd.get(-150, 150), leader.getY() + Rnd.get(-150, 150), leader.getZ());
+ return;
+ }
+ final WorldObject leaderTarget = leader.getTarget();
+ if (!bot.isInCombat() && (leaderTarget instanceof Monster monster) && !monster.isDead() && (monster.getTarget() != null) && (bot.calculateDistance2D(monster) < 1500))
+ {
+ bot.setTarget(monster);
+ bot.getAI().setIntentionAttack(monster);
+ state.action("assist squad");
+ return;
+ }
+ if (FakePlayerBrain.act(bot, state, distance < 900))
+ {
+ return;
+ }
+ if (distance > 500)
+ {
+ bot.setRunning();
+ bot.getAI().setIntentionFollow(leader);
+ state.action("follow squad");
+ return;
+ }
+ }
+
+ // Раз в минуту сверяем экипировку с уровнем: после апа тира надо переодеться,
+ // а после отката уровня - снять слишком высокий грейд.
+ if (now > state.nextGearCheck)
+ {
+ state.nextGearCheck = now + 60000;
+ if ((bot.getExpertisePenaltyBonus() > 0) || (FakePlayerProgressionManager.tierOf(bot.getLevel()) != state.gearTier))
+ {
+ state.gearTier = FakePlayerProgressionManager.tierOf(bot.getLevel());
+ equipAndTeach(bot);
+ }
+ FakePlayerEconomy.getInstance().ensureShots(bot);
+ // Buffs ran out: a player would run to the buffer; we re-buff on the spot between fights.
+ if (!bot.isInCombat() && !bot.isAffectedBySkill(1204))
+ {
+ FakePlayerEconomy.getInstance().buffUp(bot);
+ }
+ }
+
+ // Заблокированная цель (ганг / клановая война / дебаг-команда): не отвлекаемся на мобов.
+ if (state.forcedTargetId != 0)
+ {
+ if (now >= state.forcedUntil)
+ {
+ state.forcedTargetId = 0;
+ }
+ else
+ {
+ final WorldObject victim = World.findObject(state.forcedTargetId);
if ((victim instanceof Player victimPlayer) && !victimPlayer.isDead() && (bot.calculateDistance2D(victimPlayer) < 3000))
{
if ((bot.getTarget() != victimPlayer) || !bot.isAttackingNow())
@@ -415,243 +576,194 @@ public class FakePlayerHeadlessManager
}
return;
}
- focus.forcedTargetId = 0;
+ state.forcedTargetId = 0;
}
}
-
- // Game sense: survival, upkeep, party role, combat rotation, target picking.
+
+ // Private store shift: sitting in town with a real sell store.
+ if (state.tradeUntil > 0)
+ {
+ if (now < state.tradeUntil)
+ {
+ return;
+ }
+ closeStore(bot);
+ state.nextTradeAt = now + Rnd.get(1800000, 5400000);
+ // Restock with the adena earned and go back to the spot.
+ FakePlayerEconomy.getInstance().visitTown(bot, state);
+ ThreadPool.schedule(() -> returnToSpot(bot), Rnd.get(5000, 20000));
+ return;
+ }
+
+ // Game sense: survival, upkeep, party role, combat rotation, loot, target picking.
// Runs before the "life" logic so a bot never trades or wanders while it is
// bleeding out or has a monster on its back.
- final BotState brainState = _states.get(bot.getObjectId());
- if (brainState != null)
- {
- final boolean hunts = !_tradeUntil.containsKey(bot.getObjectId());
- if (FakePlayerBrain.act(bot, brainState.combat, hunts))
- {
- return;
- }
- }
-
- // Private store shift: sit in Giran with a real sell store.
- final Long tradeEnd = _tradeUntil.get(bot.getObjectId());
- if (tradeEnd != null)
- {
- if (now < tradeEnd)
- {
- return;
- }
- _tradeUntil.remove(bot.getObjectId());
- bot.setPrivateStoreType(PrivateStoreType.NONE);
- bot.standUp();
- bot.broadcastUserInfo();
- _nextTradeAt.put(bot.getObjectId(), now + Rnd.get(1800000, 5400000));
- }
- else
- {
- final Long tradeAt = _nextTradeAt.get(bot.getObjectId());
- if ((tradeAt != null) && (now >= tradeAt) && !bot.isInCombat() && (bot.getParty() == null) && !bot.isDead())
- {
- // A bot goes to the market only when it actually has farmed loot to sell.
- if (!sellableLoot(bot).isEmpty() && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_TRADE_CHANCE))
- {
- openStore(bot, now);
- return;
- }
- _nextTradeAt.put(bot.getObjectId(), now + Rnd.get(600000, 1800000));
- }
- }
-
- final BotState state = _states.get(bot.getObjectId());
- if (state != null)
- {
- // Real level ups happen by themselves (they earn real exp) - react and migrate.
- if (bot.getLevel() > state.lastLevel)
- {
- final int oldTier = FakePlayerProgressionManager.tierOf(state.lastLevel);
- final int newTier = FakePlayerProgressionManager.tierOf(bot.getLevel());
- state.lastLevel = bot.getLevel();
- if (newTier != oldTier)
- {
- eventChat(bot, "Ты апнул " + bot.getLevel() + " уровень и перерос эту локацию. Попрощайся, уходишь на новый спот.", "migrate", null, null);
- final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race);
- if (spot != null)
- {
- teleport(bot, (Location) spot[0]);
- state.zone = (String) spot[1];
- }
- }
- else
- {
- final Map slots = new HashMap<>();
- slots.put("level", String.valueOf(bot.getLevel()));
- FakePlayerBotContext.setEvent(seedIdOf(bot), "levelup");
- if ((bot.getClan() != null) && (Rnd.get(100) < 40))
- {
- final BotState levelState = _states.get(bot.getObjectId());
- final String clanLine = FakePlayerChatLines.getInstance().compose((levelState != null) ? levelState.seedId : 0, "levelup", (levelState != null) ? levelState.personality : "neutral", slots);
- if (clanLine != null)
- {
- bot.getClan().broadcastToOnlineMembers(new CreatureSay(bot, ChatType.CLAN, bot.getName(), clanLine));
- }
- }
- else
- {
- eventChat(bot, "Ты только что взял " + bot.getLevel() + " уровень. Порадуйся одной фразой.", "levelup", slots, null);
- }
- }
- return;
- }
-
- // Credit players helping this bot with its monster.
- final WorldObject current = bot.getTarget();
- if (bot.isInCombat() && (current instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000))
- {
- final Player helper = World.getNearestVisibleObjectInRange(bot, Player.class, 900, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat() && (other.getTarget() == monsterTarget));
- if (helper != null)
- {
- state.lastHelpCredit = now;
- FakePlayerMemoryManager.getInstance().onHelpedBy(state.seedId, helper.getName());
- }
- }
-
- // Personality actions against real players.
- if (!bot.isInCombat() && (bot.getParty() == null))
- {
- Player victim = null;
- String seam = null;
- String instruction = null;
- if ("ganker".equals(state.personality))
- {
- victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible() && !other.isGM());
- seam = "gank_start";
- instruction = "Ты нападаешь на игрока. Крикни что-нибудь дерзкое.";
- }
- else if ("pkk".equals(state.personality))
- {
- victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && ((other.getKarma() > 0) || (other.getPvpFlag() != 0)));
- seam = "pkk_start";
- instruction = "Ты охотник на ПК и нападаешь на нарушителя. Крикни про правосудие.";
- }
- if ((victim != null) && (victim.getObjectId() != state.lastPvpTargetId))
- {
- state.lastPvpTargetId = victim.getObjectId();
- state.lastChat = now;
- forceTarget(bot, victim, 45);
- eventChat(bot, instruction, seam, Map.of("target", victim.getName()), victim.getName());
- return;
- }
-
- // Helpers assist real players fighting monsters.
- if ("helper".equals(state.personality))
- {
- final Player ally = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat());
- if (ally != null)
- {
- final WorldObject allyTarget = ally.getTarget();
- if ((allyTarget instanceof Monster monster) && !monster.isDead())
- {
- bot.setTarget(monster);
- bot.getAI().setIntentionAttack(monster);
- if ((now - state.lastChat) > TAUNT_COOLDOWN)
- {
- state.lastChat = now;
- eventChat(bot, "Ты помогаешь игроку " + ally.getName() + " убить монстра. Скажи что-то дружелюбное.", "assist", Map.of("player", ally.getName()), ally.getName());
- }
- return;
- }
- }
- }
- }
-
- // Ambient talk when a real player is around (memory and rumors aware).
- if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE))
- {
- final Player nearby = World.getNearestVisibleObjectInRange(bot, Player.class, 700, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible());
- if (nearby != null)
- {
- state.lastChat = now;
- final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(state.seedId, nearby.getName());
- final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(state.seedId, nearby.getName());
- if ((memory != null) && (memory.score <= -2))
- {
- eventChat(bot, "Рядом игрок " + nearby.getName() + ", который тебя убивал. Скажи что-то злопамятное.", "revenge_meet", Map.of("player", nearby.getName()), nearby.getName());
- }
- else if ((memory != null) && (memory.score >= 2))
- {
- eventChat(bot, "Рядом игрок " + nearby.getName() + ", который тебе помогал. Поприветствуй тепло.", "friendly_meet", Map.of("player", nearby.getName()), nearby.getName());
- }
- else if (rumor != null)
- {
- say(bot, FakePlayerRumorManager.getInstance().tell(rumor));
- }
- else if ((("helper".equals(state.personality)) || ("neutral".equals(state.personality))) && (Rnd.get(100) < 30))
- {
- eventChat(bot, "Рядом игрок " + nearby.getName() + ". Позови его в пати.", "invite_player", Map.of("player", nearby.getName()), nearby.getName());
- }
- else
- {
- eventChat(bot, "Рядом игрок. Скажи что-нибудь бытовое про фарм, дроп или спот.", "ambient", null, null);
- }
- }
- }
-
- // Squads: headless bots of the same clan form REAL parties with each other.
- if ((bot.getParty() == null) && (now > state.partyCooldown) && (bot.getClanId() > 0) && (Rnd.get(100) < 10))
- {
- state.partyCooldown = now + 300000;
- final Player mate = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && (other.getParty() == null) && (other.getClanId() == bot.getClanId()));
- if (mate != null)
- {
- final Party squad = new Party(bot, bot.getPartyDistributionType());
- bot.setParty(squad);
- mate.joinParty(squad);
- say(bot, FakePlayerChatLines.getInstance().compose(state.seedId, "invite_player", state.personality, "player", mate.getName()), "invite_player");
- }
- }
- }
-
- // Clan war PvP: attack players and headless bots of enemy clans.
- if (!bot.isInCombat() && (bot.getClan() != null) && (Rnd.get(100) < 40))
- {
- final Clan clan = bot.getClan();
- final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> !other.isDead() && (other != bot) && (other.getClan() != null) && (clan.isAtWarWith(other.getClanId()) || other.getClan().isAtWarWith(clan.getId())));
- if (enemy != null)
- {
- forceTarget(bot, enemy, 45);
- return;
- }
- }
-
- if (bot.isInCombat() || bot.isAttackingNow())
+ if (FakePlayerBrain.act(bot, state, true))
{
return;
}
-
- // Grab the loot that dropped from the last kills.
- pickupLoot(bot);
-
+
+ // Time to sell: a bot goes to the market only when it has real loot worth a store.
+ if ((state.nextTradeAt > 0) && (now >= state.nextTradeAt) && !bot.isInCombat() && (bot.getParty() == null))
+ {
+ if (FakePlayersConfig.FAKE_PLAYER_TRADING && !FakePlayerEconomy.getInstance().storeGoods(bot).isEmpty() && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_TRADE_CHANCE))
+ {
+ openStore(bot, now);
+ return;
+ }
+ state.nextTradeAt = now + Rnd.get(600000, 1800000);
+ }
+
// Nothing to fight nearby - wander a bit inside the zone.
if (Rnd.get(100) < 30)
{
bot.getAI().setIntentionMoveTo(new Location(bot.getX() + Rnd.get(-800, 800), bot.getY() + Rnd.get(-800, 800), bot.getZ()));
}
}
-
+
+ private void handleDead(Player bot, FakePlayerBotState state, long now)
+ {
+ final Long at = _reviveAt.get(bot.getObjectId());
+ if (at == null)
+ {
+ _reviveAt.put(bot.getObjectId(), now + 15000);
+ return;
+ }
+ if (now < at)
+ {
+ return;
+ }
+ _reviveAt.remove(bot.getObjectId());
+
+ // Respawn in the nearest town, exactly like a player pressing "to village".
+ final Location town = MapRegionData.getInstance().getTeleToLocation(bot, TeleportWhereType.TOWN);
+ if (town != null)
+ {
+ teleport(bot, town);
+ }
+ bot.doRevive();
+ bot.getStatus().setCurrentHp(bot.getMaxHp() * 0.7);
+ bot.getStatus().setCurrentMp(bot.getMaxMp() * 0.7);
+ bot.broadcastUserInfo();
+ state.action("revived");
+
+ // In town anyway: sell the junk, buy shots, then head back to the farming zone after a short break.
+ ThreadPool.schedule(() ->
+ {
+ if (!bot.isDead())
+ {
+ FakePlayerEconomy.getInstance().visitTown(bot, state);
+ }
+ }, 4000);
+ if (state.siegeSide != 0)
+ {
+ return; // The siege logic brings the bot back to the battle.
+ }
+ ThreadPool.schedule(() ->
+ {
+ if (!bot.isDead() && (state.siegeSide == 0))
+ {
+ returnToSpot(bot);
+ }
+ }, 20000 + Rnd.get(40000));
+ }
+
/**
- * Says a line in local chat as this bot (real player broadcast).
+ * Teleports the bot back to a farming spot of its level (a fresh one, like a player choosing a location).
+ * @param bot the bot.
+ */
+ public void returnToSpot(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if ((state == null) || bot.isDead() || bot.isInStoreMode())
+ {
+ return;
+ }
+ final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race);
+ if (spot != null)
+ {
+ state.spot = (Location) spot[0];
+ state.zone = (String) spot[1];
+ teleport(bot, state.spot);
+ state.action("back to " + state.zone);
+ }
+ }
+
+ /**
+ * Moves the bot to a zone of its level (tier change or debug): a farewell line, town shopping, new spot.
+ * @param bot the bot.
+ * @param state its state.
+ * @param farewell true to say goodbye first.
+ */
+ private void migrate(Player bot, FakePlayerBotState state, boolean farewell)
+ {
+ final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), state.race);
+ if (spot == null)
+ {
+ return;
+ }
+ state.migrations.incrementAndGet();
+ FakePlayerBotContext.setPlan(state.seedId, "migrate", 300000);
+ if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null))
+ {
+ bot.leaveParty();
+ }
+ if (farewell)
+ {
+ final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "migrate", null, null);
+ if (line != null)
+ {
+ say(bot, line, "migrate");
+ }
+ }
+ closeStore(bot);
+ final Location target = (Location) spot[0];
+ final String zone = (String) spot[1];
+ ThreadPool.schedule(() ->
+ {
+ if (bot.isDead())
+ {
+ return;
+ }
+ // Through town: a player would sell and restock on the way to a new area.
+ FakePlayerEconomy.getInstance().visitTown(bot, state);
+ state.spot = target;
+ state.zone = zone;
+ teleport(bot, target);
+ state.action("migrated to " + zone);
+ }, farewell ? Rnd.get(4000, 9000) : 500);
+ }
+
+ // ======================== Speech ========================
+
+ private static final Logger CHAT_LOG = Logger.getLogger("fakeplayer.chat");
+
+ /**
+ * Writes a bot line to the chat log (FakePlayerChatLog = True): the way to see what the population says
+ * without standing next to every bot.
+ * @param bot the speaker.
+ * @param channel channel name.
+ * @param seam seam the line came from.
+ * @param message the line.
+ */
+ private static void logChat(Player bot, String channel, String seam, String message)
+ {
+ if (FakePlayersConfig.FAKE_PLAYER_CHAT_LOG)
+ {
+ CHAT_LOG.info("[" + channel + "] " + bot.getName() + " (" + seam + "): " + message);
+ }
+ }
+
+ /**
+ * Says a line in local chat as this bot (real player broadcast) and lets the neighbours hear it.
* @param bot the headless player.
* @param message the text.
*/
public void say(Player bot, String message)
{
- if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
- {
- return;
- }
- bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message));
- FakePlayerHeardManager.getInstance().onSpeech(bot, message);
+ say(bot, message, "");
}
-
+
public void say(Player bot, String message, String seam)
{
if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
@@ -659,9 +771,37 @@ public class FakePlayerHeadlessManager
return;
}
bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message));
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ }
+ logChat(bot, "say", seam, message);
FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam);
}
-
+
+ /**
+ * A dead bot's line: the corpse lies there and the chat still works, like for real players.
+ * @param bot the dead bot.
+ * @param message the text.
+ * @param seam the seam.
+ */
+ public void sayDead(Player bot, String message, String seam)
+ {
+ if ((bot == null) || (message == null) || message.isEmpty())
+ {
+ return;
+ }
+ bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message));
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ }
+ logChat(bot, "dead", seam, message);
+ FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam);
+ }
+
/**
* Say without the heard hook - used for pickup replies (depth 1).
* @param bot the headless bot.
@@ -674,55 +814,83 @@ public class FakePlayerHeadlessManager
return;
}
bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), message));
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ state.repliesGiven.incrementAndGet();
+ }
+ logChat(bot, "pickup", "", message);
}
-
+
/**
- * @param player any player.
- * @return true when the player is one of the headless bots.
+ * Region-wide shout (call for help, siege cries).
+ * @param bot the bot.
+ * @param message the line.
+ * @param seam the seam.
*/
- public boolean isBot(Player player)
+ public void shout(Player bot, String message, String seam)
{
- return (player != null) && _states.containsKey(player.getObjectId());
+ if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
+ {
+ return;
+ }
+ World.broadcastToVisiblePlayersInRange(bot, new CreatureSay(bot, ChatType.SHOUT, bot.getName(), message), 6000);
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ }
+ logChat(bot, "shout", seam, message);
+ FakePlayerHeardManager.getInstance().onSpeech(bot, message, seam);
}
-
+
/**
- * @param bot a headless bot.
- * @return its seed character id (idiolect/anti-repeat identity), 0 when unknown.
+ * Trade channel line (party search, sales).
+ * @param bot the bot.
+ * @param message the line.
+ * @param seam the seam.
*/
- public int seedIdOf(Player bot)
+ public void trade(Player bot, String message, String seam)
{
- final BotState state = (bot == null) ? null : _states.get(bot.getObjectId());
- return (state != null) ? state.seedId : 0;
+ if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
+ {
+ return;
+ }
+ World.broadcastToVisiblePlayersInRange(bot, new CreatureSay(bot, ChatType.TRADE, bot.getName(), message), 9000);
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ }
+ logChat(bot, "trade", seam, message);
}
-
- /**
- * @param bot a headless bot.
- * @return its personality, "neutral" when unknown.
- */
- public String personalityOf(Player bot)
+
+ public void whisper(Player bot, Player to, String message)
{
- final BotState state = (bot == null) ? null : _states.get(bot.getObjectId());
- return (state != null) ? state.personality : "neutral";
- }
-
- private void whisper(Player bot, Player to, String message)
- {
- if ((message == null) || message.isEmpty())
+ if ((message == null) || message.isEmpty() || (to == null))
{
return;
}
to.sendPacket(new CreatureSay(bot, ChatType.WHISPER, bot.getName(), message));
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.linesSaid.incrementAndGet();
+ state.repliesGiven.incrementAndGet();
+ }
+ logChat(bot, "whisper->" + to.getName(), "", message);
}
-
+
/**
* Persona for the LLM, including this bot's memory and rumors about the target.
* @param bot the headless player.
* @param about player name the bot talks to or about.
* @return prompt text.
*/
- private String persona(Player bot, String about)
+ public String persona(Player bot, String about)
{
- final BotState state = _states.get(bot.getObjectId());
+ final FakePlayerBotState state = stateOf(bot);
final StringBuilder sb = new StringBuilder(256);
sb.append("Ты - ").append(bot.getName()).append(", игрок на сервере Lineage 2 Interlude, класс ").append(bot.getPlayerClass()).append(", уровень ").append(bot.getLevel()).append('.');
if (state != null)
@@ -745,6 +913,11 @@ public class FakePlayerHeadlessManager
default:
sb.append(" Ты спокойно фармишь, болтаешь о дропе, ценах и сервере.");
}
+ final String plan = FakePlayerBotContext.planOf(state.seedId);
+ if (!"farm".equals(plan))
+ {
+ sb.append(" Сейчас ты занят: ").append(plan).append('.');
+ }
if (about != null)
{
final String memory = FakePlayerMemoryManager.getInstance().describe(state.seedId, about);
@@ -761,31 +934,7 @@ public class FakePlayerHeadlessManager
}
return sb.toString();
}
-
- /**
- * Event comment: LLM when available, seam line otherwise.
- * @param bot the bot.
- * @param instruction LLM instruction.
- * @param seam seam key for the fallback.
- * @param slots seam slots.
- * @param about player the event is about (memory context), may be null.
- */
- private void eventChat(Player bot, String instruction, String seam, Map slots, String about)
- {
- final BotState state = _states.get(bot.getObjectId());
- final int seedId = (state != null) ? state.seedId : 0;
- final String mood = (state != null) ? state.personality : "neutral";
- final String fallback = FakePlayerChatLines.getInstance().compose(seedId, seam, mood, slots);
- if (FakePlayerLlmService.getInstance().isEnabled())
- {
- FakePlayerLlmService.getInstance().generate(persona(bot, about), instruction, reply -> say(bot, reply, seam), () -> say(bot, fallback, seam));
- }
- else
- {
- say(bot, fallback, seam);
- }
- }
-
+
/**
* Called from ChatWhisper when a player whispers a headless bot.
* @param player the speaker.
@@ -794,246 +943,175 @@ public class FakePlayerHeadlessManager
*/
public void onWhisper(Player player, Player bot, String message)
{
- final BotState state = _states.get(bot.getObjectId());
- final int seedId = (state != null) ? state.seedId : 0;
- final String personality = (state != null) ? state.personality : "neutral";
- final String reply = understand(player, bot, message, personality, seedId);
- if (FakePlayerLlmService.getInstance().isEnabled())
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
+ final Understanding understanding = FakePlayerUnderstanding.understand(ref, player, message, true);
+ if ((understanding != null) && understanding.silent)
{
- final String hint = FakePlayerChatManager.intentHint(lastIntent(message));
+ return; // The bot ignores the stranger, like real players sometimes do.
+ }
+ final String templateReply = ((understanding != null) && (understanding.responseLine != null)) ? understanding.responseLine : null;
+ final String fallback = (templateReply != null) ? templateReply : FakePlayerChatLines.getInstance().speakPrivate(ref.botId, "whisper_default", FakePlayerUnderstanding.facts(ref, player), FakePlayerUnderstanding.slots(ref, player), FakePlayerIdiolect.Mirror.of(message));
+ final boolean useLlm = FakePlayerLlmService.getInstance().isEnabled() && ("llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY) || (templateReply == null));
+ final long delay = Rnd.get(1500, 5000);
+ if (useLlm)
+ {
+ final String hint = (understanding != null) ? understanding.llmHint : "";
FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " написал тебе в личные сообщения: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer ->
{
whisper(bot, player, answer);
- FakePlayerMemoryManager.getInstance().addNote(seedId, player.getName(), "он писал: " + message + " / я: " + answer);
- }, () -> whisper(bot, player, (reply != null) ? reply : FakePlayerChatLines.getInstance().pick("whisper_default", null)));
+ FakePlayerMemoryManager.getInstance().addNote(ref.botId, player.getName(), "он писал: " + message + " / я: " + answer);
+ }, () -> ThreadPool.schedule(() -> whisper(bot, player, fallback), delay));
return;
}
- whisper(bot, player, (reply != null) ? reply : FakePlayerChatLines.getInstance().pick("whisper_default", null));
+ ThreadPool.schedule(() -> whisper(bot, player, fallback), delay);
}
-
+
/**
- * Called from ChatGeneral when a player speaks near a headless bot.
+ * Called when a player speaks near a headless bot.
* @param player the speaker.
* @param bot the headless bot.
* @param message the text.
*/
public void onLocalChat(Player player, Player bot, String message)
{
- final BotState state = _states.get(bot.getObjectId());
- final int seedId = (state != null) ? state.seedId : 0;
- final String personality = (state != null) ? state.personality : "neutral";
- final String reply = understand(player, bot, message, personality, seedId);
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
+ final Understanding understanding = FakePlayerUnderstanding.understand(ref, player, message, false);
+ if ((understanding != null) && understanding.silent)
+ {
+ return;
+ }
+ final String templateReply = ((understanding != null) && (understanding.responseLine != null)) ? understanding.responseLine : null;
+ final boolean useLlm = FakePlayerLlmService.getInstance().isEnabled() && ("llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY) || (templateReply == null));
+ final FakePlayerBotState state = stateOf(bot);
ThreadPool.schedule(() ->
{
- if (FakePlayerLlmService.getInstance().isEnabled())
+ if (bot.isDead() || (bot.calculateDistance2D(player) > 1500))
{
- final String hint = FakePlayerChatManager.intentHint(lastIntent(message));
- FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer -> say(bot, answer), () -> say(bot, reply));
+ return;
}
- else if (reply != null)
+ if (useLlm)
{
- say(bot, reply);
+ final String hint = (understanding != null) ? understanding.llmHint : "";
+ FakePlayerLlmService.getInstance().generate(persona(bot, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой: \"" + message + "\"." + (hint.isEmpty() ? "" : " (суть: " + hint + ")"), answer -> reply(bot, state, answer, "reply"), () -> reply(bot, state, templateReply, (understanding != null) ? understanding.intentKey : "reply"));
+ }
+ else
+ {
+ reply(bot, state, templateReply, (understanding != null) ? understanding.intentKey : "reply");
}
}, Rnd.get(3000, 8000));
}
-
- private String lastIntent(String message)
+
+ private void reply(Player bot, FakePlayerBotState state, String line, String seam)
{
- final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
- return (intent != null) ? intent.key : "";
+ if (line == null)
+ {
+ return;
+ }
+ say(bot, line, seam);
+ if (state != null)
+ {
+ state.repliesGiven.incrementAndGet();
+ state.action("reply");
+ }
}
-
+
/**
- * Applies the intent effects for a headless bot and returns the template reply.
- * @param player speaker.
- * @param bot headless bot.
- * @param message text.
- * @param personality bot personality.
- * @param seedId memory id.
- * @return reply line or null.
+ * Called from Player.doDie when a headless bot dies (any killer).
+ * @param bot the dead bot.
+ * @param killer the killer (may be null).
*/
- private String understand(Player player, Player bot, String message, String personality, int seedId)
+ public void onBotDied(Player bot, Creature killer)
{
- final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
- if (intent == null)
+ final FakePlayerBotState state = stateOf(bot);
+ if (state == null)
{
- return null;
+ return;
}
-
- String seam = intent.seam;
- final Map slots = new HashMap<>();
- slots.put("player", player.getName());
- final BotState state = _states.get(bot.getObjectId());
- slots.put("zone", ((state != null) && !state.zone.isEmpty()) ? state.zone : "поле");
- slots.put("level", String.valueOf(bot.getLevel()));
-
- switch (intent.key)
- {
- case "insult":
- {
- FakePlayerMemoryManager.getInstance().adjustScore(seedId, player.getName(), -1);
- final boolean aggressive = "ganker".equals(personality) || "pkk".equals(personality);
- seam = aggressive ? "reply_insult_aggro" : "reply_insult_soft";
- if (aggressive && (bot.calculateDistance2D(player) < 1500))
- {
- forceTarget(bot, player, 45);
- }
- break;
- }
- case "thanks":
- {
- FakePlayerMemoryManager.getInstance().adjustScore(seedId, player.getName(), 1);
- break;
- }
- case "ask_help":
- {
- final boolean willing = !"ganker".equals(personality) && (bot.calculateDistance2D(player) < 3000);
- seam = willing ? "reply_help_yes" : "reply_help_no";
- if (willing)
- {
- final WorldObject playerTarget = player.getTarget();
- if ((playerTarget instanceof Monster monster) && !monster.isDead())
- {
- bot.setTarget(monster);
- bot.getAI().setIntentionAttack(monster);
- }
- else
- {
- bot.getAI().setIntentionFollow(player);
- }
- }
- break;
- }
- case "ask_party":
- {
- // Real party: the bot invites the player back, the client shows a normal invite.
- seam = "ganker".equals(personality) ? "reply_party_no" : "party_accept";
- break;
- }
- case "ask_rumors":
- {
- final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(seedId);
- if (story != null)
- {
- return "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story);
- }
- break;
- }
- }
- return seam.isEmpty() ? null : FakePlayerChatLines.getInstance().compose(seedId, seam, personality, slots);
+ state.forcedTargetId = 0;
+ FakePlayerSocial.getInstance().onDied(bot, state, killer);
}
-
+
/**
- * Called from Player.doDie when a headless bot is killed by a player.
+ * Kept for older call sites: a player killed the bot.
* @param bot the dead bot.
* @param killer the killer.
*/
public void onBotKilled(Player bot, Player killer)
{
- final BotState state = _states.get(bot.getObjectId());
+ onBotDied(bot, killer);
+ }
+
+ /**
+ * Called from Attackable.doDie when a headless bot kills a monster.
+ * @param bot the bot.
+ * @param victim the monster.
+ */
+ public void onKilledMonster(Player bot, Creature victim)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.kills.incrementAndGet();
+ state.action("killed " + victim.getName());
+ }
+ }
+
+ /**
+ * Called by the brain after picking an item up.
+ * @param bot the bot.
+ * @param item the item.
+ */
+ public void onLootPicked(Player bot, Item item)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.lootPicked.incrementAndGet();
+ if (item.getId() != 57)
+ {
+ state.lastDrop = item.getName();
+ }
+ }
+ }
+
+ // ======================== Trading ========================
+
+ private void openStore(Player bot, long now)
+ {
+ final FakePlayerBotState state = stateOf(bot);
if (state == null)
{
return;
}
- FakePlayerMemoryManager.getInstance().onKilledBy(state.seedId, killer.getName());
- FakePlayerBotContext.setEvent(state.seedId, "ganked");
- final String deathCry = FakePlayerChatLines.getInstance().compose(state.seedId, "killed_by", state.personality, "player", killer.getName());
- if (deathCry != null)
- {
- ThreadPool.schedule(() ->
- {
- if (bot != null)
- {
- // The bot lies dead for a while - dead players still type.
- bot.broadcastPacket(new CreatureSay(bot, ChatType.GENERAL, bot.getName(), deathCry));
- FakePlayerHeardManager.getInstance().onSpeech(bot, deathCry, "killed_by");
- }
- }, Rnd.get(1500, 4000));
- }
-
- // Nearby bots witness the kill (rumor seed) - both headless and NPC ones.
- final String victimName = bot.getName();
- World.forEachVisibleObjectInRange(bot, Player.class, 900, witness ->
- {
- if (witness.isHeadlessBot() && (witness != bot))
- {
- final BotState witnessState = _states.get(witness.getObjectId());
- if (witnessState != null)
- {
- FakePlayerRumorManager.getInstance().addWitnessed(witnessState.seedId, (killer.getKarma() > 0) ? "pk" : "kill", killer.getName(), victimName);
- }
- }
- });
- }
-
- /**
- * Opens a real private sell store in Giran: gives the bot goods if the
- * inventory is empty, fills the trade list and sits down.
- * @param bot the headless player.
- * @param now current time.
- */
- /**
- * @param bot the bot.
- * @return farmed items the bot can put in a private store (no adena, nothing equipped).
- */
- private List sellableLoot(Player bot)
- {
- final List result = new ArrayList<>();
- for (org.l2jmobius.gameserver.entity.item.instance.Item item : bot.getInventory().getItems())
- {
- if ((item == null) || item.isEquipped() || (item.getId() == 57) || !item.isSellable() || !item.isTradeable())
- {
- continue;
- }
- result.add(item);
- }
- return result;
- }
-
- /**
- * Picks up loot dropped by this bot's kills, so its store has real goods.
- * @param bot the bot.
- */
- private void pickupLoot(Player bot)
- {
- World.forEachVisibleObjectInRange(bot, org.l2jmobius.gameserver.entity.item.instance.Item.class, 250, item ->
- {
- if (!item.isSpawned() || (bot.getInventory().getSize() > 60))
- {
- return;
- }
- final int owner = item.getOwnerId();
- if ((owner != 0) && (owner != bot.getObjectId()))
- {
- return; // Someone else's drop.
- }
- bot.doPickupItem(item);
- });
- }
-
- private void openStore(Player bot, long now)
- {
try
{
- // Stop whatever the bot was doing, then travel to the market.
+ if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null))
+ {
+ bot.leaveParty();
+ }
+ // Stop whatever the bot was doing, then travel to the market of its level.
bot.getAI().setIntentionActive();
bot.stopMove(null);
- teleport(bot, TRADE_SPOT.getX() + Rnd.get(-250, 250), TRADE_SPOT.getY() + Rnd.get(-250, 250), TRADE_SPOT.getZ());
- // Reserve the slot right away so the trader cap is honoured.
+ Location market = GIRAN;
+ final Object[] town = FakePlayerProgressionManager.getInstance().pickTownSpot(bot.getLevel(), state.race);
+ if (town != null)
+ {
+ market = (Location) town[0];
+ }
+ teleport(bot, market.getX() + Rnd.get(-250, 250), market.getY() + Rnd.get(-250, 250), market.getZ());
final long tradeFor = Rnd.get(1200000, 3600000);
- _tradeUntil.put(bot.getObjectId(), now + tradeFor);
- FakePlayerBotContext.setPlan(seedIdOf(bot), "trade", tradeFor);
- FakePlayerBotContext.setEvent(seedIdOf(bot), "opened_store");
- ThreadPool.schedule(() -> finishStore(bot), 1200);
-
+ state.tradeUntil = now + tradeFor;
+ FakePlayerBotContext.setPlan(state.seedId, "trade", tradeFor);
+ FakePlayerBotContext.setEvent(state.seedId, "opened_store");
+ ThreadPool.schedule(() -> finishStore(bot), 1500);
}
catch (Exception e)
{
- _tradeUntil.remove(bot.getObjectId());
- _nextTradeAt.put(bot.getObjectId(), now + 600000);
+ state.tradeUntil = 0;
+ state.nextTradeAt = now + 600000;
}
}
-
+
/**
* Second half of opening a store - runs after the teleport actually landed.
* @param bot the bot.
@@ -1041,45 +1119,51 @@ public class FakePlayerHeadlessManager
private void finishStore(Player bot)
{
final long now = System.currentTimeMillis();
+ final FakePlayerBotState state = stateOf(bot);
+ if (state == null)
+ {
+ return;
+ }
try
{
if (bot.isDead() || !bot.isSpawned())
{
- _tradeUntil.remove(bot.getObjectId());
- _nextTradeAt.put(bot.getObjectId(), now + 300000);
+ state.tradeUntil = 0;
+ state.nextTradeAt = now + 300000;
return;
}
-
- // Sell what was actually farmed.
- final List loot = sellableLoot(bot);
+ // In town: the junk goes to the merchant first, the good stuff to the store.
+ FakePlayerEconomy.getInstance().visitTown(bot, state);
+ final List- loot = FakePlayerEconomy.getInstance().storeGoods(bot);
if (loot.isEmpty())
{
- _tradeUntil.remove(bot.getObjectId());
- _nextTradeAt.put(bot.getObjectId(), now + 900000);
+ state.tradeUntil = 0;
+ state.nextTradeAt = now + 900000;
+ returnToSpot(bot);
return;
}
-
+
bot.getSellList().clear();
int listed = 0;
- for (org.l2jmobius.gameserver.entity.item.instance.Item item : loot)
+ for (Item item : loot)
{
- if (listed >= 6)
+ if (listed >= 8)
{
break;
}
final int base = Math.max(1, item.getReferencePrice());
final int price = (int) Math.min(Integer.MAX_VALUE, base * (80 + Rnd.get(90)) / 100L);
- bot.getSellList().addItem(item.getObjectId(), (int) Math.min(item.getCount(), 5000), price);
+ bot.getSellList().addItem(item.getObjectId(), Math.min(item.getCount(), 5000), price);
listed++;
}
if (listed == 0)
{
- _tradeUntil.remove(bot.getObjectId());
- _nextTradeAt.put(bot.getObjectId(), now + 900000);
+ state.tradeUntil = 0;
+ state.nextTradeAt = now + 900000;
+ returnToSpot(bot);
return;
}
- final BotState storeState = _states.get(bot.getObjectId());
- final String title = FakePlayerChatLines.getInstance().compose((storeState != null) ? storeState.seedId : 0, "store_title", (storeState != null) ? storeState.personality : "neutral", null);
+ final String title = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "store_title", null, FakePlayerEconomy.getInstance().storeSlots(loot));
final String caption = ((title != null) && !title.isEmpty()) ? title : STORE_MSGS[Rnd.get(STORE_MSGS.length)];
bot.getSellList().setTitle(caption);
bot.setStoreName(caption);
@@ -1087,14 +1171,43 @@ public class FakePlayerHeadlessManager
bot.setPrivateStoreType(PrivateStoreType.SELL);
bot.broadcastUserInfo();
// Without this the store caption stays invisible for everyone around.
- bot.broadcastPacket(new org.l2jmobius.gameserver.network.serverpackets.PrivateStoreMsgSell(bot));
+ bot.broadcastPacket(new PrivateStoreMsgSell(bot));
+ state.storesOpened.incrementAndGet();
+ state.action("store: " + caption);
+ // A trader shouts about it in the trade channel once in a while.
+ final String shout = FakePlayerUnderstanding.optionalLine(FakePlayerBotRef.ofHeadless(bot), "trade_shout", null, FakePlayerEconomy.getInstance().storeSlots(loot));
+ if (shout != null)
+ {
+ ThreadPool.schedule(() -> trade(bot, shout, "trade_shout"), Rnd.get(10000, 40000));
+ }
}
catch (Exception e)
{
- _nextTradeAt.put(bot.getObjectId(), now + 600000);
+ state.nextTradeAt = now + 600000;
}
}
-
+
+ /**
+ * Closes the private store of a bot if it has one (party, siege, migration).
+ * @param bot the bot.
+ */
+ public void closeStore(Player bot)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.tradeUntil = 0;
+ }
+ if (bot.isInStoreMode() || bot.isSitting())
+ {
+ bot.setPrivateStoreType(PrivateStoreType.NONE);
+ bot.standUp();
+ bot.broadcastUserInfo();
+ }
+ }
+
+ // ======================== Movement ========================
+
/**
* Teleports a headless bot and finishes the teleport by hand: a normal player
* returns to the world when its client confirms the teleport, a headless one
@@ -1120,12 +1233,34 @@ public class FakePlayerHeadlessManager
}
}, 500);
}
-
- private void teleport(Player bot, Location location)
+
+ public void teleport(Player bot, Location location)
{
teleport(bot, location.getX(), location.getY(), location.getZ());
}
-
+
+ /**
+ * Держит бота на цели, чтобы рутинный фарм не перебивал ганг.
+ * @param bot the bot.
+ * @param target the victim.
+ * @param seconds focus duration.
+ */
+ public void forceTarget(Player bot, Player target, int seconds)
+ {
+ final FakePlayerBotState state = stateOf(bot);
+ if (state != null)
+ {
+ state.forcedTargetId = target.getObjectId();
+ state.forcedUntil = System.currentTimeMillis() + (seconds * 1000L);
+ }
+ closeStore(bot);
+ bot.setTarget(target);
+ bot.setRunning();
+ bot.getAI().setIntentionAttack(target);
+ }
+
+ // ======================== Gear ========================
+
/**
* Выдаёт боту скиллы его класса и экипировку по уровню: сид-персонажи приходят
* из базы голыми и без скиллов, поэтому раньше они дрались кулаками.
@@ -1137,41 +1272,54 @@ public class FakePlayerHeadlessManager
{
bot.giveAvailableSkills(true, true, true);
bot.sendSkillList();
-
+
// Снимаем всё, что не по уровню (например, после пересчёта уровня из опыта).
- for (org.l2jmobius.gameserver.entity.item.instance.Item worn : bot.getInventory().getItems())
+ for (Item worn : bot.getInventory().getItems())
{
if (worn.isEquipped() && (worn.getTemplate().getCrystalType().getLevel() > gradeForLevel(bot.getLevel())))
{
bot.getInventory().unEquipItemInSlot(worn.getLocationSlot());
}
}
-
+
final int tier = Math.min(7, FakePlayerProgressionManager.tierOf(bot.getLevel()));
final boolean mage = bot.isMageClass();
final int classId = bot.getPlayerClass().getId();
final boolean archer = (classId == 9) || (classId == 24) || (classId == 37) || (classId == 92) || (classId == 102) || (classId == 109);
final boolean dagger = (classId == 8) || (classId == 23) || (classId == 36) || (classId == 93) || (classId == 101) || (classId == 108);
final boolean blunt = (classId == 15) || (classId == 16) || (classId == 30) || (classId == 97) || (classId == 105) || (classId == 116);
-
+
final int weaponId = mage ? STAVES[tier] : archer ? BOWS[tier] : dagger ? DAGGERS[tier] : blunt ? BLUNTS[tier] : SWORDS[tier];
final int chestId = mage ? ROBE_CHEST[tier] : (archer || dagger) ? LIGHT_CHEST[tier] : HEAVY_CHEST[tier];
final boolean onepiece = mage ? ROBE_ONEPIECE[tier] : (archer || dagger) ? LIGHT_ONEPIECE[tier] : HEAVY_ONEPIECE[tier];
final int legsId = mage ? ROBE_LEGS[tier] : (archer || dagger) ? LIGHT_LEGS[tier] : HEAVY_LEGS[tier];
-
+
giveAndWear(bot, weaponId);
giveAndWear(bot, chestId);
if (!onepiece)
{
giveAndWear(bot, legsId);
}
-
- // Шоты того же грейда, что оружие - иначе просто не сработают.
+
+ // Шоты того же грейда, что оружие - иначе просто не сработают. Стартовый запас, дальше покупает сам.
final int shotId = mage ? SPIRITSHOTS[tier] : SOULSHOTS[tier];
if (bot.getInventory().getItemByItemId(shotId) == null)
{
- bot.addItem(ItemProcessType.NONE, shotId, 5000, bot, false);
+ bot.addItem(ItemProcessType.NONE, shotId, 1500, bot, false);
}
+ if (archer && (bot.getInventory().getItemByItemId(ARROWS[tier]) == null))
+ {
+ final Item arrows = bot.addItem(ItemProcessType.NONE, ARROWS[tier], 2000, bot, false);
+ if ((arrows != null) && !arrows.isEquipped())
+ {
+ bot.useEquippableItem(arrows, false);
+ }
+ }
+ bot.getAutoSoulShot().clear();
+ bot.addAutoSoulShot(shotId);
+ // Jewelry of the grade and the usual town buffs: without them a real player of this level dies too.
+ FakePlayerEconomy.getInstance().wearJewelry(bot, gradeForLevel(bot.getLevel()));
+ FakePlayerEconomy.getInstance().buffUp(bot);
bot.refreshExpertisePenalty();
bot.broadcastUserInfo();
}
@@ -1180,7 +1328,7 @@ public class FakePlayerHeadlessManager
LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not equip " + bot.getName(), e);
}
}
-
+
/**
* @param level character level.
* @return highest crystal grade level the character may wear without penalty.
@@ -1209,14 +1357,14 @@ public class FakePlayerHeadlessManager
}
return 0; // NG
}
-
+
private void giveAndWear(Player bot, int itemId)
{
if (itemId <= 0)
{
return;
}
- org.l2jmobius.gameserver.entity.item.instance.Item item = bot.getInventory().getItemByItemId(itemId);
+ Item item = bot.getInventory().getItemByItemId(itemId);
if (item == null)
{
item = bot.addItem(ItemProcessType.NONE, itemId, 1, bot, false);
@@ -1226,25 +1374,9 @@ public class FakePlayerHeadlessManager
bot.useEquippableItem(item, false);
}
}
-
- /**
- * Держит бота на цели, чтобы рутинный фарм не перебивал ганг.
- * @param bot the bot.
- * @param target the victim.
- * @param seconds focus duration.
- */
- private void forceTarget(Player bot, Player target, int seconds)
- {
- final BotState state = _states.get(bot.getObjectId());
- if (state != null)
- {
- state.forcedTargetId = target.getObjectId();
- state.forcedUntil = System.currentTimeMillis() + (seconds * 1000L);
- }
- bot.setTarget(target);
- bot.getAI().setIntentionAttack(target);
- }
-
+
+ // ======================== Debug ========================
+
private Player findBot(String name)
{
for (Player bot : _bots)
@@ -1256,7 +1388,7 @@ public class FakePlayerHeadlessManager
}
return null;
}
-
+
/**
* Debug commands for the dashboard: force behaviors without waiting for timers.
* @param command command name.
@@ -1280,7 +1412,8 @@ public class FakePlayerHeadlessManager
}
}
}
-
+ final FakePlayerBotState state = stateOf(bot);
+
switch (command)
{
case "list":
@@ -1293,20 +1426,35 @@ public class FakePlayerHeadlessManager
{
break;
}
- final BotState state = _states.get(player.getObjectId());
- sb.append(player.getName()).append(" lvl").append(player.getLevel()).append(' ').append((state != null) ? state.personality : "?").append(' ').append((state != null) ? state.zone : "").append(player.isDead() ? " [dead]" : "").append('\n');
+ final FakePlayerBotState playerState = _states.get(player.getObjectId());
+ sb.append(player.getName()).append(" lvl").append(player.getLevel()).append(' ').append(player.getPlayerClass()).append(' ').append((playerState != null) ? playerState.personality : "?").append(' ').append((playerState != null) ? playerState.zone : "").append(player.isDead() ? " [dead]" : (" hp" + (int) (player.getCurrentHp() * 100 / Math.max(1, player.getMaxHp())) + "%")).append(" k").append((playerState != null) ? playerState.kills.get() : 0).append(" d").append((playerState != null) ? playerState.deaths.get() : 0).append(" - ").append((playerState != null) ? playerState.lastAction : "").append('\n');
}
return "headless: " + _bots.size() + "\n" + sb;
}
+ case "stats":
+ {
+ if (!name.isEmpty() && (bot != null) && (state != null))
+ {
+ return FakePlayerTelemetry.getInstance().statsText(bot, state);
+ }
+ return FakePlayerTelemetry.getInstance().statsText();
+ }
case "trade":
{
- if (bot == null)
+ if ((bot == null) || (state == null))
{
return "bot not found";
}
- _tradeUntil.remove(bot.getObjectId());
+ state.tradeUntil = 0;
+ if (FakePlayerEconomy.getInstance().storeGoods(bot).isEmpty())
+ {
+ // Debug convenience: give the bot something to sell.
+ bot.addItem(ItemProcessType.NONE, 1463, 300, bot, false);
+ bot.addItem(ItemProcessType.NONE, 1874, 12, bot, false); // Oriharukon Ore
+ bot.addItem(ItemProcessType.NONE, 1867, 40, bot, false); // Animal Skin
+ }
openStore(bot, now);
- return bot.getName() + ": лавка открыта в Гиране";
+ return bot.getName() + ": лавка открыта (" + FakePlayerEconomy.getInstance().storeGoods(bot).size() + " позиций)";
}
case "tradeall":
{
@@ -1314,15 +1462,18 @@ public class FakePlayerHeadlessManager
final int limit = argument.isEmpty() ? 10 : Math.min(50, Integer.parseInt(argument));
for (Player player : _bots)
{
- if ((count >= limit) || player.isDead() || (player.getParty() != null))
+ final FakePlayerBotState playerState = _states.get(player.getObjectId());
+ if ((count >= limit) || player.isDead() || (player.getParty() != null) || (playerState == null) || (playerState.tradeUntil > 0))
{
continue;
}
- if (!_tradeUntil.containsKey(player.getObjectId()))
+ if (FakePlayerEconomy.getInstance().storeGoods(player).isEmpty())
{
- openStore(player, now);
- count++;
+ player.addItem(ItemProcessType.NONE, 1463, 300, player, false);
+ player.addItem(ItemProcessType.NONE, 1874, 12, player, false);
}
+ openStore(player, now);
+ count++;
}
return "лавок открыто: " + count;
}
@@ -1332,10 +1483,7 @@ public class FakePlayerHeadlessManager
{
return "bot not found";
}
- _tradeUntil.remove(bot.getObjectId());
- bot.setPrivateStoreType(PrivateStoreType.NONE);
- bot.standUp();
- bot.broadcastUserInfo();
+ closeStore(bot);
return bot.getName() + ": лавка закрыта";
}
case "come":
@@ -1366,9 +1514,23 @@ public class FakePlayerHeadlessManager
{
bot.joinParty(target.getParty());
}
- say(bot, FakePlayerChatLines.getInstance().compose(0, "party_accept", "neutral", null), "party_accept");
+ onJoinedParty(bot);
+ say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "party_accept", target, null), "party_accept");
return bot.getName() + " в пати с " + target.getName();
}
+ case "invite":
+ {
+ final Player target = World.getPlayer(argument);
+ if ((bot == null) || (target == null))
+ {
+ return "usage: invite&arg=";
+ }
+ teleport(bot, target.getLocation());
+ final Player inviter = bot;
+ ThreadPool.schedule(() -> FakePlayerSocial.getInstance().inviteToParty(inviter, target), 1500);
+ say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "invite_player", target, null), "invite_player");
+ return bot.getName() + " зовёт " + target.getName() + " в пати (окно приглашения)";
+ }
case "gank":
{
final Player target = World.getPlayer(argument);
@@ -1379,7 +1541,7 @@ public class FakePlayerHeadlessManager
final Player ganker = bot;
teleport(ganker, target.getX() + 150, target.getY() + 150, target.getZ());
ThreadPool.schedule(() -> forceTarget(ganker, target, 90), 1400);
- say(bot, FakePlayerChatLines.getInstance().compose(0, "gank_start", "ganker", "target", target.getName()), "gank_start");
+ say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "gank_start", target, Map.of("target", target.getName())), "gank_start");
return bot.getName() + " атакует " + target.getName();
}
case "equip":
@@ -1419,7 +1581,7 @@ public class FakePlayerHeadlessManager
return "bot not found";
}
final StringBuilder sb = new StringBuilder(bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + "\n");
- for (org.l2jmobius.gameserver.entity.item.instance.Item item : bot.getInventory().getItems())
+ for (Item item : bot.getInventory().getItems())
{
if (item.isEquipped())
{
@@ -1427,6 +1589,7 @@ public class FakePlayerHeadlessManager
}
}
sb.append(" штраф экспертизы: ").append(bot.getExpertisePenaltyBonus());
+ sb.append("\n автошоты: ").append(bot.getAutoSoulShot()).append(", адена: ").append(bot.getAdena());
return sb.toString();
}
case "levelup":
@@ -1440,38 +1603,76 @@ public class FakePlayerHeadlessManager
}
case "migrate":
{
- if (bot == null)
+ if ((bot == null) || (state == null))
{
return "bot not found";
}
- final BotState state = _states.get(bot.getObjectId());
- final Object[] spot = FakePlayerProgressionManager.getInstance().pickSpot(bot.getLevel(), (state != null) ? state.race : "");
- if (spot == null)
+ migrate(bot, state, true);
+ return bot.getName() + " переезжает (прощание, город, новый спот)";
+ }
+ case "town":
+ {
+ if ((bot == null) || (state == null))
{
- return "no zone";
+ return "bot not found";
}
- teleport(bot, (Location) spot[0]);
- if (state != null)
+ return bot.getName() + ": " + FakePlayerEconomy.getInstance().visitTown(bot, state);
+ }
+ case "profession":
+ {
+ if ((bot == null) || (state == null))
{
- state.zone = (String) spot[1];
+ return "bot not found";
}
- say(bot, FakePlayerChatLines.getInstance().compose(0, "migrate", (state != null) ? state.personality : "neutral", null), "migrate");
- return bot.getName() + " переехал в " + ((state != null) ? state.zone : "");
+ final Object result = FakePlayerEconomy.getInstance().checkProfession(bot, state);
+ return bot.getName() + ": " + ((result != null) ? ("новая профа " + result) : ("профа не положена (lvl " + bot.getLevel() + ", " + bot.getPlayerClass() + ")"));
}
case "say":
{
- if (bot == null)
+ if ((bot == null) || (state == null))
{
return "bot not found";
}
if (argument.isEmpty())
{
- eventChat(bot, "Скажи что-нибудь бытовое про фарм.", "ambient", null, null);
+ FakePlayerSocial.getInstance().eventChat(bot, state, "Скажи что-нибудь бытовое про фарм.", "ambient", null, null);
return bot.getName() + ": сказал реплику из шва ambient";
}
say(bot, argument);
return bot.getName() + ": " + argument;
}
+ case "talk":
+ {
+ // Two bots talk to each other in front of you: talk&name=&arg=
+ if ((bot == null) || (state == null))
+ {
+ return "bot not found";
+ }
+ final Player target = World.getPlayer(argument);
+ Player peer = null;
+ for (Player candidate : _bots)
+ {
+ if ((candidate != bot) && !candidate.isDead() && (candidate.getParty() == null))
+ {
+ peer = candidate;
+ break;
+ }
+ }
+ if (peer == null)
+ {
+ return "no second bot";
+ }
+ if (target != null)
+ {
+ teleport(bot, target.getX() + 100, target.getY() + 100, target.getZ());
+ teleport(peer, target.getX() - 100, target.getY() + 100, target.getZ());
+ }
+ final Player speaker = bot;
+ final Player listener = peer;
+ final String opener = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(speaker), "bot_talk", listener, null);
+ ThreadPool.schedule(() -> FakePlayerSocial.getInstance().converse(speaker, listener, (opener != null) ? opener : ("как фарм, " + listener.getName()), "bot_talk", 1), 2000);
+ return speaker.getName() + " заговорит с " + listener.getName();
+ }
case "kill":
{
if (bot == null)
@@ -1484,15 +1685,10 @@ public class FakePlayerHeadlessManager
}
case "rumor":
{
- if ((bot == null) || argument.isEmpty())
+ if ((bot == null) || (state == null) || argument.isEmpty())
{
return "usage: rumor&arg=";
}
- final BotState state = _states.get(bot.getObjectId());
- if (state == null)
- {
- return "no state";
- }
FakePlayerRumorManager.getInstance().addWitnessed(state.seedId, "kill", argument, "кого-то");
return bot.getName() + " теперь помнит слух про " + argument;
}
@@ -1510,10 +1706,34 @@ public class FakePlayerHeadlessManager
ClanTable.getInstance().storeClanWars(bot.getClanId(), target.getClanId());
return "война: " + bot.getClan().getName() + " против " + target.getClan().getName();
}
+ case "siege":
+ {
+ return FakePlayerSiegeManager.getInstance().forceSiege(argument.isEmpty() ? "Giran" : argument);
+ }
+ case "siegeend":
+ {
+ return FakePlayerSiegeManager.getInstance().endSiege(argument.isEmpty() ? "Giran" : argument);
+ }
+ case "siegeinfo":
+ {
+ return FakePlayerSiegeManager.getInstance().info();
+ }
+ case "online":
+ {
+ try
+ {
+ final Player extra = ensureOnline(Integer.parseInt(argument));
+ return (extra != null) ? (extra.getName() + " в мире") : "не удалось (уже онлайн или нет такого charId)";
+ }
+ catch (NumberFormatException e)
+ {
+ return "usage: online&arg=";
+ }
+ }
}
return "unknown command";
}
-
+
/**
* @return JSON array of headless bots for the dashboard.
*/
@@ -1523,7 +1743,7 @@ public class FakePlayerHeadlessManager
boolean first = true;
for (Player bot : _bots)
{
- final BotState state = _states.get(bot.getObjectId());
+ final FakePlayerBotState state = _states.get(bot.getObjectId());
if (!first)
{
sb.append(',');
@@ -1534,7 +1754,11 @@ public class FakePlayerHeadlessManager
{
activity = "dead";
}
- else if (_tradeUntil.containsKey(bot.getObjectId()))
+ else if ((state != null) && (state.siegeSide != 0))
+ {
+ activity = "siege";
+ }
+ else if ((state != null) && (state.tradeUntil > 0))
{
activity = "trading";
}
@@ -1546,6 +1770,10 @@ public class FakePlayerHeadlessManager
{
activity = "combat";
}
+ else if (bot.isSitting())
+ {
+ activity = "resting";
+ }
else if (bot.isMoving())
{
activity = "moving";
@@ -1559,25 +1787,22 @@ public class FakePlayerHeadlessManager
.append("\",\"clanId\":").append(bot.getClanId())
.append(",\"zone\":\"").append((state != null) ? state.zone : "")
.append("\",\"activity\":\"").append(activity)
+ .append("\",\"action\":\"").append((state != null) ? state.lastAction.replace('"', ' ') : "")
.append("\",\"target\":\"").append(target.replace('"', ' '))
.append("\",\"hp\":").append((int) (bot.getCurrentHp() * 100 / Math.max(1, bot.getMaxHp())))
+ .append(",\"kills\":").append((state != null) ? state.kills.get() : 0)
.append(",\"headless\":true")
.append(",\"x\":").append(bot.getX()).append(",\"y\":").append(bot.getY()).append(",\"z\":").append(bot.getZ())
.append('}');
}
return sb.toString();
}
-
- public int size()
- {
- return _bots.size();
- }
-
+
public static FakePlayerHeadlessManager getInstance()
{
return SingletonHolder.INSTANCE;
}
-
+
private static class SingletonHolder
{
protected static final FakePlayerHeadlessManager INSTANCE = new FakePlayerHeadlessManager();
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java
index 966876e..82af07e 100644
--- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerHeardManager.java
@@ -48,13 +48,15 @@ public class FakePlayerHeardManager
private static final int PICKUP_CHANCE = 10; // Percent.
private static final int GRATS_CHANCE = 12; // Percent, per neighbour.
private static final int GRATS_MAX = 2;
- private static final long GLOBAL_THROTTLE = 8000; // At most one pickup per N ms server-wide.
+ private static final long GLOBAL_THROTTLE = 2500; // At most one pickup per N ms server-wide.
+ private static final long LISTENER_COOLDOWN = 25000; // A bot picks something up at most this often.
private static final Pattern QUESTION = Pattern.compile("^(кто|где|когда|скок|сколько|почем)\\b.*|.*\\?.*", Pattern.UNICODE_CHARACTER_CLASS);
private static final Pattern COMMERCIAL = Pattern.compile(".*(лавка|продаю|продает|скупа|распродажа|налетай|торгую|акци|цены|меняю|покупай|торговец|зацени|ждет (своего )?хозяина|открыл точку|ищу (кп|пати)|лфп|нужн|формирую пати|собран|собираю|стучите|(кп|пати).{0,25}\\bго\\b|\\bго\\b.{0,25}(кп|пати)|за \\d+к|по \\d+к|\\d+ ?кк?\\b|лям|сотк|грейд|соски|шоты|бсое|свитки|кристалл|рецепт|уголь|бижа|банки|дешевле|отдам|вх\\b|под завязку|гк вызову).*", Pattern.UNICODE_CHARACTER_CLASS);
private static final Pattern LEVELUP = Pattern.compile(".*\\b\\d{1,2}\\b.*", Pattern.UNICODE_CHARACTER_CLASS);
private final AtomicLong _lastPickup = new AtomicLong();
+ private final Map _listenerCooldown = new java.util.concurrent.ConcurrentHashMap<>();
protected FakePlayerHeardManager()
{
@@ -101,7 +103,7 @@ public class FakePlayerHeardManager
// The seam is the ground truth; heuristics only cover seamless (LLM) lines.
final boolean known = (seam != null) && !seam.isEmpty();
final boolean levelup = known ? "levelup".equals(seam) : isLevelupLine(text);
- final boolean commercial = known ? ("trade_shout".equals(seam) || "lfp".equals(seam) || "store_title".equals(seam) || "call_help".equals(seam) || "grats".equals(seam) || "overheard".equals(seam) || "overheard_q".equals(seam)) : isCommercialLine(text);
+ final boolean commercial = known ? ("trade_shout".equals(seam) || "lfp".equals(seam) || "store_title".equals(seam) || "call_help".equals(seam) || "grats".equals(seam) || "overheard".equals(seam) || "overheard_q".equals(seam) || "dialogue".equals(seam) || "bot_talk".equals(seam) || "reply".equals(seam) || "invite_player".equals(seam) || "party_accept".equals(seam) || seam.startsWith("siege_") || seam.startsWith("reply_")) : isCommercialLine(text);
if (!levelup && commercial)
{
return; // Nobody "agrees" with an advertisement (or a pickup - depth 1).
@@ -124,14 +126,14 @@ public class FakePlayerHeardManager
final List listeners = new ArrayList<>();
for (Npc npc : World.getVisibleObjectsInRange(speaker, Npc.class, HEAR_RANGE))
{
- if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead())
+ if ((npc != speaker) && npc.getTemplate().isFakePlayer() && !npc.isDead() && !onCooldown(npc.getObjectId(), now))
{
listeners.add(npc);
}
}
for (Player player : World.getVisibleObjectsInRange(speaker, Player.class, HEAR_RANGE))
{
- if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead())
+ if ((player != speaker) && FakePlayerHeadlessManager.getInstance().isBot(player) && !player.isDead() && !player.isInStoreMode() && !onCooldown(player.getObjectId(), now))
{
listeners.add(player);
}
@@ -163,8 +165,30 @@ public class FakePlayerHeardManager
}
final Creature listener = listeners.get(Rnd.get(listeners.size()));
+ _listenerCooldown.put(listener.getObjectId(), now);
+ if (_listenerCooldown.size() > 2000)
+ {
+ _listenerCooldown.entrySet().removeIf(entry -> (now - entry.getValue()) > LISTENER_COOLDOWN);
+ }
final boolean question = isQuestionLine(text);
- final Map slots = new HashMap<>(1);
+ final Map slots = new HashMap<>(2);
+ slots.put("msg", echoOf(text));
+ slots.put("player", speaker.getName());
+ scheduleReply(listener, question ? "overheard_q" : "overheard", slots);
+ }
+
+ private boolean onCooldown(int objectId, long now)
+ {
+ final Long last = _listenerCooldown.get(objectId);
+ return (last != null) && ((now - last) < LISTENER_COOLDOWN);
+ }
+
+ /**
+ * @param text a said line.
+ * @return its first words, the way a neighbour echoes a topic ("да, {msg}, точно").
+ */
+ public static String echoOf(String text)
+ {
final String[] words = text.split("\\s+");
final StringBuilder echo = new StringBuilder();
for (int i = 0; (i < words.length) && (i < 3); i++)
@@ -175,8 +199,7 @@ public class FakePlayerHeardManager
}
echo.append(words[i]);
}
- slots.put("msg", echo.toString());
- scheduleReply(listener, question ? "overheard_q" : "overheard", slots);
+ return echo.toString();
}
private void scheduleReply(Creature listener, String seam, Map slots)
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java
index 61c528f..5f608d0 100644
--- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerIntentParser.java
@@ -173,21 +173,31 @@ public class FakePlayerIntentParser implements IXmlReader
{
continue;
}
+ // Each group needs its own token: "слышь" alone must not satisfy both halves of "что нового".
+ final boolean[] used = new boolean[tokens.length];
boolean all = true;
for (String[] group : intent.groups)
{
boolean any = false;
- for (String token : tokens)
+ for (int index = 0; index < tokens.length; index++)
{
- if (token.isEmpty())
+ final String token = tokens[index];
+ if (token.isEmpty() || used[index])
{
continue;
}
for (String stem : group)
{
- if (token.startsWith(stem) || ((stem.length() > 3) && fuzzyPrefix(token, stem)))
+ if (matches(token, stem))
{
any = true;
+ used[index] = true;
+ // The same word appears twice (raw and phonetic): consume both copies.
+ final int twin = (index < rawTokens.length) ? (index + rawTokens.length) : (index - rawTokens.length);
+ if ((twin >= 0) && (twin < tokens.length) && tokens[twin].equals(token))
+ {
+ used[twin] = true;
+ }
break;
}
}
@@ -210,6 +220,26 @@ public class FakePlayerIntentParser implements IXmlReader
return null;
}
+ /**
+ * @param token message token.
+ * @param stem lexicon stem; a leading "=" means exact prefix only (no typo tolerance).
+ * @return true when the token matches the stem.
+ */
+ private static boolean matches(String token, String stem)
+ {
+ if (stem.startsWith("="))
+ {
+ return token.startsWith(stem.substring(1));
+ }
+ if (token.startsWith(stem))
+ {
+ return true;
+ }
+ // Typo tolerance only for longer stems and for tokens that could be the same word:
+ // "молодая" is not a typo of "соло", "думаешь" is not "дурак".
+ return (stem.length() >= 5) && (token.length() <= (stem.length() + 3)) && fuzzyPrefix(token, stem);
+ }
+
/**
* Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein
* distance 1 of the stem ("помгите" matches stem "помо").
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java
index 6be2666..af95b4f 100644
--- a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerProgressionManager.java
@@ -1300,6 +1300,26 @@ public class FakePlayerProgressionManager implements IXmlReader
};
}
+ /**
+ * Picks a town for a level and race (the town a character of that level would trade in).
+ * @param level character level.
+ * @param race race name.
+ * @return location and town name, or null.
+ */
+ public Object[] pickTownSpot(int level, String race)
+ {
+ final ZoneInfo town = pickTown(level, race);
+ if (town == null)
+ {
+ return null;
+ }
+ return new Object[]
+ {
+ town.points.get(Rnd.get(town.points.size())),
+ town.name
+ };
+ }
+
public static FakePlayerProgressionManager getInstance()
{
return SingletonHolder.INSTANCE;
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java
new file mode 100644
index 0000000..04d7088
--- /dev/null
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSiegeManager.java
@@ -0,0 +1,732 @@
+/*
+ * Copyright (c) 2013 L2jMobius
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+ * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.l2jmobius.gameserver.managers;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.l2jmobius.commons.threads.ThreadPool;
+import org.l2jmobius.commons.util.Rnd;
+import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
+import org.l2jmobius.gameserver.data.sql.ClanTable;
+import org.l2jmobius.gameserver.entity.Location;
+import org.l2jmobius.gameserver.entity.World;
+import org.l2jmobius.gameserver.entity.WorldObject;
+import org.l2jmobius.gameserver.entity.actor.Player;
+import org.l2jmobius.gameserver.entity.actor.instance.Artefact;
+import org.l2jmobius.gameserver.entity.actor.instance.Door;
+import org.l2jmobius.gameserver.entity.clan.Clan;
+import org.l2jmobius.gameserver.geoengine.GeoEngine;
+import org.l2jmobius.gameserver.mechanics.siege.Castle;
+import org.l2jmobius.gameserver.mechanics.siege.Siege;
+import org.l2jmobius.gameserver.mechanics.siege.SiegeClan;
+import org.l2jmobius.gameserver.mechanics.skill.CommonSkill;
+import org.l2jmobius.gameserver.mechanics.skill.Skill;
+
+/**
+ * Castle sieges played by the bot clans with the real siege engine:
+ *
+ * - while registration is open, bot clans with enough high level members online register as attackers of
+ * castles owned by bot clans (and, when allowed, of castles owned by real players);
+ * - when the siege starts, attackers gather outside the outer gate and defenders inside by the holy artifact;
+ * - attackers fight defenders, break the gates, walk to the artifact and their clan leader engraves it with Seal
+ * of Ruler (3 minutes of casting the others have to cover); defenders hold the artifact and hunt attackers;
+ * - battle cries at the start, at the gate, on engraving, and win / lose lines at the end; the castle really
+ * changes hands because the engine does the engraving, not us.
+ *
+ * Everything runs on the real siege schedule (SiegeSchedule.xml); do=siege&arg=Giran forces one right now.
+ * @author Mobius SPP
+ */
+public class FakePlayerSiegeManager
+{
+ private static final Logger LOGGER = Logger.getLogger(FakePlayerSiegeManager.class.getName());
+
+ private static final long TICK = 10000;
+ private static final int MAX_PER_CLAN = 12;
+ private static final int MIN_LEVEL = 40;
+ private static final int MIN_MEMBERS = 3;
+
+ private static class Battle
+ {
+ Siege siege;
+ Castle castle;
+ int firstOwnerId;
+ Location inner; // By the artifact.
+ Location staging; // Outside the outer gate.
+ final Set attackerClans = new HashSet<>();
+ final Set defenderClans = new HashSet<>();
+ final List participants = new ArrayList<>();
+ final Map lastCry = new HashMap<>();
+ boolean engraveAnnounced;
+ }
+
+ private final Map _battles = new ConcurrentHashMap<>();
+ private final Map _lastRegisterTry = new ConcurrentHashMap<>();
+
+ protected FakePlayerSiegeManager()
+ {
+ if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || !FakePlayersConfig.FAKE_PLAYER_SIEGES || (FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT <= 0))
+ {
+ LOGGER.info(getClass().getSimpleName() + ": Disabled.");
+ return;
+ }
+ ThreadPool.scheduleAtFixedRate(this::tick, 60000, TICK);
+ LOGGER.info(getClass().getSimpleName() + ": Bot clans will register for castle sieges (" + FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACKER_CLANS + " attacker clans per castle).");
+ }
+
+ private void tick()
+ {
+ try
+ {
+ for (Siege siege : SiegeManager.getInstance().getSieges())
+ {
+ final int castleId = siege.getCastle().getResidenceId();
+ final Battle battle = _battles.get(castleId);
+ if (siege.isInProgress())
+ {
+ if (battle == null)
+ {
+ onSiegeStarted(siege);
+ }
+ }
+ else
+ {
+ if (battle != null)
+ {
+ onSiegeEnded(battle);
+ }
+ else
+ {
+ maybeRegister(siege);
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": tick failed.", e);
+ }
+ }
+
+ // ======================== Registration ========================
+
+ private static boolean isBotClan(int clanId)
+ {
+ return (clanId >= 300000001) && (clanId <= 300000999);
+ }
+
+ /**
+ * Registers bot clans as attackers while registration is open. Retried every 10 minutes.
+ * @param siege the siege.
+ */
+ private void maybeRegister(Siege siege)
+ {
+ final int castleId = siege.getCastle().getResidenceId();
+ final long now = System.currentTimeMillis();
+ final Long last = _lastRegisterTry.get(castleId);
+ if ((last != null) && ((now - last) < 600000))
+ {
+ return;
+ }
+ _lastRegisterTry.put(castleId, now);
+ if (siege.isRegistrationOver())
+ {
+ return;
+ }
+ final int ownerId = siege.getCastle().getOwnerId();
+ if ((ownerId > 0) && !isBotClan(ownerId) && !FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES)
+ {
+ return; // A real clan's castle stays in peace unless allowed.
+ }
+ int registered = 0;
+ for (SiegeClan siegeClan : siege.getAttackerClans())
+ {
+ if (isBotClan(siegeClan.getClanId()))
+ {
+ registered++;
+ }
+ }
+ final int wanted = FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACKER_CLANS - registered;
+ if (wanted <= 0)
+ {
+ return;
+ }
+ // Candidate clans: enough strong members online, no castle, not registered anywhere else that day.
+ final Map> byClan = membersByClan();
+ final List candidates = new ArrayList<>();
+ for (Map.Entry> entry : byClan.entrySet())
+ {
+ final int clanId = entry.getKey();
+ final Clan clan = ClanTable.getInstance().getClan(clanId);
+ if ((clan == null) || (clanId == ownerId) || (clan.getCastleId() > 0) || (entry.getValue().size() < MIN_MEMBERS))
+ {
+ continue;
+ }
+ if ((ownerId > 0) && (clan.getAllyId() != 0))
+ {
+ final Clan owner = ClanTable.getInstance().getClan(ownerId);
+ if ((owner != null) && (owner.getAllyId() == clan.getAllyId()))
+ {
+ continue; // Allies do not attack each other.
+ }
+ }
+ if (SiegeManager.getInstance().checkIsRegistered(clan, castleId) || siege.checkIfAlreadyRegisteredForSameDay(clan))
+ {
+ continue;
+ }
+ candidates.add(clanId);
+ }
+ if (candidates.isEmpty())
+ {
+ return;
+ }
+ // Deterministic per castle and siege date, so the same clans keep coming back to "their" castle.
+ final long seed = castleId * 31L + (siege.getSiegeDate().getTimeInMillis() / 86400000L);
+ candidates.sort((a, b) -> Integer.compare(FakePlayerIdiolect.hash32(seed + ":" + a), FakePlayerIdiolect.hash32(seed + ":" + b)));
+ int done = 0;
+ for (int clanId : candidates)
+ {
+ if (done >= wanted)
+ {
+ break;
+ }
+ final Player member = byClan.get(clanId).get(0);
+ siege.registerAttacker(member, true);
+ if (SiegeManager.getInstance().checkIsRegistered(member.getClan(), castleId))
+ {
+ done++;
+ final Clan clan = ClanTable.getInstance().getClan(clanId);
+ FakePlayerHeadlessManager.getInstance().ensureOnline(clan.getLeaderId());
+ LOGGER.info(getClass().getSimpleName() + ": " + clan.getName() + " registered to attack " + siege.getCastle().getName() + " (siege " + siege.getSiegeDate().getTime() + ").");
+ }
+ }
+ if ((done > 0) && (ownerId > 0) && isBotClan(ownerId))
+ {
+ // The defending lord should be there too.
+ final Clan owner = ClanTable.getInstance().getClan(ownerId);
+ if (owner != null)
+ {
+ FakePlayerHeadlessManager.getInstance().ensureOnline(owner.getLeaderId());
+ }
+ }
+ }
+
+ private Map> membersByClan()
+ {
+ final Map> byClan = new HashMap<>();
+ for (Player bot : FakePlayerHeadlessManager.getInstance().bots())
+ {
+ if ((bot.getClanId() > 0) && isBotClan(bot.getClanId()) && (bot.getLevel() >= MIN_LEVEL) && !bot.isDead())
+ {
+ byClan.computeIfAbsent(bot.getClanId(), k -> new ArrayList<>()).add(bot);
+ }
+ }
+ return byClan;
+ }
+
+ // ======================== Battle ========================
+
+ private void onSiegeStarted(Siege siege)
+ {
+ final Castle castle = siege.getCastle();
+ final Battle battle = new Battle();
+ battle.siege = siege;
+ battle.castle = castle;
+ battle.firstOwnerId = castle.getOwnerId();
+ for (SiegeClan siegeClan : siege.getAttackerClans())
+ {
+ if (isBotClan(siegeClan.getClanId()))
+ {
+ battle.attackerClans.add(siegeClan.getClanId());
+ }
+ }
+ for (SiegeClan siegeClan : siege.getDefenderClans())
+ {
+ if (isBotClan(siegeClan.getClanId()))
+ {
+ battle.defenderClans.add(siegeClan.getClanId());
+ }
+ }
+ if (isBotClan(castle.getOwnerId()))
+ {
+ battle.defenderClans.add(castle.getOwnerId());
+ }
+ if (battle.attackerClans.isEmpty() && battle.defenderClans.isEmpty())
+ {
+ return; // Not our siege.
+ }
+ if (!computeLocations(battle))
+ {
+ LOGGER.warning(getClass().getSimpleName() + ": " + castle.getName() + " has no artifact or gates loaded, bots skip this siege.");
+ return;
+ }
+ _battles.put(castle.getResidenceId(), battle);
+
+ // Participants: online headless members of the registered clans.
+ final Map perClan = new HashMap<>();
+ for (Player bot : FakePlayerHeadlessManager.getInstance().bots())
+ {
+ final int clanId = bot.getClanId();
+ final boolean attacker = battle.attackerClans.contains(clanId);
+ final boolean defender = battle.defenderClans.contains(clanId);
+ if ((!attacker && !defender) || (bot.getLevel() < (MIN_LEVEL - 10)))
+ {
+ continue;
+ }
+ final int count = perClan.getOrDefault(clanId, 0);
+ if ((count >= MAX_PER_CLAN) && !bot.isClanLeader())
+ {
+ continue;
+ }
+ perClan.put(clanId, count + 1);
+ final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
+ if (state == null)
+ {
+ continue;
+ }
+ state.siegeSide = attacker ? (byte) 1 : (byte) 2;
+ state.siegeCastleId = castle.getResidenceId();
+ state.siegeNextThink = 0;
+ state.siegesJoined.incrementAndGet();
+ state.action((attacker ? "siege attack " : "siege defend ") + castle.getName());
+ bot.setSiegeState(state.siegeSide);
+ FakePlayerBotContext.setPlan(state.seedId, "siege", 3 * 3600000L);
+ FakePlayerBotContext.setSlot(state.seedId, "castle", castle.getName());
+ battle.participants.add(bot);
+ // Leave the farm party, close the shop, go to the castle in small waves.
+ if ((bot.getParty() != null) && (FakePlayerSocial.humanPartyAnchor(bot) == null))
+ {
+ bot.leaveParty();
+ }
+ FakePlayerHeadlessManager.getInstance().closeStore(bot);
+ final Location where = attacker ? battle.staging : battle.inner;
+ final int delay = 2000 + (battle.participants.size() * 700);
+ ThreadPool.schedule(() ->
+ {
+ if (!bot.isDead())
+ {
+ FakePlayerHeadlessManager.getInstance().teleport(bot, where.getX() + Rnd.get(-150, 150), where.getY() + Rnd.get(-150, 150), where.getZ());
+ }
+ }, delay);
+ }
+ // One battle cry per clan.
+ final Set cried = new HashSet<>();
+ for (Player bot : battle.participants)
+ {
+ if (cried.add(bot.getClanId()))
+ {
+ final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
+ final String seam = (state.siegeSide == 1) ? "siege_start" : "siege_defend";
+ ThreadPool.schedule(() -> cry(bot, seam), 6000 + Rnd.get(4000));
+ }
+ }
+ LOGGER.info(getClass().getSimpleName() + ": Siege of " + castle.getName() + " - " + battle.participants.size() + " bots joined (attacker clans " + battle.attackerClans + ", defender clans " + battle.defenderClans + ").");
+ }
+
+ /**
+ * @param battle the battle.
+ * @return true when the castle has enough geometry (artifact / gates) to play on.
+ */
+ private boolean computeLocations(Battle battle)
+ {
+ final Castle castle = battle.castle;
+ Location artefact = null;
+ for (Artefact art : castle.getArtefacts())
+ {
+ artefact = art.getLocation();
+ break;
+ }
+ Door outer = null;
+ double best = -1;
+ for (Door door : castle.getDoors())
+ {
+ if (!door.isShowHp())
+ {
+ continue;
+ }
+ final double distance = (artefact != null) ? door.calculateDistance2D(artefact) : 0;
+ if (distance > best)
+ {
+ best = distance;
+ outer = door;
+ }
+ }
+ if ((artefact == null) && (outer == null))
+ {
+ return false;
+ }
+ battle.inner = (artefact != null) ? new Location(artefact.getX(), artefact.getY(), artefact.getZ()) : outer.getLocation();
+ if ((outer != null) && (artefact != null))
+ {
+ final double dx = outer.getX() - artefact.getX();
+ final double dy = outer.getY() - artefact.getY();
+ final double length = Math.max(1, Math.sqrt((dx * dx) + (dy * dy)));
+ final int x = outer.getX() + (int) ((dx / length) * 900);
+ final int y = outer.getY() + (int) ((dy / length) * 900);
+ int z = outer.getZ();
+ try
+ {
+ z = GeoEngine.getInstance().getHeight(x, y, z);
+ }
+ catch (Exception e)
+ {
+ // Keep the door height.
+ }
+ battle.staging = new Location(x, y, z);
+ }
+ else
+ {
+ battle.staging = battle.inner;
+ }
+ return true;
+ }
+
+ /**
+ * Called every headless tick for a bot taking part in a siege.
+ * @param bot the bot.
+ * @param state its state.
+ * @param now current time.
+ * @return true when the siege logic took the tick.
+ */
+ public boolean handle(Player bot, FakePlayerBotState state, long now)
+ {
+ final Battle battle = _battles.get(state.siegeCastleId);
+ if ((battle == null) || !battle.siege.isInProgress())
+ {
+ release(bot, state, battle);
+ return false;
+ }
+ if (now < state.siegeNextThink)
+ {
+ return bot.isInCombat() || bot.isAttackingNow() || bot.isCastingNow();
+ }
+ state.siegeNextThink = now + 3000 + Rnd.get(2000);
+ if (bot.isCastingNow())
+ {
+ return true; // Engraving.
+ }
+ final boolean attacker = state.siegeSide == 1;
+ final Location post = attacker ? battle.staging : battle.inner;
+ if (bot.calculateDistance2D(post) > 4000)
+ {
+ // Came back from the village after dying: rejoin the battle.
+ FakePlayerHeadlessManager.getInstance().teleport(bot, post.getX() + Rnd.get(-150, 150), post.getY() + Rnd.get(-150, 150), post.getZ());
+ return true;
+ }
+
+ // Enemies first: players of the other side inside / around the castle.
+ final byte enemySide = attacker ? (byte) 2 : (byte) 1;
+ final int ownerId = battle.castle.getOwnerId();
+ final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, attacker ? 900 : 1200, other -> (other != bot) && !other.isDead() && !other.isInvisible() && ((other.getSiegeState() == enemySide) || (attacker && (other.getClanId() == ownerId) && (ownerId > 0))));
+ if (enemy != null)
+ {
+ if ((bot.getTarget() != enemy) || !bot.isAttackingNow())
+ {
+ bot.setTarget(enemy);
+ bot.getAI().setIntentionAttack(enemy);
+ }
+ state.action("siege fight " + enemy.getName());
+ return true;
+ }
+
+ if (attacker)
+ {
+ // The leader engraves once the artifact is reachable.
+ Artefact artefact = null;
+ for (Artefact art : battle.castle.getArtefacts())
+ {
+ if ((artefact == null) || (bot.calculateDistance2D(art) < bot.calculateDistance2D(artefact)))
+ {
+ artefact = art;
+ }
+ }
+ if ((artefact != null) && bot.isClanLeader() && (bot.calculateDistance2D(artefact) < 150) && (Math.abs(bot.getZ() - artefact.getZ()) < 45))
+ {
+ final Skill seal = bot.getKnownSkill(CommonSkill.SEAL_OF_RULER.getId());
+ if (seal != null)
+ {
+ bot.setTarget(artefact);
+ if (bot.useMagic(seal, false, false))
+ {
+ state.action("siege engrave");
+ if (!battle.engraveAnnounced)
+ {
+ battle.engraveAnnounced = true;
+ cry(bot, "siege_engrave");
+ }
+ return true;
+ }
+ }
+ }
+ // Break the nearest closed gate on the way in.
+ Door gate = null;
+ for (Door door : battle.castle.getDoors())
+ {
+ if (door.isOpen() || door.isDead() || !door.isAutoAttackable(bot))
+ {
+ continue;
+ }
+ if ((gate == null) || (bot.calculateDistance2D(door) < bot.calculateDistance2D(gate)))
+ {
+ gate = door;
+ }
+ }
+ if ((gate != null) && (bot.calculateDistance2D(gate) < 1200))
+ {
+ if ((bot.getTarget() != gate) || !bot.isAttackingNow())
+ {
+ bot.setTarget(gate);
+ bot.getAI().setIntentionAttack(gate);
+ }
+ state.action("siege gate");
+ final Long last = battle.lastCry.get(bot.getClanId());
+ if ((last == null) || ((now - last) > 90000))
+ {
+ battle.lastCry.put(bot.getClanId(), now);
+ cry(bot, "siege_gate");
+ }
+ return true;
+ }
+ // Otherwise walk towards the artifact (the leader) or escort the leader / hold the yard.
+ final Location goal = (artefact != null) ? artefact.getLocation() : battle.inner;
+ if (bot.calculateDistance2D(goal) > 250)
+ {
+ bot.setRunning();
+ bot.getAI().setIntentionMoveTo(new Location(goal.getX() + Rnd.get(-120, 120), goal.getY() + Rnd.get(-120, 120), goal.getZ()));
+ state.action("siege advance");
+ }
+ return true;
+ }
+
+ // Defender: hold the artifact room.
+ if (bot.calculateDistance2D(battle.inner) > 350)
+ {
+ bot.setRunning();
+ bot.getAI().setIntentionMoveTo(new Location(battle.inner.getX() + Rnd.get(-150, 150), battle.inner.getY() + Rnd.get(-150, 150), battle.inner.getZ()));
+ state.action("siege hold");
+ }
+ return true;
+ }
+
+ private void onSiegeEnded(Battle battle)
+ {
+ _battles.remove(battle.castle.getResidenceId());
+ final int ownerNow = battle.castle.getOwnerId();
+ final Set cried = new HashSet<>();
+ for (Player bot : battle.participants)
+ {
+ final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
+ if (state == null)
+ {
+ continue;
+ }
+ final boolean won = (bot.getClanId() == ownerNow) || ((state.siegeSide == 2) && (ownerNow == battle.firstOwnerId) && (bot.getClanId() == battle.firstOwnerId));
+ if (cried.add(bot.getClanId()))
+ {
+ ThreadPool.schedule(() -> cry(bot, won ? "siege_win" : "siege_lose"), 3000 + Rnd.get(5000));
+ }
+ release(bot, state, battle);
+ }
+ LOGGER.info(getClass().getSimpleName() + ": Siege of " + battle.castle.getName() + " finished, owner now " + ownerNow + (ownerNow != battle.firstOwnerId ? " (castle changed hands!)" : "") + ".");
+ }
+
+ private void release(Player bot, FakePlayerBotState state, Battle battle)
+ {
+ if (state.siegeSide == 0)
+ {
+ return;
+ }
+ state.siegeSide = 0;
+ state.siegeCastleId = 0;
+ FakePlayerBotContext.setPlan(state.seedId, "farm", 0);
+ FakePlayerBotContext.setEvent(state.seedId, "siege_finished");
+ state.action("siege over");
+ // Back to the farming spot after a while (the engine already sends everybody to town).
+ ThreadPool.schedule(() ->
+ {
+ if (!bot.isDead() && (FakePlayerHeadlessManager.getInstance().stateOf(bot) != null) && (FakePlayerHeadlessManager.getInstance().stateOf(bot).siegeSide == 0))
+ {
+ FakePlayerHeadlessManager.getInstance().returnToSpot(bot);
+ }
+ }, 60000 + Rnd.get(120000));
+ }
+
+ private void cry(Player bot, String seam)
+ {
+ try
+ {
+ if (bot.isDead())
+ {
+ return;
+ }
+ final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
+ final Map slots = new HashMap<>(2);
+ slots.put("castle", (state != null) && (state.siegeCastleId > 0) && (CastleManager.getInstance().getCastleById(state.siegeCastleId) != null) ? CastleManager.getInstance().getCastleById(state.siegeCastleId).getName() : "замок");
+ final Clan clan = bot.getClan();
+ if (clan != null)
+ {
+ slots.put("clan", clan.getName());
+ }
+ final String line = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), seam, null, slots);
+ if (line != null)
+ {
+ FakePlayerHeadlessManager.getInstance().shout(bot, line, seam);
+ }
+ }
+ catch (Exception e)
+ {
+ // Silence is fine.
+ }
+ }
+
+ /**
+ * @param state a bot state.
+ * @return true while the bot is fighting a siege.
+ */
+ public boolean isBusy(FakePlayerBotState state)
+ {
+ return state.siegeSide != 0;
+ }
+
+ // ======================== Debug ========================
+
+ /**
+ * Forces a siege right now: registers bot clans and starts the engine's siege.
+ * @param castleName castle name or id.
+ * @return result text.
+ */
+ public String forceSiege(String castleName)
+ {
+ final Castle castle = findCastle(castleName);
+ if (castle == null)
+ {
+ return "castle not found: " + castleName;
+ }
+ final Siege siege = castle.getSiege();
+ if (siege.isInProgress())
+ {
+ return castle.getName() + ": siege already in progress";
+ }
+ _lastRegisterTry.remove(castle.getResidenceId());
+ final boolean allowPlayers = FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES;
+ FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = true;
+ try
+ {
+ maybeRegister(siege);
+ }
+ finally
+ {
+ FakePlayersConfig.FAKE_PLAYER_SIEGE_ATTACK_PLAYER_CASTLES = allowPlayers;
+ }
+ int botAttackers = 0;
+ for (SiegeClan siegeClan : siege.getAttackerClans())
+ {
+ if (isBotClan(siegeClan.getClanId()))
+ {
+ botAttackers++;
+ }
+ }
+ if (botAttackers == 0)
+ {
+ return castle.getName() + ": no bot clan could register (need " + MIN_MEMBERS + " online members of level " + MIN_LEVEL + "+ in a clan without a castle)";
+ }
+ siege.startSiege();
+ ThreadPool.schedule(this::tick, 3000);
+ return castle.getName() + ": siege started, bot attacker clans: " + botAttackers + " (owner clan " + castle.getOwnerId() + "). Ends in " + SiegeManager.getInstance().getSiegeLength() + " min or do=siegeend";
+ }
+
+ /**
+ * Ends a forced siege.
+ * @param castleName castle name or id.
+ * @return result text.
+ */
+ public String endSiege(String castleName)
+ {
+ final Castle castle = findCastle(castleName);
+ if (castle == null)
+ {
+ return "castle not found: " + castleName;
+ }
+ if (!castle.getSiege().isInProgress())
+ {
+ return castle.getName() + ": no siege in progress";
+ }
+ castle.getSiege().endSiege();
+ ThreadPool.schedule(this::tick, 2000);
+ return castle.getName() + ": siege ended, owner " + castle.getOwnerId();
+ }
+
+ /**
+ * @return status of all sieges and bot registrations.
+ */
+ public String info()
+ {
+ final StringBuilder sb = new StringBuilder(512);
+ for (Siege siege : SiegeManager.getInstance().getSieges())
+ {
+ final Castle castle = siege.getCastle();
+ sb.append(castle.getResidenceId()).append(' ').append(castle.getName()).append(": owner ").append(castle.getOwnerId());
+ sb.append(siege.isInProgress() ? " IN PROGRESS" : (" next " + siege.getSiegeDate().getTime()));
+ sb.append(", attackers ");
+ for (SiegeClan siegeClan : siege.getAttackerClans())
+ {
+ final Clan clan = ClanTable.getInstance().getClan(siegeClan.getClanId());
+ sb.append((clan != null) ? clan.getName() : siegeClan.getClanId()).append(' ');
+ }
+ final Battle battle = _battles.get(castle.getResidenceId());
+ if (battle != null)
+ {
+ sb.append("| bots in battle: ").append(battle.participants.size());
+ }
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static Castle findCastle(String name)
+ {
+ for (Castle castle : CastleManager.getInstance().getCastles())
+ {
+ if (castle.getName().equalsIgnoreCase(name) || String.valueOf(castle.getResidenceId()).equals(name))
+ {
+ return castle;
+ }
+ }
+ return null;
+ }
+
+ public static FakePlayerSiegeManager getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ protected static final FakePlayerSiegeManager INSTANCE = new FakePlayerSiegeManager();
+ }
+}
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java
new file mode 100644
index 0000000..cd5bd4c
--- /dev/null
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerSocial.java
@@ -0,0 +1,751 @@
+/*
+ * Copyright (c) 2013 L2jMobius
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+ * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.l2jmobius.gameserver.managers;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.l2jmobius.commons.threads.ThreadPool;
+import org.l2jmobius.commons.util.Rnd;
+import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
+import org.l2jmobius.gameserver.data.xml.MapRegionData;
+import org.l2jmobius.gameserver.entity.World;
+import org.l2jmobius.gameserver.entity.WorldObject;
+import org.l2jmobius.gameserver.entity.actor.Creature;
+import org.l2jmobius.gameserver.entity.actor.Player;
+import org.l2jmobius.gameserver.entity.actor.instance.Monster;
+import org.l2jmobius.gameserver.entity.clan.Clan;
+import org.l2jmobius.gameserver.entity.groups.Party;
+import org.l2jmobius.gameserver.entity.groups.PartyDistributionType;
+import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding;
+import org.l2jmobius.gameserver.network.enums.ChatType;
+import org.l2jmobius.gameserver.network.serverpackets.AskJoinParty;
+import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;
+
+/**
+ * The social life of headless bots: everything that is perception and talk
+ * rather than combat. Runs every tick BEFORE the combat brain, so a bot keeps
+ * noticing players, clan enemies and neighbours while it farms - real players
+ * type while fighting too.
+ *
+ * - personality triggers: gankers hunt players, pk hunters hunt flagged/red ones, helpers assist, clan wars;
+ * - calls for help when losing a fight, clanmates answer;
+ * - ambient talk near real players (memory, rumors, invitations, party search);
+ * - bot to bot talk: greetings when meeting, small talk openers, gossip - every reply goes through the same
+ * understanding pipeline players get, so bots answer each other's questions;
+ * - real parties between clanmates, party invitations to players;
+ * - grounding slots for every line the bot says: zone, level, town, mob, crowd, last drop.
+ *
+ * @author Mobius SPP
+ */
+public class FakePlayerSocial
+{
+ private static final java.util.logging.Logger LOGGER = java.util.logging.Logger.getLogger(FakePlayerSocial.class.getName());
+ private static final int NOTICE_RANGE = 1000;
+ private static final int AMBIENT_RANGE = 700;
+ private static final int WITNESS_RANGE = 1600; // A real player who can see bots talking to each other.
+ private static final int PEER_RANGE = 600;
+ private static final long AMBIENT_COOLDOWN = 90000;
+ private static final long TAUNT_COOLDOWN = 20000;
+ private static final long MEET_MEMORY = 30 * 60000L;
+ private static final int MAX_DIALOGUE_DEPTH = 3;
+
+ /** Pending party invitations from bots to players: bot objectId - player objectId + deadline. */
+ private final Map _pendingInvites = new ConcurrentHashMap<>();
+
+ protected FakePlayerSocial()
+ {
+ }
+
+ /**
+ * Perception and talk for one bot. Cheap: every branch is cooldown gated.
+ * @param bot the bot.
+ * @param state its state.
+ * @param now current time.
+ * @return true when the bot started an exclusive action (fight, assist) and the tick should end.
+ */
+ public boolean tick(Player bot, FakePlayerBotState state, long now)
+ {
+ if (now > state.nextSlotRefresh)
+ {
+ state.nextSlotRefresh = now + 30000;
+ refreshSlots(bot, state);
+ }
+
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
+ final Player human = humanPartyAnchor(bot);
+
+ // Losing a PvP fight: shout for help, clanmates nearby join in.
+ final WorldObject target = bot.getTarget();
+ if (bot.isInCombat() && (target instanceof Player attacker) && (attacker != bot) && !attacker.isDead() && (attacker.getTarget() == bot) && (bot.getCurrentHp() < (bot.getMaxHp() * 0.6)) && ((now - state.lastHelpCall) > 45000))
+ {
+ state.lastHelpCall = now;
+ final String cry = FakePlayerUnderstanding.eventLine(ref, "call_help", attacker, null);
+ if (cry != null)
+ {
+ FakePlayerHeadlessManager.getInstance().shout(bot, cry, "call_help");
+ }
+ final int clanId = bot.getClanId();
+ if (clanId > 0)
+ {
+ World.forEachVisibleObjectInRange(bot, Player.class, 1500, ally ->
+ {
+ if (ally.isHeadlessBot() && (ally != bot) && !ally.isDead() && !ally.isInCombat() && (ally.getClanId() == clanId))
+ {
+ FakePlayerHeadlessManager.getInstance().forceTarget(ally, attacker, 40);
+ }
+ });
+ }
+ }
+
+ // Credit players helping this bot with its monster.
+ if (bot.isInCombat() && (target instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000))
+ {
+ final Player helper = World.getNearestVisibleObjectInRange(bot, Player.class, 900, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat() && (other.getTarget() == monsterTarget));
+ if (helper != null)
+ {
+ state.lastHelpCredit = now;
+ FakePlayerMemoryManager.getInstance().onHelpedBy(state.seedId, helper.getName());
+ }
+ }
+
+ // Personality actions against real players (not while escorting a human party).
+ if ((human == null) && (state.forcedTargetId == 0) && !FakePlayerSiegeManager.getInstance().isBusy(state))
+ {
+ if (FakePlayersConfig.FAKE_PLAYER_AGGRO_PLAYERS)
+ {
+ Player victim = null;
+ String seam = null;
+ String instruction = null;
+ if ("ganker".equals(state.personality))
+ {
+ victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible() && !other.isGM() && (Math.abs(other.getLevel() - bot.getLevel()) <= 12));
+ seam = "gank_start";
+ instruction = "Ты нападаешь на игрока. Крикни что-нибудь дерзкое.";
+ }
+ else if ("pkk".equals(state.personality))
+ {
+ victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isInvisible() && (other != bot) && ((other.getKarma() > 0) || ((other.getPvpFlag() != 0) && !other.isHeadlessBot())));
+ seam = "pkk_start";
+ instruction = "Ты охотник на ПК и нападаешь на нарушителя. Крикни про правосудие.";
+ }
+ if ((victim != null) && (victim.getObjectId() != state.lastPvpTargetId))
+ {
+ state.lastPvpTargetId = victim.getObjectId();
+ state.lastChat = now;
+ state.action("pvp:" + victim.getName());
+ FakePlayerHeadlessManager.getInstance().forceTarget(bot, victim, 60);
+ if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
+ {
+ eventChat(bot, state, instruction, seam, Map.of("target", victim.getName()), victim);
+ }
+ return true;
+ }
+ }
+
+ // Clan war: attack players and bots of clans at war with ours.
+ final Clan clan = bot.getClan();
+ if ((clan != null) && !bot.isInCombat() && (Rnd.get(100) < 40))
+ {
+ final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> !other.isDead() && (other != bot) && !other.isInvisible() && (other.getClan() != null) && (clan.isAtWarWith(other.getClanId()) || other.getClan().isAtWarWith(clan.getId())));
+ if (enemy != null)
+ {
+ state.action("clanwar:" + enemy.getName());
+ FakePlayerHeadlessManager.getInstance().forceTarget(bot, enemy, 45);
+ if (((now - state.lastChat) > TAUNT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE))
+ {
+ state.lastChat = now;
+ final Map slots = new HashMap<>(2);
+ slots.put("target", enemy.getName());
+ slots.put("enemyclan", enemy.getClan().getName());
+ eventChat(bot, state, "Клан игрока " + enemy.getName() + " воюет с твоим кланом, ты его атакуешь. Крикни боевую фразу.", "war_start", slots, enemy);
+ }
+ return true;
+ }
+ }
+
+ // Helpers assist real players fighting monsters.
+ if ("helper".equals(state.personality) && !bot.isInCombat())
+ {
+ final Player ally = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat());
+ if (ally != null)
+ {
+ final WorldObject allyTarget = ally.getTarget();
+ if ((allyTarget instanceof Monster monster) && !monster.isDead() && (bot.calculateDistance2D(monster) < 1500))
+ {
+ bot.setTarget(monster);
+ bot.getAI().setIntentionAttack(monster);
+ state.action("assist:" + ally.getName());
+ if ((now - state.lastChat) > TAUNT_COOLDOWN)
+ {
+ state.lastChat = now;
+ if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
+ {
+ eventChat(bot, state, "Ты помогаешь игроку " + ally.getName() + " убить монстра. Скажи что-то дружелюбное.", "assist", Map.of("player", ally.getName()), ally);
+ }
+ }
+ return true;
+ }
+ }
+ }
+ }
+
+ // Companion mode with a real player (they asked for help / party): escort is handled by the party
+ // logic when a real party exists; a plain escort ends after its time.
+ if ((state.companionPlayerId != 0) && (now > state.companionUntil))
+ {
+ state.companionPlayerId = 0;
+ if (bot.getParty() == null)
+ {
+ eventChat(bot, state, "Ты пофармил с игроком в паре и уходишь по своим делам. Попрощайся.", "companion_done", null, null);
+ }
+ }
+
+ // Squads: headless clanmates form REAL parties with each other.
+ if ((bot.getParty() == null) && (now > state.partyCooldown) && (bot.getClanId() > 0) && (Rnd.get(100) < 40))
+ {
+ state.partyCooldown = now + 180000;
+ final Player mate = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && (other.getClanId() == bot.getClanId()) && (Math.abs(other.getLevel() - bot.getLevel()) <= 10) && ((other.getParty() == null) || ((other.getParty().getMemberCount() < 6) && (humanPartyAnchor(other) == null))));
+ if (mate != null)
+ {
+ final Party squad;
+ if (mate.getParty() != null)
+ {
+ squad = mate.getParty();
+ bot.joinParty(squad);
+ }
+ else
+ {
+ squad = new Party(bot, bot.getPartyDistributionType());
+ bot.setParty(squad);
+ mate.joinParty(squad);
+ }
+ state.partiesJoined.incrementAndGet();
+ final FakePlayerBotState mateState = FakePlayerHeadlessManager.getInstance().stateOf(mate);
+ if (mateState != null)
+ {
+ mateState.partiesJoined.incrementAndGet();
+ }
+ state.action("squad:" + mate.getName());
+ final String invite = FakePlayerUnderstanding.eventLine(ref, "invite_player", mate, null);
+ if (invite != null)
+ {
+ converse(bot, mate, invite, "invite_player", 1);
+ }
+ }
+ }
+ // Everything below is talk - only worth it when a real player can see it.
+ final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible());
+ if (witness == null)
+ {
+ return false;
+ }
+
+ // Ambient talk when a real player is close (memory and rumors aware).
+ if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE))
+ {
+ final Player nearby = World.getNearestVisibleObjectInRange(bot, Player.class, AMBIENT_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible());
+ if (nearby != null)
+ {
+ state.lastChat = now;
+ ambientTalk(bot, state, ref, nearby, now);
+ return false;
+ }
+ }
+
+ // Bot to bot: greet a neighbour met for the first time in a while.
+ if (FakePlayersConfig.FAKE_PLAYER_BOT_DIALOGUES && (now > state.nextBotTalk))
+ {
+ final Player peer = World.getNearestVisibleObjectInRange(bot, Player.class, PEER_RANGE, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && !other.isInStoreMode());
+ if (peer != null)
+ {
+ final Long lastMet = state.met.get(peer.getObjectId());
+ if ((lastMet == null) || ((now - lastMet) > MEET_MEMORY))
+ {
+ state.met.put(peer.getObjectId(), now);
+ if (state.met.size() > 64)
+ {
+ state.met.entrySet().removeIf(entry -> (now - entry.getValue()) > MEET_MEMORY);
+ }
+ state.nextBotTalk = now + Rnd.get(40000, 120000);
+ if (Rnd.get(100) < 45)
+ {
+ final String hello = FakePlayerUnderstanding.eventLine(ref, "greeting", peer, null);
+ if (hello != null)
+ {
+ state.action("greet:" + peer.getName());
+ converse(bot, peer, hello, "greeting", 1);
+ }
+ }
+ }
+ else if (Rnd.get(100) < 35)
+ {
+ // Small talk opener addressed to the neighbour; it answers through the understanding pipeline.
+ state.nextBotTalk = now + Rnd.get(120000, 300000);
+ final String opener = FakePlayerUnderstanding.optionalLine(ref, "bot_talk", peer, null);
+ if (opener != null)
+ {
+ state.action("talk:" + peer.getName());
+ converse(bot, peer, opener, "bot_talk", 1);
+ }
+ }
+ else
+ {
+ state.nextBotTalk = now + Rnd.get(30000, 90000);
+ }
+ }
+ }
+
+ // Gossip: stories travel between bots when they meet, players nearby overhear.
+ if ((now > state.nextGossip) && !bot.isInCombat())
+ {
+ state.nextGossip = now + Rnd.get(180000, 420000);
+ final Player listener = World.getNearestVisibleObjectInRange(bot, Player.class, 400, other -> other.isHeadlessBot() && (other != bot) && !other.isDead());
+ if (listener != null)
+ {
+ final FakePlayerRumorManager.Story passed = FakePlayerRumorManager.getInstance().gossip(state.seedId, FakePlayerHeadlessManager.getInstance().seedIdOf(listener));
+ if (passed != null)
+ {
+ final String story = FakePlayerRumorManager.getInstance().tell(passed).replace(" ты ", " " + passed.subject + " ");
+ FakePlayerHeadlessManager.getInstance().say(bot, "слышал? " + story, "gossip");
+ state.action("gossip");
+ }
+ }
+ }
+
+ // Party search in the trade channel now and then (real level and zone).
+ if ((now > state.nextLfp) && (bot.getParty() == null) && !bot.isInCombat())
+ {
+ state.nextLfp = now + Rnd.get(600000, 1800000);
+ if (Rnd.get(100) < 35)
+ {
+ final String lfp = FakePlayerUnderstanding.optionalLine(ref, "lfp", null, null);
+ if (lfp != null)
+ {
+ FakePlayerHeadlessManager.getInstance().trade(bot, lfp, "lfp");
+ state.action("lfp");
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private void ambientTalk(Player bot, FakePlayerBotState state, FakePlayerBotRef ref, Player nearby, long now)
+ {
+ final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(state.seedId, nearby.getName());
+ final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(state.seedId, nearby.getName());
+ if ((memory != null) && (memory.score <= -2))
+ {
+ eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебя убивал. Скажи что-то злопамятное.", "revenge_meet", Map.of("player", nearby.getName()), nearby);
+ }
+ else if ((memory != null) && (memory.score >= 2))
+ {
+ eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебе помогал. Поприветствуй тепло.", "friendly_meet", Map.of("player", nearby.getName()), nearby);
+ }
+ else if ((rumor != null) && (Rnd.get(100) < 50))
+ {
+ FakePlayerHeadlessManager.getInstance().say(bot, FakePlayerRumorManager.getInstance().tell(rumor), "rumor");
+ }
+ else if ((("helper".equals(state.personality)) || ("neutral".equals(state.personality))) && (bot.getParty() == null) && (nearby.getParty() == null) && (Rnd.get(100) < 25))
+ {
+ // Invite the player to team up; a "yes" within a minute makes it a real party.
+ _pendingInvites.put(bot.getObjectId(), new long[]
+ {
+ nearby.getObjectId(),
+ now + 60000
+ });
+ eventChat(bot, state, "Рядом игрок " + nearby.getName() + ". Позови его в пати.", "invite_player", Map.of("player", nearby.getName()), nearby);
+ }
+ else if ((memory == null) && (Rnd.get(100) < 20))
+ {
+ eventChat(bot, state, "Рядом незнакомый игрок " + nearby.getName() + ". Поздоровайся.", "greeting", Map.of("player", nearby.getName()), nearby);
+ }
+ else
+ {
+ eventChat(bot, state, "Рядом игрок. Скажи что-нибудь бытовое про фарм, дроп или спот.", "ambient", null, null);
+ }
+ }
+
+ /**
+ * Refreshes the grounding slots every line of this bot can use.
+ * @param bot the bot.
+ * @param state its state.
+ */
+ private void refreshSlots(Player bot, FakePlayerBotState state)
+ {
+ final int botId = state.seedId;
+ if (botId <= 0)
+ {
+ return;
+ }
+ FakePlayerBotContext.setSlot(botId, "zone", state.zone.isEmpty() ? "поле" : state.zone);
+ FakePlayerBotContext.setSlot(botId, "level", String.valueOf(bot.getLevel()));
+ try
+ {
+ FakePlayerBotContext.setSlot(botId, "town", MapRegionData.getInstance().getClosestTownName(bot));
+ }
+ catch (Exception e)
+ {
+ // No region data - keep the old value.
+ }
+ final WorldObject target = bot.getTarget();
+ FakePlayerBotContext.setSlot(botId, "mob", (target instanceof Monster monster) ? monster.getName() : null);
+ int crowd = 0;
+ for (Player other : World.getVisibleObjectsInRange(bot, Player.class, 1000))
+ {
+ if ((other != bot) && !other.isDead())
+ {
+ crowd++;
+ }
+ }
+ FakePlayerBotContext.setSlot(botId, "crowd", String.valueOf(crowd));
+ FakePlayerBotContext.setSlot(botId, "drop", state.lastDrop);
+ final Clan clan = bot.getClan();
+ if ((clan != null) && !clan.getWarList().isEmpty())
+ {
+ final Clan enemy = org.l2jmobius.gameserver.data.sql.ClanTable.getInstance().getClan(clan.getWarList().iterator().next());
+ FakePlayerBotContext.setSlot(botId, "enemyclan", (enemy != null) ? enemy.getName() : null);
+ }
+ }
+
+ // ======================== Talking ========================
+
+ /**
+ * Event comment: seam line in the bot's voice; the LLM only when it has priority.
+ * @param bot the bot.
+ * @param state its state.
+ * @param instruction LLM instruction.
+ * @param seam seam key for the template line.
+ * @param slots extra slots.
+ * @param about player the event is about (memory context), may be null.
+ */
+ public void eventChat(Player bot, FakePlayerBotState state, String instruction, String seam, Map slots, Player about)
+ {
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
+ final String line = FakePlayerUnderstanding.eventLine(ref, seam, about, slots);
+ if (FakePlayerLlmService.getInstance().isEnabled() && "llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY))
+ {
+ final String persona = FakePlayerHeadlessManager.getInstance().persona(bot, (about != null) ? about.getName() : null);
+ FakePlayerLlmService.getInstance().generate(persona, instruction, reply -> FakePlayerHeadlessManager.getInstance().say(bot, reply, seam), () -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam));
+ return;
+ }
+ if (line != null)
+ {
+ // A human needs a moment to type.
+ ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam), Rnd.get(800, 2500));
+ }
+ }
+
+ /**
+ * A bot says something to another bot; the addressee answers through the
+ * understanding pipeline (intent, memory, stance, idiolect), and the two
+ * may exchange a couple more lines. Depth limited, never loops.
+ * @param speaker the speaking bot.
+ * @param listener the addressed bot.
+ * @param text the line.
+ * @param seam the seam of the line.
+ * @param depth dialogue depth of this line (1 = opener).
+ */
+ public void converse(Player speaker, Player listener, String text, String seam, int depth)
+ {
+ FakePlayerHeadlessManager.getInstance().say(speaker, text, (depth == 1) ? seam : "dialogue");
+ if ((depth >= MAX_DIALOGUE_DEPTH) || (listener == null) || listener.isDead())
+ {
+ return;
+ }
+ ThreadPool.schedule(() ->
+ {
+ try
+ {
+ if (listener.isDead() || speaker.isDead() || (listener.calculateDistance2D(speaker) > 1250))
+ {
+ return;
+ }
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(listener);
+ final Understanding understanding = FakePlayerUnderstanding.understand(ref, speaker, text, false);
+ String reply = ((understanding != null) && !understanding.silent) ? understanding.responseLine : null;
+ if ((reply == null) && (depth == 1))
+ {
+ // No intent recognized: react to the topic like an overheard line.
+ final Map slots = new HashMap<>(2);
+ slots.put("msg", FakePlayerHeardManager.echoOf(text));
+ slots.put("player", speaker.getName());
+ reply = FakePlayerUnderstanding.optionalLine(ref, FakePlayerHeardManager.isQuestionLine(text) ? "overheard_q" : "overheard", speaker, slots);
+ }
+ if (reply == null)
+ {
+ return;
+ }
+ final FakePlayerBotState listenerState = FakePlayerHeadlessManager.getInstance().stateOf(listener);
+ if (listenerState != null)
+ {
+ listenerState.repliesGiven.incrementAndGet();
+ listenerState.action("reply:" + speaker.getName());
+ }
+ // The opener's author answers back only sometimes, and the chain always ends.
+ if ((depth + 1 < MAX_DIALOGUE_DEPTH) && (Rnd.get(100) < 40))
+ {
+ converse(listener, speaker, reply, "dialogue", depth + 1);
+ }
+ else
+ {
+ FakePlayerHeadlessManager.getInstance().say(listener, reply, "dialogue");
+ }
+ }
+ catch (Exception e)
+ {
+ // A failed reply is just silence.
+ }
+ }, Rnd.get(2500, 7000));
+ }
+
+ /**
+ * A real player said something in general chat: nearby bots may answer.
+ * A bot addressed by name always answers.
+ * @param player the speaker.
+ * @param text the message.
+ */
+ public void onPlayerSaid(Player player, String text)
+ {
+ final List listeners = new ArrayList<>();
+ World.forEachVisibleObjectInRange(player, Player.class, 1250, other ->
+ {
+ if (other.isHeadlessBot() && !other.isDead())
+ {
+ listeners.add(other);
+ }
+ });
+ if (listeners.isEmpty())
+ {
+ return;
+ }
+ listeners.sort((a, b) -> Double.compare(a.calculateDistance2D(player), b.calculateDistance2D(player)));
+ final String lower = text.toLowerCase();
+ int answered = 0;
+ for (Player listener : listeners)
+ {
+ final boolean addressed = lower.contains(listener.getName().toLowerCase()) || ((listener.getName().length() > 5) && lower.contains(listener.getName().substring(0, 5).toLowerCase()));
+ if (addressed || ((answered < 2) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE)))
+ {
+ answered++;
+ FakePlayerHeadlessManager.getInstance().onLocalChat(player, listener, text);
+ if (addressed)
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ // ======================== Parties ========================
+
+ /**
+ * Real party invitation from a bot to a player (the client shows the normal window).
+ * @param bot the bot.
+ * @param player the player.
+ */
+ public void inviteToParty(Player bot, Player player)
+ {
+ try
+ {
+ if (bot.isDead() || player.isDead())
+ {
+ return;
+ }
+ final Party botParty = bot.getParty();
+ final Party playerParty = player.getParty();
+ if ((playerParty != null) && (botParty == null) && (playerParty.getMemberCount() < 9))
+ {
+ bot.joinParty(playerParty);
+ FakePlayerHeadlessManager.getInstance().onJoinedParty(bot);
+ return;
+ }
+ if ((playerParty != null) || player.isProcessingRequest() || player.isProcessingTransaction())
+ {
+ return;
+ }
+ if ((botParty != null) && (botParty.getMemberCount() >= 9))
+ {
+ return;
+ }
+ player.setActiveRequester(bot);
+ bot.onTransactionRequest(player);
+ bot.setPartyDistributionType(PartyDistributionType.FINDERS_KEEPERS);
+ player.sendPacket(new AskJoinParty(bot.getName(), PartyDistributionType.FINDERS_KEEPERS));
+ if (botParty != null)
+ {
+ botParty.setPendingInvitation(true);
+ }
+ _pendingInvites.remove(bot.getObjectId());
+ }
+ catch (Exception e)
+ {
+ // The invitation simply does not happen.
+ }
+ }
+
+ /**
+ * @param bot the bot.
+ * @param player the player.
+ * @return true when the bot invited the player recently and the invite is still valid.
+ */
+ public boolean hasPendingInvite(Player bot, Player player)
+ {
+ final long[] pending = _pendingInvites.get(bot.getObjectId());
+ return (pending != null) && (pending[0] == player.getObjectId()) && (System.currentTimeMillis() < pending[1]);
+ }
+
+ /**
+ * @param bot the bot.
+ * @return the real player the bot's party is built around (any human member), or null.
+ */
+ public static Player humanPartyAnchor(Player bot)
+ {
+ final Party party = bot.getParty();
+ if (party == null)
+ {
+ return null;
+ }
+ final Player leader = party.getLeader();
+ if ((leader != null) && (leader != bot) && !leader.isHeadlessBot())
+ {
+ return leader;
+ }
+ for (Player member : party.getMembers())
+ {
+ if ((member != null) && (member != bot) && !member.isHeadlessBot())
+ {
+ return member;
+ }
+ }
+ return null;
+ }
+
+ // ======================== Events ========================
+
+ /**
+ * Reaction to a real level up (event fact, clan brag or public joy - neighbours congratulate).
+ * @param bot the bot.
+ * @param state its state.
+ */
+ public void onLevelUp(Player bot, FakePlayerBotState state)
+ {
+ FakePlayerBotContext.setEvent(state.seedId, "levelup");
+ FakePlayerBotContext.setSlot(state.seedId, "level", String.valueOf(bot.getLevel()));
+ final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
+ final Map slots = Map.of("level", String.valueOf(bot.getLevel()));
+ if ((bot.getClan() != null) && (Rnd.get(100) < 40))
+ {
+ final String clanLine = FakePlayerUnderstanding.eventLine(ref, "levelup", null, slots);
+ if (clanLine != null)
+ {
+ bot.getClan().broadcastToOnlineMembers(new CreatureSay(bot, ChatType.CLAN, bot.getName(), clanLine));
+ state.linesSaid.incrementAndGet();
+ }
+ return;
+ }
+ final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot());
+ if (witness != null)
+ {
+ eventChat(bot, state, "Ты только что взял " + bot.getLevel() + " уровень. Порадуйся одной фразой.", "levelup", slots, null);
+ }
+ }
+
+ /**
+ * Reaction to death: killed by a player - memory, rumor seed and a death cry;
+ * killed by a monster - a complaint now and then.
+ * @param bot the dead bot.
+ * @param state its state.
+ * @param killer the killer.
+ */
+ public void onDied(Player bot, FakePlayerBotState state, Creature killer)
+ {
+ state.deaths.incrementAndGet();
+ state.action("dead");
+ if (FakePlayersConfig.FAKE_PLAYER_CHAT_LOG)
+ {
+ LOGGER.info("death: " + bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + " in " + state.zone + " killed by " + ((killer != null) ? (killer.getName() + " lvl" + killer.getLevel()) : "?") + " at " + bot.getX() + "," + bot.getY() + "," + bot.getZ());
+ }
+ final Player killerPlayer = (killer != null) ? killer.asPlayer() : null;
+ if (killerPlayer != null)
+ {
+ if (!killerPlayer.isHeadlessBot())
+ {
+ FakePlayerMemoryManager.getInstance().onKilledBy(state.seedId, killerPlayer.getName());
+ }
+ else
+ {
+ final FakePlayerBotState killerState = FakePlayerHeadlessManager.getInstance().stateOf(killerPlayer);
+ if (killerState != null)
+ {
+ killerState.pvpKills.incrementAndGet();
+ }
+ }
+ FakePlayerBotContext.setEvent(state.seedId, "ganked");
+ final String deathCry = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "killed_by", killerPlayer, null);
+ if (deathCry != null)
+ {
+ // Dead players still type - the corpse lies there for a while.
+ ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, deathCry, "killed_by"), Rnd.get(1500, 4000));
+ }
+ // Nearby bots witness the kill (rumor seed).
+ final String victimName = bot.getName();
+ World.forEachVisibleObjectInRange(bot, Player.class, 900, witness ->
+ {
+ if (witness.isHeadlessBot() && (witness != bot))
+ {
+ final FakePlayerBotState witnessState = FakePlayerHeadlessManager.getInstance().stateOf(witness);
+ if (witnessState != null)
+ {
+ FakePlayerRumorManager.getInstance().addWitnessed(witnessState.seedId, (killerPlayer.getKarma() > 0) ? "pk" : "kill", killerPlayer.getName(), victimName);
+ }
+ }
+ });
+ return;
+ }
+ if ((killer != null) && (Rnd.get(100) < 50))
+ {
+ final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot());
+ if (witness != null)
+ {
+ final String complaint = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "died_mob", null, Map.of("mob", killer.getName()));
+ if (complaint != null)
+ {
+ ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, complaint, "died_mob"), Rnd.get(2000, 5000));
+ }
+ }
+ }
+ }
+
+ public static FakePlayerSocial getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ protected static final FakePlayerSocial INSTANCE = new FakePlayerSocial();
+ }
+}
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java
new file mode 100644
index 0000000..e5a57fb
--- /dev/null
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerTelemetry.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2013 L2jMobius
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+ * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.l2jmobius.gameserver.managers;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import org.l2jmobius.commons.threads.ThreadPool;
+import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
+import org.l2jmobius.gameserver.entity.actor.Player;
+
+/**
+ * Proof of life for the bot population: every counter a bot increments while
+ * it farms, loots, trades, talks and fights is summed here, printed to the
+ * server log every few minutes and served by the dashboard (/api/stats,
+ * do=stats). If the numbers do not move, the bots are pretending.
+ * @author Mobius SPP
+ */
+public class FakePlayerTelemetry
+{
+ private static final Logger LOGGER = Logger.getLogger(FakePlayerTelemetry.class.getName());
+
+ private final long _startedAt = System.currentTimeMillis();
+ private Map _lastTotals = new LinkedHashMap<>();
+
+ protected FakePlayerTelemetry()
+ {
+ if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || (FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES <= 0))
+ {
+ return;
+ }
+ final long interval = FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES * 60000L;
+ ThreadPool.scheduleAtFixedRate(this::logSummary, interval, interval);
+ }
+
+ /**
+ * @return population totals in a fixed order.
+ */
+ public Map totals()
+ {
+ final Map totals = new LinkedHashMap<>();
+ long kills = 0;
+ long exp = 0;
+ long deaths = 0;
+ long pvp = 0;
+ long loot = 0;
+ long sold = 0;
+ long earned = 0;
+ long spent = 0;
+ long stores = 0;
+ long lines = 0;
+ long replies = 0;
+ long casts = 0;
+ long rests = 0;
+ long sieges = 0;
+ long migrations = 0;
+ long professions = 0;
+ long parties = 0;
+ int alive = 0;
+ int fighting = 0;
+ int trading = 0;
+ int inParty = 0;
+ int sitting = 0;
+ int besieging = 0;
+ int levels = 0;
+ for (Player bot : FakePlayerHeadlessManager.getInstance().bots())
+ {
+ final FakePlayerBotState state = FakePlayerHeadlessManager.getInstance().stateOf(bot);
+ if (state == null)
+ {
+ continue;
+ }
+ kills += state.kills.get();
+ exp += state.expGained.get();
+ deaths += state.deaths.get();
+ pvp += state.pvpKills.get();
+ loot += state.lootPicked.get();
+ sold += state.itemsSold.get();
+ earned += state.adenaEarned.get();
+ spent += state.adenaSpent.get();
+ stores += state.storesOpened.get();
+ lines += state.linesSaid.get();
+ replies += state.repliesGiven.get();
+ casts += state.skillsCast.get();
+ rests += state.rests.get();
+ sieges += state.siegesJoined.get();
+ migrations += state.migrations.get();
+ professions += state.professions.get();
+ parties += state.partiesJoined.get();
+ levels += bot.getLevel();
+ if (!bot.isDead())
+ {
+ alive++;
+ }
+ if (bot.isInCombat())
+ {
+ fighting++;
+ }
+ if (bot.isInStoreMode())
+ {
+ trading++;
+ }
+ if (bot.getParty() != null)
+ {
+ inParty++;
+ }
+ if (bot.isSitting())
+ {
+ sitting++;
+ }
+ if (state.siegeSide != 0)
+ {
+ besieging++;
+ }
+ }
+ final int count = FakePlayerHeadlessManager.getInstance().size();
+ totals.put("bots", (long) count);
+ totals.put("alive", (long) alive);
+ totals.put("fighting", (long) fighting);
+ totals.put("sitting", (long) sitting);
+ totals.put("trading", (long) trading);
+ totals.put("inParty", (long) inParty);
+ totals.put("besieging", (long) besieging);
+ totals.put("avgLevel", (count > 0) ? (levels / count) : 0L);
+ totals.put("kills", kills);
+ totals.put("exp", exp);
+ totals.put("pvpKills", pvp);
+ totals.put("deaths", deaths);
+ totals.put("loot", loot);
+ totals.put("itemsSold", sold);
+ totals.put("adenaEarned", earned);
+ totals.put("adenaSpent", spent);
+ totals.put("stores", stores);
+ totals.put("skillsCast", casts);
+ totals.put("rests", rests);
+ totals.put("lines", lines);
+ totals.put("replies", replies);
+ totals.put("parties", parties);
+ totals.put("migrations", migrations);
+ totals.put("professions", professions);
+ totals.put("sieges", sieges);
+ totals.put("uptimeMin", (System.currentTimeMillis() - _startedAt) / 60000);
+ return totals;
+ }
+
+ private void logSummary()
+ {
+ try
+ {
+ final Map totals = totals();
+ final StringBuilder sb = new StringBuilder(256);
+ sb.append(getClass().getSimpleName()).append(": last ").append(FakePlayersConfig.FAKE_PLAYER_STATS_LOG_MINUTES).append(" min ->");
+ for (Map.Entry entry : totals.entrySet())
+ {
+ final String key = entry.getKey();
+ if (key.equals("bots") || key.equals("alive") || key.equals("fighting") || key.equals("sitting") || key.equals("trading") || key.equals("inParty") || key.equals("besieging") || key.equals("avgLevel") || key.equals("uptimeMin"))
+ {
+ continue;
+ }
+ final long delta = entry.getValue() - _lastTotals.getOrDefault(key, 0L);
+ sb.append(' ').append(key).append("=+").append(delta);
+ }
+ sb.append(" | now: alive ").append(totals.get("alive")).append('/').append(totals.get("bots")).append(", fighting ").append(totals.get("fighting")).append(", sitting ").append(totals.get("sitting")).append(", trading ").append(totals.get("trading")).append(", in party ").append(totals.get("inParty")).append(", besieging ").append(totals.get("besieging")).append(", avg lvl ").append(totals.get("avgLevel"));
+ LOGGER.info(sb.toString());
+ _lastTotals = totals;
+ }
+ catch (Exception e)
+ {
+ // Logging must never break anything.
+ }
+ }
+
+ /**
+ * @return totals as JSON for the dashboard.
+ */
+ public String statsJson()
+ {
+ final StringBuilder sb = new StringBuilder(512);
+ sb.append('{');
+ boolean first = true;
+ for (Map.Entry entry : totals().entrySet())
+ {
+ if (!first)
+ {
+ sb.append(',');
+ }
+ first = false;
+ sb.append('"').append(entry.getKey()).append("\":").append(entry.getValue());
+ }
+ return sb.append('}').toString();
+ }
+
+ /**
+ * @return human readable totals (do=stats).
+ */
+ public String statsText()
+ {
+ final StringBuilder sb = new StringBuilder(512);
+ for (Map.Entry entry : totals().entrySet())
+ {
+ sb.append(entry.getKey()).append('=').append(entry.getValue()).append('\n');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * @param bot a bot.
+ * @param state its state.
+ * @return one bot's counters (do=stats&name=).
+ */
+ public String statsText(Player bot, FakePlayerBotState state)
+ {
+ final StringBuilder sb = new StringBuilder(256);
+ sb.append(bot.getName()).append(" lvl").append(bot.getLevel()).append(' ').append(bot.getPlayerClass()).append(" [").append(state.personality).append("] zone=").append(state.zone).append(" plan=").append(FakePlayerBotContext.planOf(state.seedId)).append('\n');
+ sb.append(" last action: ").append(state.lastAction).append(" (").append((System.currentTimeMillis() - state.lastActionAt) / 1000).append("s ago)\n");
+ sb.append(" kills=").append(state.kills.get()).append(" exp=").append(state.expGained.get()).append(" pvp=").append(state.pvpKills.get()).append(" deaths=").append(state.deaths.get()).append('\n');
+ sb.append(" loot=").append(state.lootPicked.get()).append(" sold=").append(state.itemsSold.get()).append(" earned=").append(state.adenaEarned.get()).append(" spent=").append(state.adenaSpent.get()).append(" adena=").append(bot.getAdena()).append(" stores=").append(state.storesOpened.get()).append('\n');
+ sb.append(" casts=").append(state.skillsCast.get()).append(" rests=").append(state.rests.get()).append(" lines=").append(state.linesSaid.get()).append(" replies=").append(state.repliesGiven.get()).append(" parties=").append(state.partiesJoined.get()).append('\n');
+ sb.append(" migrations=").append(state.migrations.get()).append(" professions=").append(state.professions.get()).append(" sieges=").append(state.siegesJoined.get()).append(" siegeSide=").append(state.siegeSide).append('\n');
+ sb.append(" shots=").append(bot.getAutoSoulShot()).append(" inventory=").append(bot.getInventory().getSize()).append(" hp=").append((int) bot.getCurrentHp()).append('/').append(bot.getMaxHp());
+ return sb.toString();
+ }
+
+ public static FakePlayerTelemetry getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ protected static final FakePlayerTelemetry INSTANCE = new FakePlayerTelemetry();
+ }
+}
diff --git a/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java
new file mode 100644
index 0000000..81f4fa8
--- /dev/null
+++ b/src_mobius/mobius/L2J_Mobius_CT_0_Interlude/java/org/l2jmobius/gameserver/managers/FakePlayerUnderstanding.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright (c) 2013 L2jMobius
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+ * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.l2jmobius.gameserver.managers;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.l2jmobius.commons.util.Rnd;
+import org.l2jmobius.gameserver.entity.WorldObject;
+import org.l2jmobius.gameserver.entity.actor.Creature;
+import org.l2jmobius.gameserver.entity.actor.Player;
+import org.l2jmobius.gameserver.entity.actor.instance.Monster;
+import org.l2jmobius.gameserver.entity.clan.Clan;
+import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding;
+
+/**
+ * The one place where a bot understands what was said to it. Shared by NPC
+ * fake players and headless players through {@link FakePlayerBotRef}, so both
+ * get the whole pipeline: intent lexicon with typo tolerance, memory facts
+ * (asker = stranger / killed_me / helped_me / clanmate / enemy_war), stance
+ * memory (a decision on duel / party / help / insults holds for 15 minutes),
+ * silence towards a stranger's first message, reactions to caps lock, style
+ * mirroring, the bot's own context facts (plan, event) and grounding slots
+ * (zone, level, mob, town, crowd).
+ * @author Mobius SPP
+ */
+public class FakePlayerUnderstanding
+{
+ private static final long STANCE_TTL = 15 * 60 * 1000;
+ private static final Set STANCE_INTENTS = Set.of("duel", "ask_party", "ask_help", "insult");
+
+ private static class Stance
+ {
+ final String seam;
+ final String text;
+ final long timestamp = System.currentTimeMillis();
+
+ Stance(String seam, String text)
+ {
+ this.seam = seam;
+ this.text = text;
+ }
+ }
+
+ private static final Map STANCES = new ConcurrentHashMap<>();
+ private static final Map FIRST_CONTACT = new ConcurrentHashMap<>();
+
+ private FakePlayerUnderstanding()
+ {
+ }
+
+ /**
+ * Memory based relation of the speaker to the bot.
+ * @param bot the bot.
+ * @param speaker the speaking player (real or another bot).
+ * @return asker fact value.
+ */
+ public static String askerOf(FakePlayerBotRef bot, Player speaker)
+ {
+ final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(bot.botId, speaker.getName());
+ if ((memory != null) && (memory.kills > 0))
+ {
+ return "killed_me";
+ }
+ if ((memory != null) && (memory.helps > 0))
+ {
+ return "helped_me";
+ }
+ final int clanId = bot.getClanId();
+ if ((clanId > 0) && (speaker.getClanId() == clanId))
+ {
+ return "clanmate";
+ }
+ final Clan botClan = bot.getClan();
+ if ((botClan != null) && (speaker.getClan() != null) && (botClan.isAtWarWith(speaker.getClanId()) || speaker.getClan().isAtWarWith(botClan.getId())))
+ {
+ return "enemy_war";
+ }
+ return "stranger";
+ }
+
+ /**
+ * Base facts of a bot for the seam lottery (personality + relation to the speaker).
+ * @param bot the bot.
+ * @param speaker the speaker, may be null.
+ * @return facts map.
+ */
+ public static Map facts(FakePlayerBotRef bot, Player speaker)
+ {
+ final Map facts = new HashMap<>(3);
+ facts.put("personality", bot.personality);
+ if (speaker != null)
+ {
+ facts.put("asker", askerOf(bot, speaker));
+ }
+ return facts;
+ }
+
+ /**
+ * Grounding slots that are always available for a bot line.
+ * @param bot the bot.
+ * @param speaker the speaker, may be null.
+ * @return slots map.
+ */
+ public static Map slots(FakePlayerBotRef bot, Player speaker)
+ {
+ final Map slots = new HashMap<>(6);
+ if (speaker != null)
+ {
+ slots.put("player", speaker.getName());
+ }
+ final String zone = bot.getZone();
+ slots.put("zone", ((zone == null) || zone.isEmpty()) ? "поле" : zone);
+ slots.put("level", String.valueOf(bot.getLevel()));
+ final Clan clan = bot.getClan();
+ if (clan != null)
+ {
+ slots.put("clan", clan.getName());
+ }
+ if ((clan != null) && (speaker != null) && (speaker.getClan() != null) && (clan.isAtWarWith(speaker.getClanId()) || speaker.getClan().isAtWarWith(clan.getId())))
+ {
+ slots.put("enemyclan", speaker.getClan().getName());
+ }
+ return slots;
+ }
+
+ /**
+ * Understands an incoming message: matches an intent, applies world effects
+ * (memory, aggression, running to help, teaming up) and prepares a template
+ * response in the bot's own voice.
+ * @param bot the addressed bot.
+ * @param speaker the speaking player (a real one or another headless bot).
+ * @param message the raw message.
+ * @param privateChannel true for whispers (no global template cooldown).
+ * @return understanding or null when no intent matched.
+ */
+ public static Understanding understand(FakePlayerBotRef bot, Player speaker, String message, boolean privateChannel)
+ {
+ final int botId = bot.botId;
+ final String personality = bot.personality;
+ final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
+ final FakePlayerIdiolect.Mirror mirror = FakePlayerIdiolect.Mirror.of(message);
+ final Map facts = facts(bot, speaker);
+ final String asker = facts.get("asker");
+ final Map slots = slots(bot, speaker);
+ final boolean speakerIsBot = speaker.isHeadlessBot();
+
+ final FakePlayerIntentParser.Intent intent = FakePlayerIntentParser.getInstance().parse(message);
+ final long now = System.currentTimeMillis();
+ final String contactKey = botId + "|" + speaker.getName();
+
+ // Silence: only a stranger's FIRST message can be ignored, and never a trade question.
+ final Long firstSeen = FIRST_CONTACT.putIfAbsent(contactKey, now);
+ if ((firstSeen == null) && "stranger".equals(asker) && !speakerIsBot && ((intent == null) || !"ask_price".equals(intent.key)) && (Rnd.get(1000) < (int) (idiolect.silence * 1000)))
+ {
+ final Understanding ignored = new Understanding();
+ ignored.silent = true;
+ ignored.llmHint = "";
+ return ignored;
+ }
+ if (FIRST_CONTACT.size() > 5000)
+ {
+ FIRST_CONTACT.entrySet().removeIf(entry -> (now - entry.getValue()) > STANCE_TTL);
+ }
+
+ // Caps-lock: sometimes the reaction is to the shouting itself.
+ int letters = 0;
+ int upper = 0;
+ for (int i = 0; i < message.length(); i++)
+ {
+ final char ch = message.charAt(i);
+ if (Character.isLetter(ch))
+ {
+ letters++;
+ if (Character.isUpperCase(ch))
+ {
+ upper++;
+ }
+ }
+ }
+ if ((letters > 3) && (upper > (letters * 0.7)) && (Rnd.get(100) < 35))
+ {
+ final String capsReply = line(botId, "reply_caps", facts, slots, mirror, privateChannel);
+ if (capsReply != null)
+ {
+ final Understanding result = new Understanding();
+ result.intentKey = "caps";
+ result.llmHint = "он пишет капсом";
+ result.responseLine = capsReply;
+ return result;
+ }
+ }
+
+ if (intent == null)
+ {
+ return null;
+ }
+
+ final Understanding result = new Understanding();
+ result.intentKey = intent.key;
+ result.llmHint = FakePlayerChatManager.intentHint(intent.key);
+ String seam = intent.seam;
+
+ // Stance memory: a repeated question gets the same position, not a new roll.
+ final String stanceKey = contactKey + "|" + intent.key;
+ Stance stance = STANCE_INTENTS.contains(intent.key) ? STANCES.get(stanceKey) : null;
+ if ((stance != null) && ((now - stance.timestamp) > STANCE_TTL))
+ {
+ STANCES.remove(stanceKey);
+ stance = null;
+ }
+ if (stance != null)
+ {
+ if ("insult".equals(intent.key))
+ {
+ seam = stance.seam; // Same reaction mode, fresh wording.
+ }
+ else
+ {
+ final Map againSlots = new HashMap<>(slots);
+ againSlots.put("prev", stance.text);
+ final String again = line(botId, "reply_again", facts, againSlots, mirror, privateChannel);
+ result.responseLine = (again != null) ? again : stance.text;
+ return result;
+ }
+ }
+
+ switch (intent.key)
+ {
+ case "insult":
+ {
+ FakePlayerMemoryManager.getInstance().adjustScore(botId, speaker.getName(), -1);
+ if (stance == null)
+ {
+ final boolean aggressive = "ganker".equals(personality) || "pkk".equals(personality) || (Rnd.get(100) < 50);
+ seam = aggressive ? "reply_insult_aggro" : "reply_insult_soft";
+ }
+ final boolean fights = "ganker".equals(personality) || ("pkk".equals(personality) && bot.isHeadless() && (Rnd.get(100) < 50));
+ if (fights && !speakerIsBot && (bot.distanceTo(speaker) < 2000))
+ {
+ bot.attack(speaker, 45);
+ }
+ break;
+ }
+ case "greeting":
+ {
+ if ("killed_me".equals(asker) && (Rnd.get(100) < 60))
+ {
+ seam = "revenge_meet";
+ }
+ else if ("helped_me".equals(asker) && (Rnd.get(100) < 60))
+ {
+ seam = "friendly_meet";
+ }
+ break;
+ }
+ case "thanks":
+ {
+ FakePlayerMemoryManager.getInstance().adjustScore(botId, speaker.getName(), 1);
+ break;
+ }
+ case "ask_party":
+ {
+ final boolean willing = !"ganker".equals(personality) && !"killed_me".equals(asker) && !bot.isInCombat() && (bot.distanceTo(speaker) < 2500);
+ if (willing)
+ {
+ if (!speakerIsBot)
+ {
+ bot.teamUp(speaker);
+ }
+ seam = "party_accept";
+ }
+ break;
+ }
+ case "yes_ok":
+ {
+ if (!speakerIsBot && bot.hasPendingInvite(speaker))
+ {
+ bot.teamUp(speaker);
+ seam = "party_accept";
+ }
+ break;
+ }
+ case "ask_help":
+ {
+ final boolean willing = (!"ganker".equals(personality) || "helped_me".equals(asker) || "clanmate".equals(asker)) && !"killed_me".equals(asker) && !"enemy_war".equals(asker) && !bot.isInCombat() && (bot.distanceTo(speaker) < 3000);
+ seam = willing ? "reply_help_yes" : "reply_help_no";
+ if (willing && !speakerIsBot)
+ {
+ final WorldObject playerTarget = speaker.getTarget();
+ if ((playerTarget instanceof Monster monster) && !monster.isDead())
+ {
+ bot.assist(monster);
+ }
+ else
+ {
+ bot.moveTo(speaker);
+ }
+ }
+ break;
+ }
+ case "ask_rumors":
+ {
+ final FakePlayerRumorManager.Story story = FakePlayerRumorManager.getInstance().bestAny(botId);
+ if (story != null)
+ {
+ result.responseLine = "слышал? " + FakePlayerRumorManager.getInstance().tellThird(story);
+ }
+ break;
+ }
+ case "duel":
+ {
+ if ("enemy_war".equals(asker) || "killed_me".equals(asker))
+ {
+ seam = "reply_insult_aggro";
+ }
+ break;
+ }
+ }
+
+ if ((result.responseLine == null) && !seam.isEmpty())
+ {
+ result.responseLine = line(botId, seam, facts, slots, mirror, privateChannel);
+ }
+
+ // A fresh decision on duel/party/help/insult becomes the bot's stance.
+ if ((stance == null) && (result.responseLine != null) && STANCE_INTENTS.contains(intent.key))
+ {
+ STANCES.put(stanceKey, new Stance(seam, result.responseLine));
+ if (STANCES.size() > 5000)
+ {
+ STANCES.entrySet().removeIf(entry -> (now - entry.getValue().timestamp) > STANCE_TTL);
+ }
+ }
+ return result;
+ }
+
+ private static String line(int botId, String seam, Map facts, Map slots, FakePlayerIdiolect.Mirror mirror, boolean privateChannel)
+ {
+ if (privateChannel)
+ {
+ return FakePlayerChatLines.getInstance().speakPrivate(botId, seam, facts, slots, mirror);
+ }
+ return FakePlayerChatLines.getInstance().speak(botId, seam, facts, slots, false, mirror);
+ }
+
+ /**
+ * Composes an event line for a bot with its full facts (personality, relation to the player about).
+ * @param bot the bot.
+ * @param seam seam key.
+ * @param about the player the event is about (may be null).
+ * @param extraSlots extra slots (may be null).
+ * @return the line or null.
+ */
+ public static String eventLine(FakePlayerBotRef bot, String seam, Player about, Map extraSlots)
+ {
+ final Map slots = slots(bot, about);
+ if (extraSlots != null)
+ {
+ slots.putAll(extraSlots);
+ }
+ return FakePlayerChatLines.getInstance().speak(bot.botId, seam, facts(bot, about), slots, false, null);
+ }
+
+ /**
+ * Optional background line (silence beats repetition).
+ * @param bot the bot.
+ * @param seam seam key.
+ * @param about the player nearby (may be null).
+ * @param extraSlots extra slots (may be null).
+ * @return the line or null.
+ */
+ public static String optionalLine(FakePlayerBotRef bot, String seam, Player about, Map extraSlots)
+ {
+ final Map slots = slots(bot, about);
+ if (extraSlots != null)
+ {
+ slots.putAll(extraSlots);
+ }
+ return FakePlayerChatLines.getInstance().speak(bot.botId, seam, facts(bot, about), slots, true, null);
+ }
+
+ /**
+ * @param creature a creature.
+ * @return true when it is a bot of either kind.
+ */
+ public static boolean isBot(Creature creature)
+ {
+ return FakePlayerBotRef.of(creature) != null;
+ }
+}
diff --git a/tools/build.sh b/tools/build.sh
new file mode 100644
index 0000000..754586d
--- /dev/null
+++ b/tools/build.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Builds GameServer.jar / LoginServer.jar from src_mobius with ant (JDK 25).
+# Usage: tools/build.sh -> build/dist/libs/GameServer.jar
+set -e
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
+ant -q jar
+ls -la "$ROOT/src_mobius/mobius/build/dist/libs/"
diff --git a/tools/check_data.py b/tools/check_data.py
new file mode 100644
index 0000000..0d2897b
--- /dev/null
+++ b/tools/check_data.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""Static checks of the bot data files (run from anywhere, no server needed).
+
+- FakePlayerChatLines.xml / FakePlayerPools.xml: every #pool# exists, no cycles,
+ every {slot} is one the code can fill, morph brackets are balanced;
+- every seam the Java code or the intent lexicon refers to exists in the XML;
+- FakePlayerCombat.xml: every skill id is learnable by that class (or one of its
+ parent classes) according to the datapack skill trees, and every class id
+ used by the seeded characters has a profile.
+
+Usage: python tools/check_data.py [--skilltrees PATH] [--seed PATH]
+Exit code 1 on errors.
+"""
+import argparse
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude'
+DATA = SRC / 'dist' / 'game' / 'data'
+JAVA = SRC / 'java' / 'org' / 'l2jmobius' / 'gameserver'
+
+KNOWN_SLOTS = {'player', 'target', 'level', 'boss', 'zone', 'town', 'msg', 'prev', 'enemyclan', 'mob', 'castle',
+ 'clan', 'class', 'drop', 'item', 'price', 'crowd', 'daytime'}
+# Seams the code calls by a literal name (kept in sync with the harness list).
+CODE_SEAMS = {
+ 'greeting', 'whisper_default', 'party_accept', 'reply_insult_soft', 'reply_insult_aggro', 'reply_caps',
+ 'reply_again', 'revenge_meet', 'friendly_meet', 'gank_start', 'pkk_start', 'war_start', 'assist',
+ 'companion_done', 'invite_player', 'ambient', 'lfp', 'levelup', 'grats', 'overheard', 'overheard_q',
+ 'killed_by', 'died_mob', 'migrate', 'profession', 'store_title', 'trade_shout', 'call_help', 'raid_start',
+ 'bot_talk', 'siege_start', 'siege_defend', 'siege_gate', 'siege_engrave', 'siege_win', 'siege_lose',
+ 'reply_help_yes', 'reply_help_no', 'loot_brag',
+}
+# Class transfer chains (Interlude): child -> parent.
+PARENT = {1: 0, 2: 1, 3: 1, 4: 0, 5: 4, 6: 4, 7: 0, 8: 7, 9: 7, 11: 10, 12: 11, 13: 11, 14: 11, 15: 10, 16: 15,
+ 17: 15, 19: 18, 20: 19, 21: 19, 22: 18, 23: 22, 24: 22, 26: 25, 27: 26, 28: 26, 29: 25, 30: 29, 32: 31,
+ 33: 32, 34: 32, 35: 31, 36: 35, 37: 35, 39: 38, 40: 39, 41: 39, 42: 38, 43: 42, 45: 44, 46: 45, 47: 44,
+ 48: 47, 50: 49, 51: 50, 52: 50, 54: 53, 55: 54, 56: 53, 57: 56,
+ 88: 2, 89: 3, 90: 5, 91: 6, 92: 9, 93: 8, 94: 12, 95: 13, 96: 14, 97: 16, 98: 17, 99: 20, 100: 21,
+ 101: 23, 102: 24, 103: 27, 104: 28, 105: 30, 106: 33, 107: 34, 108: 36, 109: 37, 110: 40, 111: 41,
+ 112: 43, 113: 46, 114: 48, 115: 51, 116: 52, 117: 55, 118: 57}
+
+errors = []
+warnings = []
+
+
+def err(msg):
+ errors.append(msg)
+
+
+def warn(msg):
+ warnings.append(msg)
+
+
+def check_chat():
+ lines_xml = (DATA / 'FakePlayerChatLines.xml').read_text(encoding='utf-8')
+ pools_xml = (DATA / 'FakePlayerPools.xml').read_text(encoding='utf-8')
+ seams = {}
+ for m in re.finditer(r']*>(.*?)', lines_xml, re.S):
+ seams[m.group(1)] = re.findall(r']*>(.*?)', m.group(2), re.S)
+ pools = {}
+ for m in re.finditer(r']*>(.*?)', pools_xml, re.S):
+ pools[m.group(1)] = re.findall(r']*>(.*?)', m.group(2), re.S)
+ total_lines = sum(len(v) for v in seams.values())
+ total_pool = sum(len(v) for v in pools.values())
+ print(f'seams: {len(seams)} ({total_lines} lines), pools: {len(pools)} ({total_pool} lines)')
+
+ def scan(text, where):
+ for ref in re.findall(r'#([^#\s]+)#', text):
+ if ref not in pools:
+ err(f'{where}: unknown pool #{ref}#')
+ for slot in re.findall(r'\{([a-z_]+)\}', text):
+ if slot not in KNOWN_SLOTS:
+ err(f'{where}: unknown slot {{{slot}}}')
+ for open_, close in (('[', ']'), ('(', ')')):
+ if text.count(open_) != text.count(close):
+ err(f'{where}: unbalanced {open_}{close} in "{text}"')
+ if '{msg}' in text and where.split(':')[0] not in ('overheard', 'overheard_q', 'reply_again'):
+ pass
+
+ for key, lines in seams.items():
+ if not lines:
+ err(f'seam {key} is empty')
+ for line in lines:
+ scan(line, f'{key}:line')
+ for key, lines in pools.items():
+ if not lines:
+ err(f'pool {key} is empty')
+ for line in lines:
+ scan(line, f'pool {key}')
+
+ # Pool cycles.
+ graph = {k: set(re.findall(r'#([^#\s]+)#', ' '.join(v))) for k, v in pools.items()}
+
+ def cyclic(node, stack):
+ if node in stack:
+ return True
+ for nxt in graph.get(node, ()):
+ if cyclic(nxt, stack | {node}):
+ return True
+ return False
+
+ for key in pools:
+ if cyclic(key, set()):
+ err(f'pool cycle through {key}')
+
+ # Seams referenced by code and intents.
+ intents_xml = (DATA / 'FakePlayerIntents.xml').read_text(encoding='utf-8')
+ intent_seams = set(re.findall(r'seam="([^"]+)"', intents_xml)) - {''}
+ for seam in sorted(CODE_SEAMS | intent_seams):
+ if seam not in seams:
+ err(f'seam referenced by code/intents but missing in XML: {seam}')
+ # Seams referenced as literals in the Java sources (best effort).
+ literal = set()
+ for java in JAVA.rglob('FakePlayer*.java'):
+ text = java.read_text(encoding='utf-8', errors='replace')
+ for m in re.finditer(r'(?:eventLine|optionalLine|compose|speak|speakPrivate|pick|pickFor)\([^;]*?"([a-z_]+)"', text):
+ literal.add(m.group(1))
+ for seam in sorted(literal):
+ if seam not in seams and seam not in ('personality', 'player', 'target', 'level', 'msg', 'boss', 'zone', 'neutral', 'ganker', 'item', 'price'):
+ warn(f'literal "{seam}" used with the chat engine but not a seam (check the call)')
+ unused = sorted(set(seams) - CODE_SEAMS - intent_seams)
+ if unused:
+ warn(f'seams not referenced by code or intents: {", ".join(unused)}')
+
+
+def check_combat(skilltrees, seed):
+ combat_xml = (DATA / 'FakePlayerCombat.xml').read_text(encoding='utf-8')
+ profiles = {}
+ for m in re.finditer(r']*>(.*?)', combat_xml, re.S):
+ profiles[int(m.group(1))] = [int(x) for x in re.findall(r'id="(\d+)"', m.group(2))]
+ print(f'combat profiles: {len(profiles)} classes')
+ if skilltrees and Path(skilltrees).is_dir():
+ learnable = {}
+ for f in Path(skilltrees).rglob('*.xml'):
+ text = f.read_text(encoding='utf-8', errors='replace')
+ for tree in re.finditer(r']*>(.*?)', text, re.S):
+ cid = int(tree.group(1))
+ learnable.setdefault(cid, set()).update(int(x) for x in re.findall(r'skillId="(\d+)"', tree.group(2)))
+
+ def chain(cid):
+ out = set()
+ while cid is not None:
+ out |= learnable.get(cid, set())
+ cid = PARENT.get(cid)
+ return out
+
+ for cid, skills in profiles.items():
+ available = chain(cid)
+ for sid in skills:
+ if sid not in available:
+ err(f'combat profile class {cid}: skill {sid} is not learnable by this class chain')
+ else:
+ warn('skill trees not found, combat skill ids not verified (pass --skilltrees)')
+ if seed and Path(seed).is_file():
+ text = Path(seed).read_text(encoding='utf-8', errors='replace')
+ classes = set()
+ for m in re.finditer(r"'sppbots', '[^']*', (?:-?\d+, ){12}(\d+), \d+, \d+", text):
+ classes.add(int(m.group(1)))
+ missing = sorted(c for c in classes if c not in profiles)
+ if missing:
+ warn(f'seed classes without a combat profile (default profile is used): {missing}')
+ else:
+ print(f'all {len(classes)} seed classes have a combat profile')
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--skilltrees', default=str(ROOT / 'server' / 'game' / 'data' / 'stats' / 'players' / 'skillTrees'))
+ parser.add_argument('--seed', default=str(SRC / 'dist' / 'db_installer' / 'sql' / 'spp_clan_seed.sql'))
+ args = parser.parse_args()
+ check_chat()
+ check_combat(args.skilltrees, args.seed)
+ for w in warnings:
+ print('WARN', w)
+ for e in errors:
+ print('ERROR', e)
+ print(f'{len(errors)} errors, {len(warnings)} warnings')
+ sys.exit(1 if errors else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/deploy.sh b/tools/deploy.sh
new file mode 100644
index 0000000..fa112c1
--- /dev/null
+++ b/tools/deploy.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+# Deploys the SPP overlay (bot data, configs, chat handlers, seeds) and the built jars
+# into a server directory (default: ../server). The datapack itself is vanilla Mobius,
+# only the files under src_mobius/.../dist are ours.
+# Usage: tools/deploy.sh [server_dir] [GameServer.jar]
+set -e
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
+TARGET="${1:-$ROOT/server}"
+JAR="${2:-$ROOT/src_mobius/mobius/build/dist/libs/GameServer.jar}"
+if [ ! -d "$TARGET/game" ]; then
+ echo "server dir not found: $TARGET" >&2
+ exit 1
+fi
+# Overlay: everything under dist except libs (vendored build deps) and the ini files that
+# the docker entrypoint patches in place (Database.ini / Server.ini are not in the overlay anyway).
+rsync -a --no-perms --no-owner --no-group --exclude 'libs/' "$SRC/dist/" "$TARGET/"
+if [ -f "$JAR" ]; then
+ cp -f "$JAR" "$TARGET/libs/GameServer.jar"
+ LOGIN="$(dirname "$JAR")/LoginServer.jar"
+ [ -f "$LOGIN" ] && cp -f "$LOGIN" "$TARGET/libs/LoginServer.jar"
+ echo "jars -> $TARGET/libs"
+else
+ echo "no jar at $JAR (build first: tools/build.sh); overlay deployed only"
+fi
+echo "overlay -> $TARGET (data, config, scripts, sql seeds)"
diff --git a/tools/fix_zones.py b/tools/fix_zones.py
new file mode 100644
index 0000000..b7bebee
--- /dev/null
+++ b/tools/fix_zones.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""Re-checks the bot farming zones (FakePlayerProgression.xml) against the real
+monster spawns of the datapack and keeps only spots where the monsters match
+the zone level band. Bots are real players now: a level 15 fighter dropped
+next to a level 34 Turek Orc Elder simply dies.
+
+For every zone point the monsters spawned within RADIUS are collected (point
+spawns and territory spawns), the point is kept when the median monster level
+fits the band and no dangerous pack (p75) sits above band+2. Zones that end
+up with too few points get their band re-labelled to the real monster levels.
+
+Usage: python tools/fix_zones.py [--apply] [--datapack server/game/data]
+Without --apply only the report is printed.
+"""
+import argparse
+import math
+import re
+import statistics
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+XML = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude' / 'dist' / 'game' / 'data' / 'FakePlayerProgression.xml'
+RADIUS = 1400
+MIN_POINTS = 10
+BAND = 4 # Width of a level band a spot is sorted into.
+# Names for auto generated zones (grid cell of 4000 units -> area name), used to fill level holes.
+CELL_NAMES = {
+ (-2, 27): 'RuinsOfAgony', (-5, 35): 'RuinsOfDespair', (-4, 37): 'RuinsOfDespair',
+ (-12, 11): 'Swampland', (-13, 11): 'Swampland', (-3, 12): 'DarkForest', (-4, 12): 'DarkForest',
+ (3, 19): 'SpiderNest', (4, 19): 'SpiderNest', (-17, 26): 'WindmillHill', (-15, 27): 'AbandonedCamp',
+ (-14, 27): 'AbandonedCamp', (36, -44): 'AbandonedCoalMines', (37, -43): 'AbandonedCoalMines',
+}
+
+
+def load_npc_levels(datapack):
+ levels = {}
+ for f in (datapack / 'stats' / 'npcs').rglob('*.xml'):
+ text = f.read_text(encoding='utf-8', errors='replace')
+ for m in re.finditer(r']*>(.*?)', text, re.S):
+ body = g.group(1)
+ nodes = [(int(a), int(b)) for a, b in re.findall(r']*\sx=)[^>]*?count="(\d+)"', body):
+ level = levels.get(int(npc_id))
+ if level:
+ for _ in range(min(int(count), 6)):
+ blobs.append((cx, cy, level, radius))
+ return blobs
+
+
+def near(blobs, x, y):
+ out = []
+ for bx, by, level, radius in blobs:
+ if math.hypot(bx - x, by - y) <= RADIUS + radius:
+ out.append(level)
+ return out
+
+
+def percentile(values, p):
+ values = sorted(values)
+ k = (len(values) - 1) * p
+ lo = math.floor(k)
+ hi = math.ceil(k)
+ return values[lo] + (values[hi] - values[lo]) * (k - lo)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--apply', action='store_true')
+ parser.add_argument('--datapack', default=str(ROOT / 'server' / 'game' / 'data'))
+ args = parser.parse_args()
+ datapack = Path(args.datapack)
+ levels = load_npc_levels(datapack)
+ blobs = load_spawns(datapack, levels)
+ print(f'monster templates: {len(levels)}, spawn blobs: {len(blobs)}')
+ text = XML.read_text(encoding='utf-8')
+ new_text = text
+ for zm in re.finditer(r'(]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)()', text, re.S):
+ header, name, extra, lo, hi, body, tail = zm.groups()
+ lo = int(lo)
+ hi = int(hi)
+ points = re.findall(r'', body)
+ # Local danger of every point: the strongest packs around it (p90 of monster levels within RADIUS).
+ groups = {}
+ dropped = 0
+ for x, y, z in points:
+ around = near(blobs, int(x), int(y))
+ if len(around) < 3:
+ dropped += 1
+ continue
+ danger = int(percentile(around, 0.9))
+ groups.setdefault((danger - 1) // BAND, []).append((x, y, z, danger))
+ pieces = ''
+ summary = []
+ for band_index in sorted(groups):
+ pts = groups[band_index]
+ if len(pts) < MIN_POINTS:
+ dropped += len(pts)
+ continue
+ dangers = [p[3] for p in pts]
+ band_lo = max(1, min(dangers) - 3)
+ band_hi = max(band_lo + 2, max(dangers))
+ summary.append(f'{band_lo}-{band_hi}:{len(pts)}')
+ pieces += f'\t\n' + ''.join(f'\t\t\n' for p in pts) + '\t\n'
+ if not pieces:
+ # Nothing safe here at all: keep the ten least dangerous points under the old band.
+ ranked = sorted(((x, y, z, int(percentile(near(blobs, int(x), int(y)) or [hi], 0.9))) for x, y, z in points), key=lambda p: p[3])[:MIN_POINTS]
+ pieces = f'\t\n' + ''.join(f'\t\t\n' for p in ranked) + '\t\n'
+ summary.append(f'{lo}-{hi}:{len(ranked)} (least dangerous)')
+ print(f'{name:22s} was {lo}-{hi}: {len(points)} points -> bands {", ".join(summary)}; dropped {dropped}')
+ new_text = new_text.replace('\t' + header + body + tail + '\n', pieces)
+ # Every level 1..76 must have a zone: fill holes with zones built from the spawns themselves.
+ # Racial starting areas only serve their race, so the holes are computed over the shared zones.
+ bands = [(int(a), int(b)) for a, b in re.findall(r'', new_text)]
+ holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)]
+ if holes:
+ print(f'levels without a shared zone: {holes} -> building zones from spawns')
+ existing = set(re.findall(r'\n' + ''.join(f'\t\t\n' for x, y, z in zpoints) + '\t\n'
+ print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points')
+ if added:
+ new_text = new_text.replace('\t', new_text)]
+ holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)]
+ if holes:
+ print(f'still without a shared zone (nearest zone is used): {holes}')
+ if args.apply:
+ XML.write_text(new_text, encoding='utf-8')
+ print(f'written {XML}')
+ else:
+ print('dry run, add --apply to write')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/harness/ChatHarness.java b/tools/harness/ChatHarness.java
new file mode 100644
index 0000000..7cf95c9
--- /dev/null
+++ b/tools/harness/ChatHarness.java
@@ -0,0 +1,221 @@
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.l2jmobius.gameserver.managers.FakePlayerBotContext;
+import org.l2jmobius.gameserver.managers.FakePlayerChatLines;
+import org.l2jmobius.gameserver.managers.FakePlayerHeardManager;
+import org.l2jmobius.gameserver.managers.FakePlayerIdiolect;
+import org.l2jmobius.gameserver.managers.FakePlayerIntentParser;
+
+/**
+ * Offline harness for the bot chat engine: runs the real server classes
+ * (FakePlayerChatLines, FakePlayerIntentParser, FakePlayerIdiolect,
+ * FakePlayerBotContext, FakePlayerHeardManager) against the real XML data
+ * without a game server. Run from a directory that contains data/ (see
+ * tools/run_harness.sh). Exit code 1 on any failed check.
+ */
+public class ChatHarness
+{
+ private static int failures = 0;
+ private static int checks = 0;
+
+ private static void check(boolean condition, String what)
+ {
+ checks++;
+ if (!condition)
+ {
+ failures++;
+ System.out.println(" FAIL: " + what);
+ }
+ }
+
+ public static void main(String[] args)
+ {
+ final FakePlayerChatLines lines = FakePlayerChatLines.getInstance();
+ final FakePlayerIntentParser intents = FakePlayerIntentParser.getInstance();
+
+ // 1. Every seam the code uses must exist and produce a line with full slots.
+ final String[] codeSeams =
+ {
+ "greeting", "whisper_default", "reply_where", "reply_price", "reply_help_yes", "reply_help_no", "reply_party_no", "party_accept", "reply_insult_soft", "reply_insult_aggro", "reply_thanks", "reply_botcheck", "reply_duel", "reply_rumor_none", "reply_bye", "reply_yes_ok", "reply_how", "reply_caps", "reply_again", "overheard_q", "reply_level", "gank_start", "pkk_start", "war_start", "killed_by", "revenge_meet", "friendly_meet", "ambient", "levelup", "grats", "migrate", "raid_start", "trade_shout", "invite_player", "call_help", "lfp", "overheard", "assist", "companion_done", "store_title", "bot_talk", "reply_drop", "siege_start", "siege_defend", "siege_gate", "siege_engrave", "siege_win", "siege_lose", "profession", "died_mob", "loot_brag", "reply_clan", "reply_solo", "reply_long", "reply_pk"
+ };
+ final Map slots = new HashMap<>();
+ slots.put("player", "Vanger");
+ slots.put("target", "Vanger");
+ slots.put("level", "42");
+ slots.put("boss", "Core");
+ slots.put("zone", "Cruma");
+ slots.put("town", "Giran");
+ slots.put("msg", "продам дроп");
+ slots.put("prev", "не пойду");
+ slots.put("enemyclan", "RedCrew");
+ slots.put("mob", "Cruma Marshlands Traitor");
+ slots.put("castle", "Giran");
+ slots.put("clan", "IronPact");
+ slots.put("class", "Gladiator");
+ slots.put("drop", "Oriharukon Ore");
+ slots.put("item", "соски D");
+ slots.put("price", "50к");
+ slots.put("crowd", "6");
+ System.out.println("== seams used by code (" + codeSeams.length + ") ==");
+ final Map samples = new TreeMap<>();
+ for (String seam : codeSeams)
+ {
+ String ok = null;
+ for (int botId = 1; (botId <= 12) && (ok == null); botId++)
+ {
+ final Map facts = new HashMap<>();
+ facts.put("personality", (botId % 4 == 0) ? "ganker" : (botId % 4 == 1) ? "helper" : (botId % 4 == 2) ? "pkk" : "neutral");
+ facts.put("asker", (botId % 3 == 0) ? "stranger" : (botId % 3 == 1) ? "clanmate" : "killed_me");
+ ok = lines.speak(botId, seam, facts, slots, false, null);
+ }
+ check(ok != null, "seam produces nothing: " + seam);
+ if (ok != null)
+ {
+ samples.put(seam, ok);
+ }
+ }
+ for (Map.Entry entry : samples.entrySet())
+ {
+ System.out.println(" " + entry.getKey() + ": " + entry.getValue());
+ }
+
+ // 2. Context facts change what a bot says (plan / event / grounding slots).
+ System.out.println("== context ==");
+ FakePlayerBotContext.setPlan(77, "siege", 60000);
+ FakePlayerBotContext.setSlot(77, "castle", "Aden");
+ int siegeMentions = 0;
+ for (int i = 0; i < 40; i++)
+ {
+ final String line = lines.speak(77, "ambient", Map.of("personality", "neutral"), null, false, null);
+ if ((line != null) && (line.contains("осад") || line.contains("Aden")))
+ {
+ siegeMentions++;
+ }
+ }
+ System.out.println(" plan=siege -> siege mentions in 40 ambient lines: " + siegeMentions);
+ check(siegeMentions >= 8, "plan=siege rule should fire regularly (got " + siegeMentions + ")");
+ FakePlayerBotContext.setPlan(78, "farm", 0);
+ FakePlayerBotContext.setEvent(78, "profession");
+ int professionMentions = 0;
+ for (int i = 0; i < 40; i++)
+ {
+ final String line = lines.speak(78, "ambient", Map.of("personality", "neutral"), null, false, null);
+ if ((line != null) && line.contains("проф"))
+ {
+ professionMentions++;
+ }
+ }
+ System.out.println(" event=profession -> mentions in 40 ambient lines: " + professionMentions);
+ check(professionMentions >= 2, "event=profession rule should fire (got " + professionMentions + ")");
+ FakePlayerBotContext.setSlot(79, "drop", "Adamantite Nugget");
+ FakePlayerBotContext.setSlot(79, "mob", "Dion Grizzly");
+ final String dropLine = lines.speak(79, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null);
+ System.out.println(" reply_drop with grounding slots: " + dropLine);
+ check(dropLine != null, "reply_drop with slots from context");
+ final String noDrop = lines.speak(80, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null);
+ System.out.println(" reply_drop without drop slot: " + noDrop);
+ check(noDrop != null, "reply_drop must have slot-free lines");
+
+ // 3. Bot to bot openers are understood by the listener (intent) or fall back to overheard.
+ System.out.println("== bot_talk -> intents ==");
+ final Map distribution = new LinkedHashMap<>();
+ int understood = 0;
+ final int openers = 200;
+ for (int i = 0; i < openers; i++)
+ {
+ final int speaker = 100 + (i % 20);
+ final String opener = lines.speak(speaker, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Kolyan", "level", "35"), false, null);
+ if (opener == null)
+ {
+ continue;
+ }
+ final FakePlayerIntentParser.Intent intent = intents.parse(opener);
+ final String key = (intent != null) ? intent.key : (FakePlayerHeardManager.isQuestionLine(opener) ? "(overheard_q)" : "(overheard)");
+ distribution.merge(key, 1, Integer::sum);
+ if (intent != null)
+ {
+ understood++;
+ }
+ if (i < 12)
+ {
+ System.out.println(" \"" + opener + "\" -> " + key);
+ }
+ }
+ System.out.println(" intents: " + distribution);
+ check(understood >= (openers / 2), "at least half of bot_talk openers should map to an intent (got " + understood + "/" + openers + ")");
+
+ // 4. Sample dialogue as the social layer would run it (opener -> reply seam of the intent).
+ System.out.println("== sample dialogues ==");
+ for (int i = 0; i < 6; i++)
+ {
+ final int a = 300 + i;
+ final int b = 400 + i;
+ final String opener = lines.speak(a, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Zheka"), false, null);
+ final FakePlayerIntentParser.Intent intent = (opener != null) ? intents.parse(opener) : null;
+ String reply;
+ if ((intent != null) && !intent.seam.isEmpty())
+ {
+ final Map replySlots = new HashMap<>(slots);
+ replySlots.put("player", "Vovan");
+ reply = lines.speak(b, intent.seam, Map.of("personality", "helper", "asker", "clanmate"), replySlots, false, FakePlayerIdiolect.Mirror.of(opener));
+ }
+ else
+ {
+ reply = lines.speak(b, (opener != null) && FakePlayerHeardManager.isQuestionLine(opener) ? "overheard_q" : "overheard", Map.of("personality", "helper"), Map.of("msg", FakePlayerHeardManager.echoOf(opener != null ? opener : ""), "player", "Vovan"), true, null);
+ }
+ System.out.println(" Vovan: " + opener);
+ System.out.println(" Zheka: " + reply + " [" + ((intent != null) ? intent.key : "overheard") + "]");
+ }
+
+ // 5. Idiolect: two bots never sound the same.
+ System.out.println("== idiolect ==");
+ final FakePlayerIdiolect one = FakePlayerIdiolect.of(1001);
+ final FakePlayerIdiolect two = FakePlayerIdiolect.of(1002);
+ check(!(one.laugh.equals(two.laugh) && one.filler.equals(two.filler) && (one.chattiness == two.chattiness) && (one.slang == two.slang)), "idiolects should differ");
+ System.out.println(" 1001: laugh=" + one.laugh + " filler=" + one.filler + " slang=" + one.slang + " mat=" + one.traitMat);
+ System.out.println(" 1002: laugh=" + two.laugh + " filler=" + two.filler + " slang=" + two.slang + " mat=" + two.traitMat);
+
+ // 6. Heard classification helpers.
+ check(FakePlayerHeardManager.isQuestionLine("кто знает где рб"), "cyrillic question detection");
+ check(!FakePlayerHeardManager.isQuestionLine("фарм идет норм"), "statement is not a question");
+ check(FakePlayerHeardManager.isCommercialLine("продам соски дешево"), "commercial detection");
+ check("продам соски дешево".startsWith(FakePlayerHeardManager.echoOf("продам соски дешево налетай")), "echo takes first words");
+
+ // 7. Intents that sieges / economy rely on.
+ final FakePlayerIntentParser.Intent drop = intents.parse("дроп есть?");
+ check((drop != null) && "ask_drop".equals(drop.key), "ask_drop intent");
+ final FakePlayerIntentParser.Intent level = intents.parse("какой лвл");
+ check((level != null) && "ask_level".equals(level.key), "ask_level intent");
+ final FakePlayerIntentParser.Intent party = intents.parse("го в пати");
+ check((party != null) && "ask_party".equals(party.key), "ask_party intent");
+ final FakePlayerIntentParser.Intent how = intents.parse("как фарм?");
+ check((how != null) && "how_are_you".equals(how.key), "how_are_you intent");
+ final String[][] regression =
+ {
+ { "го пвп", "duel" }, { "помгите", "ask_help" }, { "дуель?", "duel" }, { "ты бот?", "botcheck" }, { "спс бро", "thanks" },
+ { "лох", "insult" }, { "где ты", "ask_where" }, { "что нового", "ask_rumors" }, { "пока", "bye" }, { "прив", "greeting" },
+ { "ок", "yes_ok" }, { "сколько стоит", "ask_price" }, { "клан есть?", "ask_clan" }, { "ты соло?", "ask_solo" },
+ { "давно тут стоишь?", "ask_long" }, { "пк ходят?", "ask_pk" }, { "здарова, как жизнь молодая?", "how_are_you" },
+ { "где качаться думаешь", "ask_where" }
+ };
+ for (String[] pair : regression)
+ {
+ final FakePlayerIntentParser.Intent parsed = intents.parse(pair[0]);
+ final String got = (parsed != null) ? parsed.key : "null";
+ check(got.equals(pair[1]), "intent of \"" + pair[0] + "\": expected " + pair[1] + ", got " + got);
+ }
+ final String[] mustBeNull = { "думаешь тут фармить?", "молодая", "стоишь тут" };
+ for (String text : mustBeNull)
+ {
+ final FakePlayerIntentParser.Intent parsed = intents.parse(text);
+ check((parsed == null) || "ask_long".equals(parsed.key) || "ask_where".equals(parsed.key), "no false positive for \"" + text + "\" (got " + ((parsed != null) ? parsed.key : "null") + ")");
+ }
+
+ System.out.println("== " + (checks - failures) + "/" + checks + " checks passed ==");
+ System.exit(failures == 0 ? 0 : 1);
+ }
+}
diff --git a/tools/run_harness.sh b/tools/run_harness.sh
new file mode 100644
index 0000000..b1dccca
--- /dev/null
+++ b/tools/run_harness.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Offline chat harness: real engine classes + real XML, no game server.
+# Usage: tools/run_harness.sh [path/to/GameServer.jar]
+# Needs JAVA_HOME with JDK 25 (or java/javac on PATH).
+set -e
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
+JAR="${1:-$ROOT/build/dist/libs/GameServer.jar}"
+if [ ! -f "$JAR" ]; then
+ echo "GameServer.jar not found: $JAR (build first: tools/build.sh)" >&2
+ exit 1
+fi
+WORK="$(mktemp -d)"
+mkdir -p "$WORK/data/xsd"
+cp "$SRC"/dist/game/data/FakePlayer*.xml "$WORK/data/"
+cp "$SRC"/dist/game/data/xsd/FakePlayer*.xsd "$WORK/data/xsd/"
+JAVAC="${JAVA_HOME:+$JAVA_HOME/bin/}javac"
+JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
+"$JAVAC" -nowarn -d "$WORK" -cp "$JAR:$SRC/dist/libs/*" "$ROOT/tools/harness/ChatHarness.java"
+cd "$WORK"
+"$JAVA" -Dfile.encoding=UTF-8 -cp "$WORK:$JAR:$SRC/dist/libs/*" ChatHarness
+STATUS=$?
+rm -rf "$WORK"
+exit $STATUS