Config.java:
--- a/aCis_gameserver/java/net/sf/l2j/Config.java
+++ b/aCis_gameserver/java/net/sf/l2j/Config.java
@@ -955,6 +955,31 @@
public static int ENCHANT_BOOST_ACTIVATION_SKILL_LEVEL;
public static int ENCHANT_BOOST_ACTIVATION_HIT_TIME;
+ /**
+ * By Atrein:
+ * Enables the server-side repeat-enchant confirmation workflow.
+ */
+ public static boolean ENCHANT_REPEAT_ENABLED;
+ /**
+ * By Atrein:
+ * Confirmation lifetime in milliseconds.
+ */
+ public static int ENCHANT_REPEAT_TIMEOUT;
+ /**
+ * By Atrein:
+ * Allows repeat prompts for normal enchant scrolls.
+ */
+ public static boolean ENCHANT_REPEAT_NORMAL_SCROLLS;
+ /**
+ * By Atrein:
+ * Allows repeat prompts for Blessed enchant scrolls.
+ */
+ public static boolean ENCHANT_REPEAT_BLESSED_SCROLLS;
+ /**
+ * By Atrein:
+ * Allows repeat prompts for Crystal enchant scrolls.
+ */
+ public static boolean ENCHANT_REPEAT_CRYSTAL_SCROLLS;
/**
* By Atrein:
@@ -2591,6 +2616,11 @@
ENCHANT_BOOST_ACTIVATION_SKILL_LEVEL = Math.max(1, rusacis.getProperty("EnchantBoostActivationSkillLevel", 1));
ENCHANT_BOOST_ACTIVATION_HIT_TIME = Math.max(0, rusacis.getProperty("EnchantBoostActivationHitTime", 1000));
+ ENCHANT_REPEAT_ENABLED = rusacis.getProperty("EnchantRepeatEnabled", true);
+ ENCHANT_REPEAT_TIMEOUT = Math.max(1000, rusacis.getProperty("EnchantRepeatTimeout", 15000));
+ ENCHANT_REPEAT_NORMAL_SCROLLS = rusacis.getProperty("EnchantRepeatNormalScrolls", true);
+ ENCHANT_REPEAT_BLESSED_SCROLLS = rusacis.getProperty("EnchantRepeatBlessedScrolls", true);
+ ENCHANT_REPEAT_CRYSTAL_SCROLLS = rusacis.getProperty("EnchantRepeatCrystalScrolls", false);
AUTO_REPAIR_CORRUPTED_RUNTIME_TABLES = rusacis.getProperty("AutoRepairCorruptedRuntimeTables", true);
ANTIFEED_ENABLE = rusacis.getProperty("AntiFeedEnable", false);
rus_acis.properties — config RU:
--- a/aCis_gameserver/config/rus_acis.properties
+++ b/aCis_gameserver/config/rus_acis.properties
@@ -165,6 +165,24 @@
EnchantBoostActivationSkillLevel = 1
EnchantBoostActivationHitTime = 1000
+# -----------------------------------------------------------------
+# Repeat Enchant
+# -----------------------------------------------------------------
+
+# Показывать стандартное окно Да/Нет после подходящей попытки заточки.
+EnchantRepeatEnabled = True
+
+# Время действия предложения повторить заточку, миллисекунды.
+EnchantRepeatTimeout = 15000
+
+# Разрешить повтор для обычных свитков.
+EnchantRepeatNormalScrolls = True
+
+# Разрешить повтор для Blessed-свитков.
+EnchantRepeatBlessedScrolls = True
+
+# Разрешить повтор для Crystal-свитков.
+EnchantRepeatCrystalScrolls = False
# Автоматически пересоздавать разрешённые временные таблицы, если MariaDB
# возвращает ошибку 1033 (повреждённые или несовместимые метаданные таблицы).
rus_acis.properties — config EN:
--- a/aCis_gameserver/config/en/rus_acis.properties
+++ b/aCis_gameserver/config/en/rus_acis.properties
@@ -165,6 +165,24 @@
EnchantBoostActivationSkillLevel = 1
EnchantBoostActivationHitTime = 1000
+# -----------------------------------------------------------------
+# Repeat Enchant
+# -----------------------------------------------------------------
+
+# Show a standard Yes/No dialog after an eligible enchant attempt.
+EnchantRepeatEnabled = True
+
+# Repeat confirmation lifetime in milliseconds.
+EnchantRepeatTimeout = 15000
+
+# Allow repeat prompts for normal enchant scrolls.
+EnchantRepeatNormalScrolls = True
+
+# Allow repeat prompts for Blessed enchant scrolls.
+EnchantRepeatBlessedScrolls = True
+
+# Allow repeat prompts for Crystal enchant scrolls.
+EnchantRepeatCrystalScrolls = False
# Automatically rebuild allow-listed disposable runtime tables when MariaDB
# reports error 1033 (corrupted or incompatible table metadata).
rus_acis.properties — runtime RU:
--- a/aCis_gameserver/bin/rus_acis.properties
+++ b/aCis_gameserver/bin/rus_acis.properties
@@ -165,6 +165,24 @@
EnchantBoostActivationSkillLevel = 1
EnchantBoostActivationHitTime = 1000
+# -----------------------------------------------------------------
+# Repeat Enchant
+# -----------------------------------------------------------------
+
+# Показывать стандартное окно Да/Нет после подходящей попытки заточки.
+EnchantRepeatEnabled = True
+
+# Время действия предложения повторить заточку, миллисекунды.
+EnchantRepeatTimeout = 15000
+
+# Разрешить повтор для обычных свитков.
+EnchantRepeatNormalScrolls = True
+
+# Разрешить повтор для Blessed-свитков.
+EnchantRepeatBlessedScrolls = True
+
+# Разрешить повтор для Crystal-свитков.
+EnchantRepeatCrystalScrolls = False
# Автоматически пересоздавать разрешённые временные таблицы, если MariaDB
# возвращает ошибку 1033 (повреждённые или несовместимые метаданные таблицы).
rus_acis.properties — runtime EN:
--- a/aCis_gameserver/bin/en/rus_acis.properties
+++ b/aCis_gameserver/bin/en/rus_acis.properties
@@ -165,6 +165,24 @@
EnchantBoostActivationSkillLevel = 1
EnchantBoostActivationHitTime = 1000
+# -----------------------------------------------------------------
+# Repeat Enchant
+# -----------------------------------------------------------------
+
+# Show a standard Yes/No dialog after an eligible enchant attempt.
+EnchantRepeatEnabled = True
+
+# Repeat confirmation lifetime in milliseconds.
+EnchantRepeatTimeout = 15000
+
+# Allow repeat prompts for normal enchant scrolls.
+EnchantRepeatNormalScrolls = True
+
+# Allow repeat prompts for Blessed enchant scrolls.
+EnchantRepeatBlessedScrolls = True
+
+# Allow repeat prompts for Crystal enchant scrolls.
+EnchantRepeatCrystalScrolls = False
# Automatically rebuild allow-listed disposable runtime tables when MariaDB
# reports error 1033 (corrupted or incompatible table metadata).
EnchantScrolls.java:
--- a/aCis_gameserver/java/net/sf/l2j/gameserver/handler/itemhandlers/EnchantScrolls.java
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/handler/itemhandlers/EnchantScrolls.java
@@ -2,25 +2,49 @@
import net.sf.l2j.gameserver.handler.IItemHandler;
import net.sf.l2j.gameserver.model.actor.Playable;
import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.enchant.EnchantRepeatService;
+import net.sf.l2j.gameserver.model.enchant.EnchantSessionService;
import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
import net.sf.l2j.gameserver.network.SystemMessageId;
-import net.sf.l2j.gameserver.network.serverpackets.ChooseInventoryItem;
+import net.sf.l2j.gameserver.network.serverpackets.PlaySound;
+/**
+ * Opens the stock item-enchant interface for enchant scrolls.
+ * <p>
+ * By Atrein:
+ * Manual scroll use invalidates only an old custom repeat confirmation and
+ * delegates active-scroll/window state to {@link EnchantSessionService}.
+ */
public class EnchantScrolls implements IItemHandler
{
+ private static final String ENCHANT_OPEN_SOUND = "InterfaceSound.charstat_open_01";
+
+ /**
+ * Creates the enchant-scroll item handler.
+ * <p>
+ * By Atrein: window state is delegated to the dedicated session service.
+ */
+ public EnchantScrolls()
+ {
+ }
+
@Override
public void useItem(Playable playable, ItemInstance item, boolean forceUse)
{
- if (!(playable instanceof Player))
+ if (!(playable instanceof Player player))
return;
-
- final Player player = (Player) playable;
-
- if (player.getActiveEnchantItem() == null)
+
+ EnchantRepeatService.getInstance().clear(player);
+
+ final boolean openNewEnchantWindow = player.getActiveEnchantItem() == null;
+ if (openNewEnchantWindow)
player.sendPacket(SystemMessageId.SELECT_ITEM_TO_ENCHANT);
-
- player.setActiveEnchantItem(item);
- player.sendPacket(new ChooseInventoryItem(item.getItemId()));
+
+ if (!EnchantSessionService.getInstance().open(player, item))
+ return;
+
+ if (openNewEnchantWindow)
+ player.sendPacket(new PlaySound(ENCHANT_OPEN_SOUND));
}
}
RequestEnchantItem.java — diff:
# Architectural replacement.
# The previous packet contained the full enchant transaction inline:
# validation, scroll consumption, random roll, success/failure, skill refresh,
# crystal return, inventory packets and active-scroll cleanup.
# Those responsibilities were removed from the packet and moved to EnchantService.
--- a/aCis_gameserver/java/net/sf/l2j/gameserver/network/clientpackets/RequestEnchantItem.java
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/network/clientpackets/RequestEnchantItem.java
@@ imports and superclass @@
-import net.sf.l2j.commons.random.Rnd;
-import net.sf.l2j.Config;
-import net.sf.l2j.gameserver.data.SkillTable;
-import net.sf.l2j.gameserver.data.xml.ArmorSetData;
-import net.sf.l2j.gameserver.enums.Paperdoll;
-import net.sf.l2j.gameserver.enums.StatusType;
-import net.sf.l2j.gameserver.model.World;
import net.sf.l2j.gameserver.model.actor.Player;
-import net.sf.l2j.gameserver.model.item.ArmorSet;
+import net.sf.l2j.gameserver.model.enchant.EnchantRepeatService;
+import net.sf.l2j.gameserver.model.enchant.EnchantService;
+import net.sf.l2j.gameserver.model.enchant.EnchantSessionService;
import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
-import net.sf.l2j.gameserver.model.item.kind.Armor;
-import net.sf.l2j.gameserver.model.item.kind.Item;
-import net.sf.l2j.gameserver.model.item.kind.Weapon;
import net.sf.l2j.gameserver.network.SystemMessageId;
-import net.sf.l2j.gameserver.network.serverpackets.EnchantResult;
-import net.sf.l2j.gameserver.network.serverpackets.InventoryUpdate;
-import net.sf.l2j.gameserver.network.serverpackets.ItemList;
-import net.sf.l2j.gameserver.network.serverpackets.StatusUpdate;
-import net.sf.l2j.gameserver.network.serverpackets.SystemMessage;
-import net.sf.l2j.gameserver.skills.L2Skill;
-public final class RequestEnchantItem extends AbstractEnchantPacket
+public final class RequestEnchantItem extends L2GameClientPacket
@@ runImpl responsibility @@
- // Legacy packet executed the entire enchant transaction here.
- // It consumed the scroll, rolled the chance, changed/destroyed the item,
- // updated skills and finally cleared player.setActiveEnchantItem(null).
+ final ItemInstance scroll = player.getActiveEnchantItem();
+ if (scroll == null)
+ {
+ EnchantRepeatService.getInstance().clear(player);
+ player.sendPacket(SystemMessageId.ENCHANT_SCROLL_CANCELLED);
+ EnchantSessionService.getInstance().close(player, true);
+ return;
+ }
+
+ EnchantService.getInstance().enchant(player, _objectId, scroll.getObjectId(), EnchantService.EnchantSource.MANUAL);
RequestEnchantItem.java — итоговый файл:
--- /dev/null
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/network/clientpackets/RequestEnchantItem.java
+package net.sf.l2j.gameserver.network.clientpackets;
+
+import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.enchant.EnchantRepeatService;
+import net.sf.l2j.gameserver.model.enchant.EnchantService;
+import net.sf.l2j.gameserver.model.enchant.EnchantSessionService;
+import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
+import net.sf.l2j.gameserver.network.SystemMessageId;
+
+/**
+ * Handles a target selected through the stock Item Enchant window.
+ * <p>
+ * By Atrein:
+ * Treats stock target selection, including the client's middle Repeat action,
+ * as an authoritative manual request. A pending custom confirmation is cleared,
+ * while the active scroll rebound by {@link EnchantSessionService} remains usable.
+ */
+public final class RequestEnchantItem extends L2GameClientPacket
+{
+ private int _objectId;
+
+ /**
+ * Creates an empty packet populated by {@link #readImpl()}.
+ * <p>
+ * By Atrein: the packet consumes the rebound active enchant scroll.
+ */
+ public RequestEnchantItem()
+ {
+ }
+
+ @Override
+ protected void readImpl()
+ {
+ _objectId = readD();
+ }
+
+ @Override
+ protected void runImpl()
+ {
+ final Player player = getClient().getPlayer();
+ if (player == null || _objectId <= 0)
+ return;
+
+ final ItemInstance scroll = player.getActiveEnchantItem();
+ if (scroll == null)
+ {
+ EnchantRepeatService.getInstance().clear(player);
+ player.sendPacket(SystemMessageId.ENCHANT_SCROLL_CANCELLED);
+ EnchantSessionService.getInstance().close(player, true);
+ return;
+ }
+
+ EnchantService.getInstance().enchant(player, _objectId, scroll.getObjectId(), EnchantService.EnchantSource.MANUAL);
+ }
+}
DlgAnswer.java:
--- a/aCis_gameserver/java/net/sf/l2j/gameserver/network/clientpackets/DlgAnswer.java
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/network/clientpackets/DlgAnswer.java
@@ -1,7 +1,8 @@
package net.sf.l2j.gameserver.network.clientpackets;
import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.enchant.EnchantRepeatService;
import net.sf.l2j.gameserver.model.entity.events.capturetheflag.CTFEvent;
import net.sf.l2j.gameserver.model.entity.events.capturetheflag.CTFManager;
import net.sf.l2j.gameserver.model.entity.events.deathmatch.DMEvent;
import net.sf.l2j.gameserver.model.entity.events.deathmatch.DMManager;
@@ -31,8 +32,11 @@
final Player player = getClient().getPlayer();
if (player == null)
return;
+ if (_messageId == SystemMessageId.S1.getId() && EnchantRepeatService.getInstance().handleAnswer(player, _answer, _requesterId))
+ return;
+
if (_requesterId == CTFManager.JOIN_CTF_REQ_ID && _answer == 1)
CTFEvent.getInstance().onBypass("ctf_event_participation", player);
else if (_requesterId == DMManager.JOIN_DM_REQ_ID && _answer == 1)
DMEvent.getInstance().onBypass("dm_event_participation", player);
Player.java:
--- a/aCis_gameserver/java/net/sf/l2j/gameserver/model/actor/Player.java
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/model/actor/Player.java
@@ -142,10 +142,13 @@
import net.sf.l2j.gameserver.model.actor.status.PlayerStatus;
import net.sf.l2j.gameserver.model.actor.template.PetTemplate;
import net.sf.l2j.gameserver.model.actor.template.PlayerTemplate;
import net.sf.l2j.gameserver.model.craft.ManufactureList;
import net.sf.l2j.gameserver.model.entity.autofarm.AutoFarmManager;
+import net.sf.l2j.gameserver.model.enchant.EnchantRepeatService;
+import net.sf.l2j.gameserver.model.enchant.EnchantService;
+import net.sf.l2j.gameserver.model.enchant.EnchantSessionService;
import net.sf.l2j.gameserver.model.entity.events.capturetheflag.CTFEvent;
import net.sf.l2j.gameserver.model.entity.events.deathmatch.DMEvent;
import net.sf.l2j.gameserver.model.entity.events.lastman.LMEvent;
import net.sf.l2j.gameserver.model.entity.events.teamvsteam.TvTEvent;
import net.sf.l2j.gameserver.model.group.CommandChannel;
@@ -3543,14 +3546,17 @@
onTradeCancel(this);
}
public void cancelActiveEnchant()
{
+ // By Atrein: explicitly closing the stock enchant window always invalidates
+ // a pending custom repeat confirmation and its timeout task.
+ EnchantRepeatService.getInstance().clear(this);
if (_activeEnchantItem == null)
return;
- setActiveEnchantItem(null);
+ EnchantSessionService.getInstance().close(this, false);
sendPacket(EnchantResult.CANCELLED);
sendPacket(SystemMessageId.ENCHANT_SCROLL_CANCELLED);
}
/**
@@ -7044,10 +7050,15 @@
CTFEvent.getInstance().onLogout(this);
DMEvent.getInstance().onLogout(this);
LMEvent.getInstance().onLogout(this);
TvTEvent.getInstance().onLogout(this);
+ // By Atrein: clear short-lived enchant state before inventory teardown.
+ EnchantRepeatService.getInstance().clear(this);
+ EnchantSessionService.getInstance().close(this, false);
+ EnchantService.getInstance().clear(this);
+
// Update inventory and remove them from the world
getInventory().deleteMe();
// Update warehouse and remove them from the world
clearWarehouse();
EnchantRepeatService.java — новый файл:
--- /dev/null
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/model/enchant/EnchantRepeatService.java
@@ -0,0 +1,301 @@
+package net.sf.l2j.gameserver.model.enchant;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.sf.l2j.commons.pool.ThreadPool;
+
+import net.sf.l2j.Config;
+import net.sf.l2j.gameserver.data.xml.EnchantData;
+import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
+import net.sf.l2j.gameserver.model.records.custom.EnchantScroll;
+import net.sf.l2j.gameserver.network.SystemMessageId;
+import net.sf.l2j.gameserver.network.serverpackets.ConfirmDlg;
+
+/**
+ * Manages short-lived custom repeat-enchant confirmation requests.
+ * <p>
+ * By Atrein:
+ * Keeps the stock Item Enchant session alive while the dialog is visible, uses
+ * one-time requester tokens and restores the stock window on cancel, close or
+ * server-side timeout without requiring a client modification.
+ */
+public final class EnchantRepeatService
+{
+ private static final int REQUEST_PREFIX = 0x6E000000;
+ private static final int REQUEST_PREFIX_MASK = 0xFF000000;
+ private static final int REQUEST_SEQUENCE_MASK = 0x00FFFFFF;
+
+ private final ConcurrentHashMap<Integer, RepeatContext> _contexts = new ConcurrentHashMap<>();
+ private final AtomicInteger _requestSequence = new AtomicInteger();
+
+ private EnchantRepeatService()
+ {
+ }
+
+ /**
+ * Offers a repeat action when the previous target survived and the stock
+ * enchant window is already rebound to another compatible scroll.
+ *
+ * @param player The player receiving the confirmation.
+ * @param item The previous enchant target.
+ * @param scrollItemId The scroll item ID used by the completed attempt.
+ * @param enchant The authoritative scroll definition.
+ */
+ public void offer(Player player, ItemInstance item, int scrollItemId, EnchantScroll enchant)
+ {
+ clear(player);
+ if (!Config.ENCHANT_REPEAT_ENABLED || player == null || item == null || enchant == null || !isScrollTypeAllowed(enchant))
+ return;
+ if (!EnchantSessionService.getInstance().isValidTarget(player, item, enchant))
+ return;
+
+ final ItemInstance activeScroll = restoreOrGetActiveScroll(player, scrollItemId);
+ if (activeScroll == null)
+ return;
+
+ final int requestId = nextRequestId();
+ final long expiresAt = System.currentTimeMillis() + Config.ENCHANT_REPEAT_TIMEOUT;
+ final RepeatContext context = new RepeatContext(requestId, item.getObjectId(), scrollItemId, expiresAt);
+ _contexts.put(player.getObjectId(), context);
+
+ final ScheduledFuture<?> timeoutTask = ThreadPool.schedule(() -> expire(player, context), Config.ENCHANT_REPEAT_TIMEOUT);
+ if (timeoutTask == null)
+ {
+ _contexts.remove(player.getObjectId(), context);
+ return;
+ }
+ context.setTimeoutTask(timeoutTask);
+ if (_contexts.get(player.getObjectId()) != context)
+ {
+ context.cancelTimeout();
+ return;
+ }
+
+ final String prompt = player.getSysString(10_374, item.getName(), activeScroll.getName());
+ player.sendPacket(new ConfirmDlg(SystemMessageId.S1).addString(prompt).addTime(Config.ENCHANT_REPEAT_TIMEOUT).addRequesterId(requestId));
+ }
+
+ /**
+ * Handles a standard dialog answer when the requester ID belongs to this service.
+ *
+ * @param player The answering player.
+ * @param answer The client answer; only {@code 1} means Yes.
+ * @param requesterId The requester ID echoed by the client.
+ * @return {@code true} when the requester ID belonged to repeat enchant.
+ */
+ public boolean handleAnswer(Player player, int answer, int requesterId)
+ {
+ if (!isRequesterId(requesterId))
+ return false;
+ if (player == null)
+ return true;
+
+ final RepeatContext context = _contexts.get(player.getObjectId());
+ if (context == null)
+ return true;
+
+ final boolean matchingContext = context.requestId() == requesterId;
+ if (!matchingContext)
+ return true;
+
+ final boolean expired = System.currentTimeMillis() > context.expiresAt();
+ final EnchantSessionPolicy.DialogAction action = EnchantSessionPolicy.resolveDialogAction(matchingContext, answer, expired);
+ if (action == EnchantSessionPolicy.DialogAction.IGNORE)
+ return true;
+ if (!_contexts.remove(player.getObjectId(), context))
+ return true;
+
+ context.cancelTimeout();
+ if (action == EnchantSessionPolicy.DialogAction.RESTORE_WINDOW)
+ {
+ EnchantSessionService.getInstance().restore(player, context.scrollItemId());
+ return true;
+ }
+
+ repeat(player, context);
+ return true;
+ }
+
+ /**
+ * By Atrein:
+ * Returns the active scroll bound to the stock enchant window, restoring the
+ * session first when necessary. A non-null result is always revalidated against
+ * the matching usable inventory scroll.
+ *
+ * @param player The player whose stock enchant session is checked.
+ * @param scrollItemId The scroll item ID that must remain active.
+ * @return The validated active scroll, or {@code null} when the session cannot be restored.
+ */
+ private static ItemInstance restoreOrGetActiveScroll(Player player, int scrollItemId)
+ {
+ ItemInstance scroll = EnchantSessionService.getInstance().findUsableScroll(player, scrollItemId);
+ ItemInstance activeScroll = player.getActiveEnchantItem();
+ if (scroll != null && activeScroll != null && activeScroll.getObjectId() == scroll.getObjectId())
+ return activeScroll;
+
+ if (!EnchantSessionService.getInstance().restore(player, scrollItemId))
+ return null;
+
+ scroll = EnchantSessionService.getInstance().findUsableScroll(player, scrollItemId);
+ activeScroll = player.getActiveEnchantItem();
+ if (scroll == null || activeScroll == null || activeScroll.getObjectId() != scroll.getObjectId())
+ return null;
+
+ return activeScroll;
+ }
+
+ private static void repeat(Player player, RepeatContext context)
+ {
+ final ItemInstance item = player.getInventory().getItemByObjectId(context.itemObjectId());
+ final ItemInstance scroll = EnchantSessionService.getInstance().findUsableScroll(player, context.scrollItemId());
+ if (item == null || scroll == null)
+ {
+ player.sendMessage(player.getSysString(10_376));
+ EnchantSessionService.getInstance().restore(player, context.scrollItemId());
+ return;
+ }
+
+ final EnchantScroll enchant = EnchantData.getInstance().getEnchantScroll(scroll);
+ if (enchant == null || !isScrollTypeAllowed(enchant) || !EnchantSessionService.getInstance().isValidTarget(player, item, enchant))
+ {
+ player.sendMessage(player.getSysString(10_377));
+ EnchantSessionService.getInstance().restore(player, context.scrollItemId());
+ return;
+ }
+ if (!EnchantSessionService.getInstance().prepareAttempt(player, scroll))
+ {
+ player.sendMessage(player.getSysString(10_376));
+ EnchantSessionService.getInstance().restore(player, context.scrollItemId());
+ return;
+ }
+
+ EnchantService.getInstance().enchant(player, item.getObjectId(), scroll.getObjectId(), EnchantService.EnchantSource.REPEAT);
+ }
+
+ private void expire(Player player, RepeatContext context)
+ {
+ if (player == null || !_contexts.remove(player.getObjectId(), context))
+ return;
+
+ context.cancelTimeout();
+ EnchantSessionService.getInstance().restore(player, context.scrollItemId());
+ }
+
+ /**
+ * Clears the pending custom repeat request without closing the stock window.
+ *
+ * @param player The player whose request is removed.
+ */
+ public void clear(Player player)
+ {
+ if (player == null)
+ return;
+
+ final RepeatContext context = _contexts.remove(player.getObjectId());
+ if (context != null)
+ context.cancelTimeout();
+ }
+
+ /**
+ * Tests whether a dialog requester ID belongs to repeat enchant.
+ *
+ * @param requesterId The requester ID received from {@code DlgAnswer}.
+ * @return {@code true} when the requester ID belongs to repeat enchant.
+ */
+ public static boolean isRequesterId(int requesterId)
+ {
+ return (requesterId & REQUEST_PREFIX_MASK) == REQUEST_PREFIX;
+ }
+
+ /**
+ * Tests whether the configured repeat policy allows the scroll category.
+ *
+ * @param enchant The scroll definition.
+ * @return {@code true} when custom repeat is enabled for the category.
+ */
+ static boolean isScrollTypeAllowed(EnchantScroll enchant)
+ {
+ if (enchant == null)
+ return false;
+ if (!enchant.cristalize())
+ return Config.ENCHANT_REPEAT_BLESSED_SCROLLS;
+ if (enchant.returnVal() == -1)
+ return Config.ENCHANT_REPEAT_CRYSTAL_SCROLLS;
+ return Config.ENCHANT_REPEAT_NORMAL_SCROLLS;
+ }
+
+ private int nextRequestId()
+ {
+ int sequence = _requestSequence.updateAndGet(value -> (value + 1) & REQUEST_SEQUENCE_MASK);
+ if (sequence == 0)
+ sequence = _requestSequence.incrementAndGet();
+ return REQUEST_PREFIX | sequence;
+ }
+
+ private static final class RepeatContext
+ {
+ private final int _requestId;
+ private final int _itemObjectId;
+ private final int _scrollItemId;
+ private final long _expiresAt;
+ private volatile ScheduledFuture<?> _timeoutTask;
+
+ private RepeatContext(int requestId, int itemObjectId, int scrollItemId, long expiresAt)
+ {
+ _requestId = requestId;
+ _itemObjectId = itemObjectId;
+ _scrollItemId = scrollItemId;
+ _expiresAt = expiresAt;
+ }
+
+ private int requestId()
+ {
+ return _requestId;
+ }
+
+ private int itemObjectId()
+ {
+ return _itemObjectId;
+ }
+
+ private int scrollItemId()
+ {
+ return _scrollItemId;
+ }
+
+ private long expiresAt()
+ {
+ return _expiresAt;
+ }
+
+ private void setTimeoutTask(ScheduledFuture<?> timeoutTask)
+ {
+ _timeoutTask = timeoutTask;
+ }
+
+ private void cancelTimeout()
+ {
+ final ScheduledFuture<?> timeoutTask = _timeoutTask;
+ if (timeoutTask != null)
+ timeoutTask.cancel(false);
+ }
+ }
+
+ /**
+ * Returns the shared repeat-enchant service instance.
+ *
+ * @return The singleton service instance.
+ */
+ public static EnchantRepeatService getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ private static final EnchantRepeatService INSTANCE = new EnchantRepeatService();
+ }
+}
EnchantSessionService.java — новый файл:
--- /dev/null
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/model/enchant/EnchantSessionService.java
@@ -0,0 +1,227 @@
+package net.sf.l2j.gameserver.model.enchant;
+
+import net.sf.l2j.gameserver.data.xml.EnchantData;
+import net.sf.l2j.gameserver.enums.items.ItemLocation;
+import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
+import net.sf.l2j.gameserver.model.records.custom.EnchantScroll;
+import net.sf.l2j.gameserver.network.serverpackets.ChooseInventoryItem;
+import net.sf.l2j.gameserver.network.serverpackets.EnchantResult;
+import net.sf.l2j.gameserver.network.serverpackets.ItemList;
+
+/**
+ * Owns the server-side state of the stock Item Enchant window.
+ * <p>
+ * By Atrein:
+ * Rebinds a real remaining scroll after every completed attempt, rebuilds the
+ * client target list and keeps the normal Repeat button usable independently
+ * from the optional custom confirmation dialog.
+ */
+public final class EnchantSessionService
+{
+ private EnchantSessionService()
+ {
+ }
+
+ /**
+ * Opens or refreshes the stock Item Enchant window with a validated scroll.
+ *
+ * @param player The player opening the window.
+ * @param scroll The scroll selected by the player.
+ * @return {@code true} when the scroll was bound and the client list was sent.
+ */
+ public boolean open(Player player, ItemInstance scroll)
+ {
+ if (!isConnected(player) || !isUsableScroll(player, scroll))
+ return false;
+
+ bind(player, scroll);
+ return true;
+ }
+
+ /**
+ * Reconciles the stock enchant window after an authoritative attempt.
+ *
+ * @param player The enchanting player.
+ * @param scrollItemId The item ID of the consumed scroll type.
+ * @param previousTarget The item targeted by the completed attempt.
+ * @param enchant The server-side scroll definition.
+ * @param targetSurvived Whether the previous target still exists.
+ * @param repeatAllowed Whether the custom repeat dialog is enabled for this scroll type.
+ * @return The resulting session state.
+ */
+ public RefreshResult afterAttempt(Player player, int scrollItemId, ItemInstance previousTarget, EnchantScroll enchant, boolean targetSurvived, boolean repeatAllowed)
+ {
+ if (!isConnected(player) || enchant == null)
+ {
+ close(player, false);
+ return RefreshResult.CLOSED;
+ }
+
+ player.sendPacket(new ItemList(player, false));
+
+ final ItemInstance nextScroll = findUsableScroll(player, scrollItemId);
+ final boolean hasNextScroll = nextScroll != null;
+ final boolean hasCompatibleTarget = hasNextScroll && hasCompatibleTarget(player, enchant);
+ final boolean sameTargetValid = targetSurvived && isValidTarget(player, previousTarget, enchant);
+ final EnchantSessionPolicy.WindowDecision decision = EnchantSessionPolicy.afterAttempt(hasNextScroll, hasCompatibleTarget, targetSurvived, sameTargetValid, repeatAllowed);
+
+ if (!decision.keepWindowOpen())
+ {
+ close(player, false);
+ return RefreshResult.CLOSED;
+ }
+
+ bind(player, nextScroll);
+ return new RefreshResult(true, decision.offerRepeat(), nextScroll);
+ }
+
+ /**
+ * Restores the stock Item Enchant window after the custom dialog was cancelled,
+ * closed or expired.
+ *
+ * @param player The player whose standard window is restored.
+ * @param scrollItemId The scroll item ID used by the session.
+ * @return {@code true} when a usable scroll and target still exist.
+ */
+ public boolean restore(Player player, int scrollItemId)
+ {
+ if (!isConnected(player))
+ {
+ close(player, false);
+ return false;
+ }
+
+ final ItemInstance scroll = findUsableScroll(player, scrollItemId);
+ final EnchantScroll enchant = scroll == null ? null : EnchantData.getInstance().getEnchantScroll(scroll);
+ if (scroll == null || enchant == null || !hasCompatibleTarget(player, enchant))
+ {
+ close(player, false);
+ return false;
+ }
+
+ player.sendPacket(new ItemList(player, false));
+ bind(player, scroll);
+ return true;
+ }
+
+ /**
+ * Finds an owned inventory scroll of the requested item ID.
+ *
+ * @param player The scroll owner.
+ * @param scrollItemId The requested scroll item ID.
+ * @return A usable scroll instance, or {@code null}.
+ */
+ public ItemInstance findUsableScroll(Player player, int scrollItemId)
+ {
+ if (player == null || scrollItemId <= 0)
+ return null;
+
+ for (ItemInstance item : player.getInventory().getItemsByItemId(scrollItemId))
+ {
+ if (isUsableScroll(player, item))
+ return item;
+ }
+ return null;
+ }
+
+ /**
+ * Verifies that the previous or manually selected target can be enchanted with
+ * the supplied definition at its current state.
+ *
+ * @param player The item owner.
+ * @param item The candidate target.
+ * @param enchant The scroll definition.
+ * @return {@code true} when the target is currently valid.
+ */
+ public boolean isValidTarget(Player player, ItemInstance item, EnchantScroll enchant)
+ {
+ return player != null && item != null && enchant != null && item.getOwnerId() == player.getObjectId() && EnchantService.isEnchantable(item) && enchant.isValid(item) && enchant.getChance(item) > 0;
+ }
+
+ /**
+ * Binds a validated scroll before an immediate repeat transaction without
+ * reopening the target list.
+ *
+ * @param player The enchanting player.
+ * @param scroll The scroll to bind.
+ * @return {@code true} when the scroll was accepted.
+ */
+ boolean prepareAttempt(Player player, ItemInstance scroll)
+ {
+ if (!isConnected(player) || !isUsableScroll(player, scroll))
+ return false;
+
+ player.setActiveEnchantItem(scroll);
+ return true;
+ }
+
+ /**
+ * Clears only the server-side stock enchant session.
+ *
+ * @param player The player whose session is closed.
+ * @param notifyClient Whether to send the stock cancellation packet.
+ */
+ public void close(Player player, boolean notifyClient)
+ {
+ if (player == null)
+ return;
+
+ player.setActiveEnchantItem(null);
+ if (notifyClient && isConnected(player))
+ player.sendPacket(EnchantResult.CANCELLED);
+ }
+
+ private static boolean isConnected(Player player)
+ {
+ return player != null && player.isOnline() && player.getClient() != null && !player.getClient().isDetached();
+ }
+
+ private static boolean isUsableScroll(Player player, ItemInstance scroll)
+ {
+ return player != null && scroll != null && scroll.getOwnerId() == player.getObjectId() && scroll.getCount() > 0 && scroll.getLocation() == ItemLocation.INVENTORY && EnchantData.getInstance().getEnchantScroll(scroll) != null;
+ }
+
+ private boolean hasCompatibleTarget(Player player, EnchantScroll enchant)
+ {
+ for (ItemInstance item : player.getInventory().getItems())
+ {
+ if (isValidTarget(player, item, enchant))
+ return true;
+ }
+ return false;
+ }
+
+ private static void bind(Player player, ItemInstance scroll)
+ {
+ player.setActiveEnchantItem(scroll);
+ player.sendPacket(new ChooseInventoryItem(scroll.getItemId()));
+ }
+
+ /**
+ * Immutable result of rebuilding the stock Item Enchant session.
+ *
+ * @param windowReady Whether the stock window has a bound scroll and valid target.
+ * @param offerRepeat Whether the custom repeat dialog may be shown.
+ * @param activeScroll The scroll bound to the stock window.
+ */
+ public record RefreshResult(boolean windowReady, boolean offerRepeat, ItemInstance activeScroll)
+ {
+ private static final RefreshResult CLOSED = new RefreshResult(false, false, null);
+ }
+
+ /**
+ * Returns the shared enchant-session service.
+ *
+ * @return The singleton service instance.
+ */
+ public static EnchantSessionService getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ private static final EnchantSessionService INSTANCE = new EnchantSessionService();
+ }
+}
EnchantSessionPolicy.java — новый файл:
--- /dev/null
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/model/enchant/EnchantSessionPolicy.java
@@ -0,0 +1,74 @@
+package net.sf.l2j.gameserver.model.enchant;
+
+/**
+ * Defines deterministic decisions for the Repeat Enchant state machine.
+ * <p>
+ * By Atrein:
+ * Keeps post-attempt and dialog-answer rules independent from packets, inventory
+ * access and timers so the critical state transitions can be regression-tested.
+ */
+public final class EnchantSessionPolicy
+{
+ private EnchantSessionPolicy()
+ {
+ throw new IllegalStateException("Utility class");
+ }
+
+ /**
+ * Resolves the normal enchant-window and custom repeat-dialog state after a
+ * completed enchant attempt.
+ *
+ * @param hasNextScroll Whether another scroll of the same item ID exists.
+ * @param hasCompatibleTarget Whether at least one target can still be enchanted.
+ * @param targetSurvived Whether the previous target still exists.
+ * @param sameTargetValid Whether the previous target can be enchanted again.
+ * @param repeatAllowed Whether the scroll category permits the custom repeat dialog.
+ * @return The immutable state decision.
+ */
+ public static WindowDecision afterAttempt(boolean hasNextScroll, boolean hasCompatibleTarget, boolean targetSurvived, boolean sameTargetValid, boolean repeatAllowed)
+ {
+ final boolean keepWindowOpen = hasNextScroll && hasCompatibleTarget;
+ final boolean offerRepeat = keepWindowOpen && targetSurvived && sameTargetValid && repeatAllowed;
+ return new WindowDecision(keepWindowOpen, offerRepeat);
+ }
+
+ /**
+ * Resolves a custom repeat-dialog answer.
+ *
+ * @param matchingContext Whether the requester ID matches the current context.
+ * @param answer The answer value received from the client.
+ * @param expired Whether the context already expired.
+ * @return The action that the service must perform.
+ */
+ public static DialogAction resolveDialogAction(boolean matchingContext, int answer, boolean expired)
+ {
+ if (!matchingContext)
+ return DialogAction.IGNORE;
+ if (expired || answer != 1)
+ return DialogAction.RESTORE_WINDOW;
+ return DialogAction.REPEAT;
+ }
+
+ /**
+ * Result of a completed-attempt state decision.
+ *
+ * @param keepWindowOpen Whether the stock Item Enchant window must remain ready.
+ * @param offerRepeat Whether the custom repeat confirmation may be shown.
+ */
+ public record WindowDecision(boolean keepWindowOpen, boolean offerRepeat)
+ {
+ }
+
+ /**
+ * Action produced by a custom repeat-dialog answer.
+ */
+ public enum DialogAction
+ {
+ /** Execute a new attempt against the previous target. */
+ REPEAT,
+ /** Keep and refresh the stock Item Enchant window. */
+ RESTORE_WINDOW,
+ /** Ignore a stale or unrelated requester ID. */
+ IGNORE
+ }
+}
EnchantService.java — новый файл:
--- /dev/null
+++ b/aCis_gameserver/java/net/sf/l2j/gameserver/model/enchant/EnchantService.java
@@ -0,0 +1,364 @@
+package net.sf.l2j.gameserver.model.enchant;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+import net.sf.l2j.commons.random.Rnd;
+
+import net.sf.l2j.Config;
+import net.sf.l2j.gameserver.data.SkillTable;
+import net.sf.l2j.gameserver.data.xml.ArmorSetData;
+import net.sf.l2j.gameserver.data.xml.EnchantData;
+import net.sf.l2j.gameserver.enums.Paperdoll;
+import net.sf.l2j.gameserver.enums.actors.MissionType;
+import net.sf.l2j.gameserver.enums.items.ItemLocation;
+import net.sf.l2j.gameserver.enums.items.WeaponType;
+import net.sf.l2j.gameserver.model.World;
+import net.sf.l2j.gameserver.model.actor.Player;
+import net.sf.l2j.gameserver.model.actor.container.player.MissionList;
+import net.sf.l2j.gameserver.model.item.ArmorSet;
+import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
+import net.sf.l2j.gameserver.model.item.kind.Armor;
+import net.sf.l2j.gameserver.model.item.kind.Item;
+import net.sf.l2j.gameserver.model.item.kind.Weapon;
+import net.sf.l2j.gameserver.model.records.custom.EnchantScroll;
+import net.sf.l2j.gameserver.network.SystemMessageId;
+import net.sf.l2j.gameserver.network.serverpackets.EnchantResult;
+import net.sf.l2j.gameserver.network.serverpackets.SkillList;
+import net.sf.l2j.gameserver.network.serverpackets.SystemMessage;
+import net.sf.l2j.gameserver.skills.L2Skill;
+
+/**
+ * Executes the authoritative item enchant transaction.
+ * <p>
+ * By Atrein:
+ * Serializes attempts per player, revalidates all mutable resources, separates
+ * transaction outcome from client-window lifecycle and delegates the stock
+ * Item Enchant session to {@link EnchantSessionService}.
+ */
+public final class EnchantService
+{
+ private final ConcurrentHashMap<Integer, ReentrantLock> _playerLocks = new ConcurrentHashMap<>();
+
+ private EnchantService()
+ {
+ }
+
+ /**
+ * Performs one enchant attempt using explicit inventory object IDs.
+ *
+ * @param player The player performing the enchant.
+ * @param itemObjectId The target item object ID.
+ * @param scrollObjectId The enchant scroll object ID.
+ * @param source The source of the request.
+ */
+ public void enchant(Player player, int itemObjectId, int scrollObjectId, EnchantSource source)
+ {
+ if (player == null || itemObjectId <= 0 || scrollObjectId <= 0 || source == null)
+ return;
+
+ if (source == EnchantSource.MANUAL)
+ EnchantRepeatService.getInstance().clear(player);
+
+ final ReentrantLock lock = _playerLocks.computeIfAbsent(player.getObjectId(), id -> new ReentrantLock());
+ if (!lock.tryLock())
+ {
+ player.sendMessage(player.getSysString(10_378));
+ EnchantSessionService.getInstance().close(player, true);
+ return;
+ }
+
+ AttemptOutcome outcome = null;
+ try
+ {
+ outcome = execute(player, itemObjectId, scrollObjectId);
+ }
+ finally
+ {
+ lock.unlock();
+ }
+
+ if (outcome == null)
+ return;
+
+ final boolean repeatAllowed = Config.ENCHANT_REPEAT_ENABLED && EnchantRepeatService.isScrollTypeAllowed(outcome.enchant());
+ final EnchantSessionService.RefreshResult refresh = EnchantSessionService.getInstance().afterAttempt(player, outcome.scrollItemId(), outcome.item(), outcome.enchant(), outcome.itemSurvived(), repeatAllowed);
+ if (refresh.offerRepeat())
+ EnchantRepeatService.getInstance().offer(player, outcome.item(), outcome.scrollItemId(), outcome.enchant());
+ }
+
+ private static AttemptOutcome execute(Player player, int itemObjectId, int scrollObjectId)
+ {
+ if (!isConnected(player))
+ {
+ EnchantSessionService.getInstance().close(player, false);
+ return null;
+ }
+ if (player.isProcessingTransaction() || player.isOperating())
+ {
+ player.sendPacket(SystemMessageId.CANNOT_ENCHANT_WHILE_STORE);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+ if (player.getActiveTradeList() != null)
+ {
+ player.cancelActiveTrade();
+ player.sendPacket(SystemMessageId.TRADE_ATTEMPT_FAILED);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+
+ final ItemInstance item = player.getInventory().getItemByObjectId(itemObjectId);
+ final ItemInstance scroll = player.getInventory().getItemByObjectId(scrollObjectId);
+ if (item == null || scroll == null)
+ {
+ player.sendPacket(SystemMessageId.ENCHANT_SCROLL_CANCELLED);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+
+ final EnchantScroll enchant = EnchantData.getInstance().getEnchantScroll(scroll);
+ if (!isValidAttempt(player, item, scroll, enchant))
+ {
+ player.sendPacket(SystemMessageId.INAPPROPRIATE_ENCHANT_CONDITION);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+
+ final int scrollItemId = scroll.getItemId();
+ final MissionList mission = player.getMissions();
+ synchronized (item)
+ {
+ final double baseChance = enchant.getChance(item);
+ if (!isValidAttempt(player, item, scroll, enchant) || baseChance <= 0)
+ {
+ player.sendPacket(SystemMessageId.INAPPROPRIATE_ENCHANT_CONDITION);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+
+ if (player.getInventory().destroyItem(scroll.getObjectId(), 1) == null)
+ {
+ player.sendPacket(SystemMessageId.NOT_ENOUGH_ITEMS);
+ EnchantSessionService.getInstance().close(player, true);
+ return null;
+ }
+
+ final EnchantBoostService.ApplicationResult boostResult = EnchantBoostService.getInstance().applyToAttempt(player, enchant, baseChance);
+ final double finalChance = boostResult.finalChance();
+ sendBoostResult(player, boostResult, finalChance);
+
+ if (Rnd.get(100) < finalChance)
+ {
+ applySuccess(player, item, enchant, mission);
+ player.broadcastUserInfo();
+ return new AttemptOutcome(item, scrollItemId, enchant, true);
+ }
+
+ final FailureResult failure = applyFailure(player, item, enchant, mission);
+ player.broadcastUserInfo();
+ return failure == FailureResult.ABORTED ? null : new AttemptOutcome(item, scrollItemId, enchant, failure == FailureResult.SURVIVED);
+ }
+ }
+
+ private static boolean isConnected(Player player)
+ {
+ return player != null && player.isOnline() && player.getClient() != null && !player.getClient().isDetached();
+ }
+
+ private static boolean isValidAttempt(Player player, ItemInstance item, ItemInstance scroll, EnchantScroll enchant)
+ {
+ return player != null && item != null && scroll != null && enchant != null && player.getActiveEnchantItem() != null && player.getActiveEnchantItem().getObjectId() == scroll.getObjectId() && item.getOwnerId() == player.getObjectId() && scroll.getOwnerId() == player.getObjectId() && scroll.getLocation() == ItemLocation.INVENTORY && scroll.getCount() > 0 && isEnchantable(item) && enchant.isValid(item);
+ }
+
+ private static void sendBoostResult(Player player, EnchantBoostService.ApplicationResult boostResult, double finalChance)
+ {
+ if (boostResult.status() == EnchantBoostService.ApplicationStatus.APPLIED)
+ player.sendMessage(player.getSysString(10_275, boostResult.configuredBonus(), boostResult.effectiveBonus(), (int) finalChance));
+ else if (boostResult.status() == EnchantBoostService.ApplicationStatus.SCROLL_NOT_ALLOWED)
+ player.sendMessage(player.getSysString(10_276));
+ else if (boostResult.status() == EnchantBoostService.ApplicationStatus.CHANCE_ALREADY_MAXIMUM)
+ player.sendMessage(player.getSysString(10_277, boostResult.configuredBonus()));
+ }
+
+ private static void applySuccess(Player player, ItemInstance item, EnchantScroll enchant, MissionList mission)
+ {
+ player.sendPacket(item.getEnchantLevel() == 0 ? SystemMessage.getSystemMessage(SystemMessageId.S1_SUCCESSFULLY_ENCHANTED).addItemName(item.getItemId()) : SystemMessage.getSystemMessage(SystemMessageId.S1_S2_SUCCESSFULLY_ENCHANTED).addNumber(item.getEnchantLevel()).addItemName(item.getItemId()));
+ item.setEnchantLevel(item.getEnchantLevel() + 1, player);
+ refreshEnchantedEquipmentSkills(player, item);
+ player.sendPacket(EnchantResult.SUCCESS);
+
+ final MissionType type = item.isWeapon() ? MissionType.ENCHANT_WEAPON : MissionType.ENCHANT_OTHER;
+ if (mission.getMission(type).getValue() < item.getEnchantLevel())
+ mission.set(type, item.getEnchantLevel(), false, false);
+ mission.update(MissionType.ENCHANT_SUCCESS);
+
+ if (enchant.announceTheEnchant(item) && enchant.message())
+ World.announceToOnlinePlayers(player.getSysString(10_060, player.getName(), item.getEnchantLevel(), item.getName()));
+ }
+
+ private static FailureResult applyFailure(Player player, ItemInstance item, EnchantScroll enchant, MissionList mission)
+ {
+ removeEquipmentSkillsBeforeFailure(player, item, enchant);
+ if (!enchant.cristalize())
+ {
+ player.sendMessage(player.getSysString(10_379, enchant.returnVal()));
+ if (enchant.returnVal() != -1)
+ item.setEnchantLevel(enchant.returnVal(), player);
+ player.sendPacket(EnchantResult.UNSUCCESS);
+ mission.update(MissionType.ENCHANT_FAILED);
+ return FailureResult.SURVIVED;
+ }
+
+ int crystalId = item.getItem().getCrystalItemId();
+ int count = item.getCrystalCount() - (item.getItem().getCrystalCount() + 1) / 2;
+ if (count < 1)
+ count = 1;
+
+ if (player.getInventory().destroyItem(item) == null)
+ {
+ EnchantSessionService.getInstance().close(player, true);
+ return FailureResult.ABORTED;
+ }
+ if (crystalId != 0)
+ {
+ player.getInventory().addItem(crystalId, count);
+ player.sendPacket(SystemMessage.getSystemMessage(SystemMessageId.EARNED_S2_S1_S).addItemName(crystalId).addItemNumber(count));
+ }
+ if (item.getEnchantLevel() > 0)
+ player.sendPacket(SystemMessage.getSystemMessage(SystemMessageId.ENCHANTMENT_FAILED_S1_S2_EVAPORATED).addNumber(item.getEnchantLevel()).addItemName(item.getItemId()));
+ else
+ player.sendPacket(SystemMessage.getSystemMessage(SystemMessageId.ENCHANTMENT_FAILED_S1_EVAPORATED).addItemName(item.getItemId()));
+ player.sendPacket(crystalId == 0 ? EnchantResult.UNK_RESULT_4 : EnchantResult.UNK_RESULT_1);
+ mission.update(MissionType.ENCHANT_FAILED);
+ return FailureResult.DESTROYED;
+ }
+
+ private static void refreshEnchantedEquipmentSkills(Player player, ItemInstance item)
+ {
+ if (!item.isEquipped())
+ return;
+ final Item template = item.getItem();
+ if (template instanceof Weapon weapon && item.getEnchantLevel() == 4)
+ {
+ if (!player.getWeaponGradePenalty())
+ {
+ final L2Skill skill = weapon.getEnchant4Skill();
+ if (skill != null)
+ {
+ player.addSkill(skill, false);
+ player.sendPacket(new SkillList(player));
+ }
+ }
+ }
+ else if (template instanceof Armor && item.getEnchantLevel() == 6)
+ {
+ final int chestId = player.getInventory().getItemIdFrom(Paperdoll.CHEST);
+ if (chestId == 0)
+ return;
+ final ArmorSet armorSet = ArmorSetData.getInstance().getSet(chestId);
+ if (armorSet == null || !armorSet.isEnchanted6(player))
+ return;
+ final int skillId = armorSet.getEnchant6skillId();
+ final L2Skill skill = skillId > 0 ? SkillTable.getInstance().getInfo(skillId, 1) : null;
+ if (skill != null)
+ {
+ player.addSkill(skill, false);
+ player.sendPacket(new SkillList(player));
+ }
+ }
+ }
+
+ private static void removeEquipmentSkillsBeforeFailure(Player player, ItemInstance item, EnchantScroll enchant)
+ {
+ if (!item.isEquipped() || (!enchant.cristalize() && enchant.returnVal() == -1))
+ return;
+ final Item template = item.getItem();
+ if (template instanceof Weapon weapon && item.getEnchantLevel() >= 4)
+ {
+ final L2Skill skill = weapon.getEnchant4Skill();
+ if (skill != null)
+ {
+ player.removeSkill(skill.getId(), false);
+ player.sendPacket(new SkillList(player));
+ }
+ }
+ else if (template instanceof Armor && item.getEnchantLevel() >= 6)
+ {
+ final int chestId = player.getInventory().getItemIdFrom(Paperdoll.CHEST);
+ final ArmorSet armorSet = chestId == 0 ? null : ArmorSetData.getInstance().getSet(chestId);
+ if (armorSet != null && armorSet.isEnchanted6(player))
+ {
+ final int skillId = armorSet.getEnchant6skillId();
+ if (skillId > 0)
+ {
+ player.removeSkill(skillId, false);
+ player.sendPacket(new SkillList(player));
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks the static item restrictions shared by manual and repeat requests.
+ *
+ * @param item The item to inspect.
+ * @return {@code true} when the item satisfies the static enchant restrictions.
+ */
+ public static boolean isEnchantable(ItemInstance item)
+ {
+ if (item == null || item.isHeroItem() || item.isShadowItem() || item.isEtcItem() || item.getItem().getItemType() == WeaponType.FISHINGROD || !item.isEnchantable())
+ return false;
+ if (item.getLocation() != ItemLocation.INVENTORY && item.getLocation() != ItemLocation.PAPERDOLL)
+ return false;
+ return !item.isWeapon() || !item.getWeaponItem().isTravelerWeapon();
+ }
+
+ /**
+ * Clears the per-player serialization lock after logout.
+ *
+ * @param player The player being removed from the world.
+ */
+ public void clear(Player player)
+ {
+ if (player != null)
+ _playerLocks.remove(player.getObjectId());
+ }
+
+ /**
+ * Identifies how an enchant attempt entered the server workflow.
+ */
+ public enum EnchantSource
+ {
+ /** A target selected through the stock Item Enchant window. */
+ MANUAL,
+ /** The same target confirmed through the custom repeat dialog. */
+ REPEAT
+ }
+
+ private enum FailureResult
+ {
+ SURVIVED,
+ DESTROYED,
+ ABORTED
+ }
+
+ private record AttemptOutcome(ItemInstance item, int scrollItemId, EnchantScroll enchant, boolean itemSurvived)
+ {
+ }
+
+ /**
+ * Returns the shared enchant transaction service instance.
+ *
+ * @return The singleton service instance.
+ */
+ public static EnchantService getInstance()
+ {
+ return SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder
+ {
+ private static final EnchantService INSTANCE = new EnchantService();
+ }
+}
sysstring.xml — en_US:
--- a/aCis_datapack/data/locale/en_US/sysstring.xml
+++ b/aCis_datapack/data/locale/en_US/sysstring.xml
@@ -472,6 +472,13 @@
<string key="10371">Zone</string>
<string key="10372">Route</string>
<string key="10373"> (optional)</string>
+ <!-- gameserver.model.enchant.EnchantRepeatService && EnchantService -->
+ <string key="10374">Repeat enchantment of %s using %s?</string>
+ <string key="10375">The repeat enchant request has expired.</string>
+ <string key="10376">The item or enchant scroll is no longer available.</string>
+ <string key="10377">This item can no longer be enchanted with the selected scroll.</string>
+ <string key="10378">Another enchant operation is already in progress.</string>
+ <string key="10379">Blessed enchant failed. The item enchant level became %s.</string>
<!-- gameserver.model.actor.Player: PK/Karma -->
<string key="10380">PK character %s dropped: %s.</string>
sysstring.xml — ru_RU:
--- a/aCis_datapack/data/locale/ru_RU/sysstring.xml
+++ b/aCis_datapack/data/locale/ru_RU/sysstring.xml
@@ -487,6 +487,13 @@
<string key="10371">Зона</string>
<string key="10372">Маршрут</string>
<string key="10373"> (необязательно)</string>
+ <!-- gameserver.model.enchant.EnchantRepeatService && EnchantService -->
+ <string key="10374">Повторить заточку предмета %s с помощью %s?</string>
+ <string key="10375">Время подтверждения повторной заточки истекло.</string>
+ <string key="10376">Предмет или свиток заточки больше недоступен.</string>
+ <string key="10377">Этот предмет больше нельзя заточить выбранным свитком.</string>
+ <string key="10378">Другая операция заточки уже выполняется.</string>
+ <string key="10379">Blessed-заточка не удалась. Уровень заточки предмета стал %s.</string>
<!-- gameserver.model.actor.Player: PK/Karma -->
<string key="10380">С PK-персонажа %s выпало: %s.</string>