/*
 * Copyright (c) 2013 L2jMobius
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
 * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package org.l2jmobius.gameserver.managers;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
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.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.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.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. 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.
 * <p>
 * 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 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 };
	private static final int[] BLUNTS = { 4, 182, 182, 201, 201, 7834, 7895, 7895 };
	private static final int[] DAGGERS = { 216, 222, 222, 242, 242, 243, 236, 236 };
	private static final int[] BOWS = { 14, 277, 277, 285, 285, 287, 289, 289 };
	private static final int[] STAVES = { 176, 185, 185, 198, 198, 209, 4904, 4904 };
	private static final int[] HEAVY_CHEST = { 26, 350, 350, 356, 356, 359, 2383, 2383 };
	private static final int[] LIGHT_CHEST = { 3899, 396, 396, 401, 401, 402, 2394, 2394 };
	private static final int[] ROBE_CHEST = { 430, 438, 438, 440, 440, 450, 459, 459 };
	private static final int[] HEAVY_LEGS = { 34, 379, 379, 382, 382, 384, 388, 388 };
	private static final int[] LIGHT_LEGS = { 30, 415, 415, 419, 419, 423, 2389, 2389 };
	private static final int[] ROBE_LEGS = { 464, 468, 468, 472, 472, 482, 2405, 2405 };
	private static final boolean[] HEAVY_ONEPIECE = { false, false, false, true, true, true, true, true };
	private static final boolean[] LIGHT_ONEPIECE = { false, true, true, true, true, true, true, true };
	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
		"ищу клан", "куплю адену", "продам всё", "не бей", "afk", "фарм", "торгую", "пк ловлю",
		"мимо", "новичок", "тут был", "Гиран", "Дион", "Аден", "хочу спать", "качаюсь",
		"без обид", "мирный", "за кланы", "рб хантер", "спойлер", "бафер", "тащу", "нуб"
	};

	private static final String[] STORE_MSGS = { "продам дроп с фарма", "скупаю ресы дорого", "распродажа, налетай", "продам D грейд дешево", "куплю бижу" };

	private final List<Player> _bots = new CopyOnWriteArrayList<>();
	private final List<Player> _view = Collections.unmodifiableList(_bots);
	private final Map<Integer, FakePlayerBotState> _states = new ConcurrentHashMap<>();
	private final Map<Integer, Long> _reviveAt = new ConcurrentHashMap<>();
	private final Map<String, Object[]> _clanSpots = new ConcurrentHashMap<>();

	protected FakePlayerHeadlessManager()
	{
		if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED || (FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT <= 0))
		{
			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).
		final List<Integer> charIds = new ArrayList<>();
		final String query = FakePlayersConfig.FAKE_PLAYER_PROGRESSION
			// NPC bots active: only the reserved tail belongs to headless players.
			? "SELECT charId FROM characters WHERE account_name='sppbots' ORDER BY charId DESC LIMIT " + count
			// Headless only world: take a level spread sample of the whole population.
			: "SELECT charId FROM characters WHERE account_name='sppbots' ORDER BY RAND() LIMIT " + count;
		try (Connection con = DatabaseFactory.getConnection();
			Statement statement = con.createStatement();
			ResultSet rset = statement.executeQuery(query))
		{
			while (rset.next())
			{
				charIds.add(rset.getInt(1));
			}
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not list characters.", e);
			return;
		}

		for (int charId : charIds)
		{
			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)
			{
				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)]);
			// The bot is online for the engine (clan member lists, sieges, parties count it like a logged in player).
			player.setOnlineStatus(true, false);
			// Restore clan membership so crests, clan skills, clan wars and sieges 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((clan.getLeaderId() == player.getObjectId()) ? 1 : 6);
					final org.l2jmobius.gameserver.entity.clan.ClanMember member = clan.getClanMember(player.getObjectId());
					if (member != null)
					{
						member.setPlayer(player); // Clan skills, siege skills for the leader, "online" for the clan.
					}
				}
			}
			// 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];
					state.zone = (String) picked[1];
					if (clanKey != null)
					{
						_clanSpots.put(clanKey, picked);
					}
				}
			}
			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)
	{
		return ensureOnline(charId, false);
	}

	/**
	 * @param charId the character id.
	 * @param temporary true when the bot should log out again after its job (siege reinforcement).
	 * @return the headless player, or null.
	 */
	public Player ensureOnline(int charId, boolean temporary)
	{
		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)
		{
			final FakePlayerBotState state = stateOf(bot);
			if ((state != null) && temporary)
			{
				state.temporary = true;
			}
			LOGGER.info(getClass().getSimpleName() + ": " + bot.getName() + " logged in on demand" + (temporary ? " for a siege" : "") + " (" + _bots.size() + " headless players now).");
		}
		return bot;
	}

	/**
	 * Logs a temporary bot out (siege reinforcements after the battle): stores it and removes it from the world.
	 * @param bot the bot.
	 */
	public void despawn(Player bot)
	{
		final FakePlayerBotState state = stateOf(bot);
		if ((state == null) || !state.temporary || (state.siegeSide != 0))
		{
			return;
		}
		try
		{
			closeStore(bot);
			if (bot.getParty() != null)
			{
				bot.leaveParty();
			}
			_states.remove(bot.getObjectId());
			_bots.remove(bot);
			bot.setOnlineStatus(false, false);
			bot.deleteMe();
			LOGGER.info(getClass().getSimpleName() + ": " + bot.getName() + " logged out (" + _bots.size() + " headless players now).");
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not log out " + bot.getName(), e);
		}
	}

	// ======================== Registry ========================

	/**
	 * @return all headless bots (read only).
	 */
	public List<Player> 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.
	 * @param target the headless bot.
	 */
	public void autoAcceptParty(Player requestor, Player target)
	{
		ThreadPool.schedule(() ->
		{
			try
			{
				requestor.sendPacket(new JoinParty(1));
				if (requestor.isInParty())
				{
					if (requestor.getParty().getMemberCount() < 9)
					{
						target.joinParty(requestor.getParty());
					}
				}
				else
				{
					requestor.setParty(new Party(requestor, requestor.getPartyDistributionType()));
					target.joinParty(requestor.getParty());
				}
				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)
			{
				LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Party accept failed.", e);
			}
		}, 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();
		for (Player bot : _bots)
		{
			try
			{
				handle(bot, now);
			}
			catch (Exception e)
			{
				// Keep the loop alive.
			}
		}
	}

	private void handle(Player bot, long now)
	{
		// Safety net: a headless bot must never stay decayed after a teleport.
		if (!bot.isSpawned())
		{
			bot.setTeleporting(true);
			bot.onTeleported();
			bot.broadcastUserInfo();
			return;
		}

		final FakePlayerBotState state = _states.get(bot.getObjectId());
		if (state == null)
		{
			return;
		}

		if (bot.isDead())
		{
			handleDead(bot, state, now);
			return;
		}

		// Real level ups happen by themselves (they earn real exp) - react, take the profession, migrate.
		final long exp = bot.getExp();
		if (exp > state.lastExp)
		{
			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)
			{
				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));
				}
			}
			if ((newTier != oldTier) && (state.siegeSide == 0) && (FakePlayerSocial.humanPartyAnchor(bot) == null))
			{
				migrate(bot, state, true);
			}
			else
			{
				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())
					{
						bot.setTarget(victimPlayer);
						bot.getAI().setIntentionAttack(victimPlayer);
					}
					return;
				}
				state.forcedTargetId = 0;
			}
		}

		// 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.
		if (FakePlayerBrain.act(bot, state, true))
		{
			return;
		}

		// 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));
	}

	/**
	 * 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)
	{
		say(bot, message, "");
	}

	public void say(Player bot, String message, String seam)
	{
		if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
		{
			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.
	 * @param message the line.
	 */
	public void sayRaw(Player bot, String message)
	{
		if ((bot == null) || (message == null) || message.isEmpty() || bot.isDead())
		{
			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);
	}

	/**
	 * Region-wide shout (call for help, siege cries).
	 * @param bot the bot.
	 * @param message the line.
	 * @param seam the seam.
	 */
	public void shout(Player bot, String message, String seam)
	{
		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);
	}

	/**
	 * Trade channel line (party search, sales).
	 * @param bot the bot.
	 * @param message the line.
	 * @param seam the seam.
	 */
	public void trade(Player bot, String message, String seam)
	{
		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);
	}

	public void whisper(Player bot, Player to, String message)
	{
		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.
	 */
	public String persona(Player bot, String about)
	{
		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)
		{
			if (!state.zone.isEmpty())
			{
				sb.append(" Твой спот: ").append(state.zone).append('.');
			}
			switch (state.personality)
			{
				case "ganker":
					sb.append(" Ты пкшер: задираешь чужаков на споте, дерзкий, пишешь коротко.");
					break;
				case "pkk":
					sb.append(" Ты охотник на ПК: презираешь убийц новичков, говоришь о справедливости.");
					break;
				case "helper":
					sb.append(" Ты дружелюбный игрок: помогаешь прохожим, зовешь в пати.");
					break;
				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);
				if (!memory.isEmpty())
				{
					sb.append(' ').append(memory);
				}
				final String rumors = FakePlayerRumorManager.getInstance().describeFor(state.seedId, about);
				if (!rumors.isEmpty())
				{
					sb.append(' ').append(rumors);
				}
			}
		}
		return sb.toString();
	}

	/**
	 * Called from ChatWhisper when a player whispers a headless bot.
	 * @param player the speaker.
	 * @param bot the headless bot.
	 * @param message the text.
	 */
	public void onWhisper(Player player, Player bot, String message)
	{
		final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
		final Understanding understanding = FakePlayerUnderstanding.understand(ref, player, message, true);
		if ((understanding != null) && understanding.silent)
		{
			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(ref.botId, player.getName(), "он писал: " + message + " / я: " + answer);
			}, () -> ThreadPool.schedule(() -> whisper(bot, player, fallback), delay));
			return;
		}
		ThreadPool.schedule(() -> whisper(bot, player, fallback), delay);
	}

	/**
	 * 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 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 (bot.isDead() || (bot.calculateDistance2D(player) > 1500))
			{
				return;
			}
			if (useLlm)
			{
				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 void reply(Player bot, FakePlayerBotState state, String line, String seam)
	{
		if (line == null)
		{
			return;
		}
		say(bot, line, seam);
		if (state != null)
		{
			state.repliesGiven.incrementAndGet();
			state.action("reply");
		}
	}

	/**
	 * Called from Player.doDie when a headless bot dies (any killer).
	 * @param bot the dead bot.
	 * @param killer the killer (may be null).
	 */
	public void onBotDied(Player bot, Creature killer)
	{
		final FakePlayerBotState state = stateOf(bot);
		if (state == null)
		{
			return;
		}
		state.forcedTargetId = 0;
		FakePlayerSocial.getInstance().onDied(bot, state, killer);
	}

	/**
	 * 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)
	{
		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;
		}
		try
		{
			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);
			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);
			state.tradeUntil = now + tradeFor;
			FakePlayerBotContext.setPlan(state.seedId, "trade", tradeFor);
			FakePlayerBotContext.setEvent(state.seedId, "opened_store");
			ThreadPool.schedule(() -> finishStore(bot), 1500);
		}
		catch (Exception e)
		{
			state.tradeUntil = 0;
			state.nextTradeAt = now + 600000;
		}
	}

	/**
	 * Second half of opening a store - runs after the teleport actually landed.
	 * @param bot the bot.
	 */
	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())
			{
				state.tradeUntil = 0;
				state.nextTradeAt = now + 300000;
				return;
			}
			// In town: the junk goes to the merchant first, the good stuff to the store.
			FakePlayerEconomy.getInstance().visitTown(bot, state);
			final List<Item> loot = FakePlayerEconomy.getInstance().storeGoods(bot);
			if (loot.isEmpty())
			{
				state.tradeUntil = 0;
				state.nextTradeAt = now + 900000;
				returnToSpot(bot);
				return;
			}

			bot.getSellList().clear();
			int listed = 0;
			for (Item item : loot)
			{
				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(), Math.min(item.getCount(), 5000), price);
				listed++;
			}
			if (listed == 0)
			{
				state.tradeUntil = 0;
				state.nextTradeAt = now + 900000;
				returnToSpot(bot);
				return;
			}
			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);
			bot.sitDown();
			bot.setPrivateStoreType(PrivateStoreType.SELL);
			bot.broadcastUserInfo();
			// Without this the store caption stays invisible for everyone around.
			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)
		{
			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
	 * has no client, so without this it stays decayed (invisible) forever.
	 * @param bot the bot.
	 * @param x target x.
	 * @param y target y.
	 * @param z target z.
	 */
	public void teleport(Player bot, int x, int y, int z)
	{
		bot.teleToLocation(x, y, z);
		ThreadPool.schedule(() ->
		{
			try
			{
				bot.onTeleported();
				bot.broadcastUserInfo();
			}
			catch (Exception e)
			{
				// Ignore - next tick will retry through the visibility check.
			}
		}, 500);
	}

	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 ========================

	/**
	 * Выдаёт боту скиллы его класса и экипировку по уровню: сид-персонажи приходят
	 * из базы голыми и без скиллов, поэтому раньше они дрались кулаками.
	 * @param bot the bot.
	 */
	private void equipAndTeach(Player bot)
	{
		try
		{
			bot.giveAvailableSkills(true, true, true);
			bot.sendSkillList();

			// Снимаем всё, что не по уровню (например, после пересчёта уровня из опыта).
			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, 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();
		}
		catch (Exception e)
		{
			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.
	 */
	private static int gradeForLevel(int level)
	{
		if (level >= 76)
		{
			return 5; // S
		}
		if (level >= 61)
		{
			return 4; // A
		}
		if (level >= 52)
		{
			return 3; // B
		}
		if (level >= 40)
		{
			return 2; // C
		}
		if (level >= 20)
		{
			return 1; // D
		}
		return 0; // NG
	}

	private void giveAndWear(Player bot, int itemId)
	{
		if (itemId <= 0)
		{
			return;
		}
		Item item = bot.getInventory().getItemByItemId(itemId);
		if (item == null)
		{
			item = bot.addItem(ItemProcessType.NONE, itemId, 1, bot, false);
		}
		if ((item != null) && !item.isEquipped())
		{
			bot.useEquippableItem(item, false);
		}
	}

	// ======================== Debug ========================

	private Player findBot(String name)
	{
		for (Player bot : _bots)
		{
			if (bot.getName().equalsIgnoreCase(name))
			{
				return bot;
			}
		}
		return null;
	}

	/**
	 * Debug commands for the dashboard: force behaviors without waiting for timers.
	 * @param command command name.
	 * @param name bot name (empty = pick a random suitable bot).
	 * @param argument optional argument (player name / text / count).
	 * @return human readable result.
	 */
	public String debugCommand(String command, String name, String argument)
	{
		final long now = System.currentTimeMillis();
		Player bot = name.isEmpty() ? null : findBot(name);
		if ((bot == null) && !_bots.isEmpty() && name.isEmpty())
		{
			for (int i = 0; i < 40; i++)
			{
				final Player candidate = _bots.get(Rnd.get(_bots.size()));
				if (!candidate.isDead())
				{
					bot = candidate;
					break;
				}
			}
		}
		final FakePlayerBotState state = stateOf(bot);

		switch (command)
		{
			case "list":
			{
				final StringBuilder sb = new StringBuilder();
				int shown = 0;
				for (Player player : _bots)
				{
					if (shown++ >= 25)
					{
						break;
					}
					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) || (state == null))
				{
					return "bot not found";
				}
				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() + ": лавка открыта (" + FakePlayerEconomy.getInstance().storeGoods(bot).size() + " позиций)";
			}
			case "tradeall":
			{
				int count = 0;
				final int limit = argument.isEmpty() ? 10 : Math.min(50, Integer.parseInt(argument));
				for (Player player : _bots)
				{
					final FakePlayerBotState playerState = _states.get(player.getObjectId());
					if ((count >= limit) || player.isDead() || (player.getParty() != null) || (playerState == null) || (playerState.tradeUntil > 0))
					{
						continue;
					}
					if (FakePlayerEconomy.getInstance().storeGoods(player).isEmpty())
					{
						player.addItem(ItemProcessType.NONE, 1463, 300, player, false);
						player.addItem(ItemProcessType.NONE, 1874, 12, player, false);
					}
					openStore(player, now);
					count++;
				}
				return "лавок открыто: " + count;
			}
			case "closeshop":
			{
				if (bot == null)
				{
					return "bot not found";
				}
				closeStore(bot);
				return bot.getName() + ": лавка закрыта";
			}
			case "come":
			{
				final Player target = World.getPlayer(argument);
				if ((bot == null) || (target == null))
				{
					return "usage: come&name=<bot>&arg=<player>";
				}
				teleport(bot, target.getLocation());
				return bot.getName() + " телепортирован к " + target.getName();
			}
			case "party":
			{
				final Player target = World.getPlayer(argument);
				if ((bot == null) || (target == null))
				{
					return "usage: party&arg=<player>";
				}
				teleport(bot, target.getLocation());
				if (target.getParty() == null)
				{
					final Party party = new Party(target, target.getPartyDistributionType());
					target.setParty(party);
					bot.joinParty(party);
				}
				else
				{
					bot.joinParty(target.getParty());
				}
				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=<player>";
				}
				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);
				if ((bot == null) || (target == null))
				{
					return "usage: gank&arg=<player>";
				}
				final Player ganker = bot;
				teleport(ganker, target.getX() + 150, target.getY() + 150, target.getZ());
				ThreadPool.schedule(() -> forceTarget(ganker, target, 90), 1400);
				say(bot, FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "gank_start", target, Map.of("target", target.getName())), "gank_start");
				return bot.getName() + " атакует " + target.getName();
			}
			case "equip":
			{
				if (bot == null)
				{
					return "bot not found";
				}
				equipAndTeach(bot);
				return bot.getName() + ": выдан шмот и скиллы (" + bot.getSkills().size() + ")";
			}
			case "equipall":
			{
				int done = 0;
				for (Player player : _bots)
				{
					if (!player.isDead())
					{
						equipAndTeach(player);
						done++;
					}
				}
				return "переодето ботов: " + done;
			}
			case "brain":
			{
				if (bot == null)
				{
					return "bot not found";
				}
				return FakePlayerBrain.describe(bot);
			}
			case "gear":
			{
				if (bot == null)
				{
					return "bot not found";
				}
				final StringBuilder sb = new StringBuilder(bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + "\n");
				for (Item item : bot.getInventory().getItems())
				{
					if (item.isEquipped())
					{
						sb.append("  [").append(item.getTemplate().getCrystalType()).append("] ").append(item.getTemplate().getName()).append('\n');
					}
				}
				sb.append("  штраф экспертизы: ").append(bot.getExpertisePenaltyBonus());
				sb.append("\n  автошоты: ").append(bot.getAutoSoulShot()).append(", адена: ").append(bot.getAdena());
				return sb.toString();
			}
			case "levelup":
			{
				if (bot == null)
				{
					return "bot not found";
				}
				bot.getStat().addLevel((byte) 1);
				return bot.getName() + " теперь " + bot.getLevel() + " уровня (реакция придёт на следующем тике)";
			}
			case "migrate":
			{
				if ((bot == null) || (state == null))
				{
					return "bot not found";
				}
				migrate(bot, state, true);
				return bot.getName() + " переезжает (прощание, город, новый спот)";
			}
			case "town":
			{
				if ((bot == null) || (state == null))
				{
					return "bot not found";
				}
				return bot.getName() + ": " + FakePlayerEconomy.getInstance().visitTown(bot, state);
			}
			case "profession":
			{
				if ((bot == null) || (state == null))
				{
					return "bot not found";
				}
				final Object result = FakePlayerEconomy.getInstance().checkProfession(bot, state);
				return bot.getName() + ": " + ((result != null) ? ("новая профа " + result) : ("профа не положена (lvl " + bot.getLevel() + ", " + bot.getPlayerClass() + ")"));
			}
			case "say":
			{
				if ((bot == null) || (state == null))
				{
					return "bot not found";
				}
				if (argument.isEmpty())
				{
					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=<bot>&arg=<player to bring them to>
				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)
				{
					return "bot not found";
				}
				bot.getStatus().setCurrentHp(1);
				bot.doDie(bot);
				return bot.getName() + " убит (встанет в городе через ~15 сек)";
			}
			case "rumor":
			{
				if ((bot == null) || (state == null) || argument.isEmpty())
				{
					return "usage: rumor&arg=<player>";
				}
				FakePlayerRumorManager.getInstance().addWitnessed(state.seedId, "kill", argument, "кого-то");
				return bot.getName() + " теперь помнит слух про " + argument;
			}
			case "clanwar":
			{
				final Player target = World.getPlayer(argument);
				if (target == null)
				{
					return "usage: clanwar&arg=<player>";
				}
				if ((target.getClan() == null) || (bot == null) || (bot.getClan() == null))
				{
					return "нужен клан у игрока и у бота";
				}
				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=<charId>";
				}
			}
		}
		return "unknown command";
	}

	/**
	 * @return JSON array of headless bots for the dashboard.
	 */
	public String statusJson()
	{
		final StringBuilder sb = new StringBuilder(1 << 14);
		boolean first = true;
		for (Player bot : _bots)
		{
			final FakePlayerBotState state = _states.get(bot.getObjectId());
			if (!first)
			{
				sb.append(',');
			}
			first = false;
			String activity = "idle";
			if (bot.isDead())
			{
				activity = "dead";
			}
			else if ((state != null) && (state.siegeSide != 0))
			{
				activity = "siege";
			}
			else if ((state != null) && (state.tradeUntil > 0))
			{
				activity = "trading";
			}
			else if (bot.getParty() != null)
			{
				activity = "party";
			}
			else if (bot.isInCombat())
			{
				activity = "combat";
			}
			else if (bot.isSitting())
			{
				activity = "resting";
			}
			else if (bot.isMoving())
			{
				activity = "moving";
			}
			final String target = (bot.getTarget() != null) ? bot.getTarget().getName() : "";
			sb.append("{\"id\":").append((state != null) ? state.seedId : 0)
				.append(",\"name\":\"").append(bot.getName().replace('"', ' '))
				.append("\",\"level\":").append(bot.getLevel())
				.append(",\"personality\":\"").append((state != null) ? state.personality : "neutral")
				.append("\",\"race\":\"").append((state != null) ? state.race : "")
				.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 static FakePlayerHeadlessManager getInstance()
	{
		return SingletonHolder.INSTANCE;
	}

	private static class SingletonHolder
	{
		protected static final FakePlayerHeadlessManager INSTANCE = new FakePlayerHeadlessManager();
	}
}
