From c3f1861cf7f16dda59e9581bd60b0095b4ec4912 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Wed, 21 Oct 2026 10:00:46 -0400 Subject: [PATCH] Replace talents by sub type and rank, cap talents at 8 Follows the client's ArcCharacter_AddRune (0x4695D0, specs/RuneRequirements.md): a talent replaces a held talent with the same non-zero rune_sub_type when its rank is 0 or higher than the held rank; otherwise the apply fails. With no talent to replace, a new talent needs fewer than 8 held talents. RuneBase gets runeType, a sub type token (SBStringHash(rune_sub_type), or the stored hash) and rank from the rune. It no longer copies the static_rune_runereq type 5 overwrite list, and ApplyRuneMsg no longer reads it. Value changes: - Stat chains (*MaxIncrease) replace the same ranks as before; the DB typos go away (250026 listed 250019 twice and missed 250018) - Talents in the same group now replace each other when applied from a runestone (e.g. Bloodline sub-race talents); before, both were added - Limit: 8 talents instead of more than 12 runes of any kind - Rank-0 downgrade (decided: follow the client) takes effect in the next commit, when the DB stat-chain blocks leave the discipline map Co-Authored-By: Claude Opus 5.5 --- src/engine/net/client/msg/ApplyRuneMsg.java | 42 +++++++++++++++------ src/engine/objects/RuneBase.java | 27 ++++++++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/engine/net/client/msg/ApplyRuneMsg.java b/src/engine/net/client/msg/ApplyRuneMsg.java index ae4cb32c..526ff445 100644 --- a/src/engine/net/client/msg/ApplyRuneMsg.java +++ b/src/engine/net/client/msg/ApplyRuneMsg.java @@ -26,6 +26,9 @@ import java.util.concurrent.ConcurrentHashMap; public class ApplyRuneMsg extends ClientNetMsg { + // Client ArcCharacter_AddRune (0x4695D0) + private static final int MAX_TALENTS = 8; + private int targetType; private int targetID; private int removeRuneBase; @@ -158,6 +161,31 @@ public class ApplyRuneMsg extends ClientNetMsg { return false; } + // Client 0x4695D0: a talent replaces a held one with the same sub type when its rank is 0 or higher + CharacterRune replacedRune = null; + + if (rb.getRuneType() == mbEnums.RuneType.TALENT) { + int talentCount = 0; + + for (CharacterRune cr : runes) { + RuneBase heldRuneBase = cr.getRuneBase(); + + if (heldRuneBase == null || heldRuneBase.getRuneType() != mbEnums.RuneType.TALENT) + continue; + + talentCount++; + + if (rb.getSubTypeToken() != 0 && heldRuneBase.getSubTypeToken() == rb.getSubTypeToken()) + replacedRune = cr; + } + + if (replacedRune != null) { + if (rb.getRank() != 0 && rb.getRank() <= replacedRune.getRuneBase().getRank()) + return false; + } else if (talentCount >= MAX_TALENTS) + return false; + } + int strTotal = 0; int dexTotal = 0; int conTotal = 0; @@ -227,11 +255,6 @@ public class ApplyRuneMsg extends ClientNetMsg { } } - //Check if max number runes already reached - if (runes.size() > 12) { - return false; - } - //if discipline, check number applied if (isDiscipline(runeID)) { if (playerCharacter.getLevel() < 70) { @@ -259,13 +282,10 @@ public class ApplyRuneMsg extends ClientNetMsg { return false; } - //remove any overridden runes from player - ArrayList overwrite = rb.getOverwrite(); + //remove the replaced talent from player CharacterRune toRemove = null; - if (overwrite.size() > 0) { - for (int overwriteID : overwrite) { - toRemove = playerCharacter.removeRune(overwriteID); - } + if (replacedRune != null) { + toRemove = playerCharacter.removeRune(replacedRune.getRuneBaseID()); } //add rune to player diff --git a/src/engine/objects/RuneBase.java b/src/engine/objects/RuneBase.java index 051af2c3..7048f45e 100644 --- a/src/engine/objects/RuneBase.java +++ b/src/engine/objects/RuneBase.java @@ -14,6 +14,7 @@ import engine.mbEnums; import engine.net.ByteBufferWriter; import engine.parsers.objects.RuneTemplate; import engine.server.MBServerStatics; +import engine.util.Hasher; import org.pmw.tinylog.Logger; import java.sql.ResultSet; @@ -38,6 +39,9 @@ public class RuneBase extends AbstractGameObject { private final ArrayList overwrite = new ArrayList<>(); private int levelRequired = 1; private ArrayList effectsList = new ArrayList<>(); + private mbEnums.RuneType runeType; + private int subTypeToken; + private int rank; /** * No Table ID Constructor @@ -84,6 +88,7 @@ public class RuneBase extends AbstractGameObject { * Rune Template Constructor * type and subtype are server enums that don't map cleanly from rune_type/rune_sub_type, so they * come from the static_rune_runebase row, as do the requirement maps and effects until 2.5b/2.10. + * Talents with the same sub type token replace each other by rank (client 0x4695D0), so no overwrite list. */ public RuneBase(RuneTemplate rune, RuneBase dbRuneBase) { super(rune.template_id); @@ -96,9 +101,16 @@ public class RuneBase extends AbstractGameObject { this.baseClass.putAll(dbRuneBase.baseClass); this.promotionClass.putAll(dbRuneBase.promotionClass); this.discipline.putAll(dbRuneBase.discipline); - this.overwrite.addAll(dbRuneBase.overwrite); this.levelRequired = dbRuneBase.levelRequired; this.effectsList = dbRuneBase.effectsList; + this.runeType = rune.rune_type; + this.rank = rune.rune_rank; + + // Same token as CObjectIndex: unresolved sub types are stored as their hash + if (!rune.rune_sub_type.isEmpty()) + this.subTypeToken = Hasher.SBStringHash(rune.rune_sub_type); + else + this.subTypeToken = rune.rune_sub_type_hash; } public static RuneBase getRuneBase(int tableId) { @@ -241,6 +253,19 @@ public class RuneBase extends AbstractGameObject { return this.overwrite; } + public mbEnums.RuneType getRuneType() { + return this.runeType; + } + + // 0 when the rune has no sub type; talents sharing a non-zero token replace each other + public int getSubTypeToken() { + return this.subTypeToken; + } + + public int getRank() { + return this.rank; + } + /* * Serializing */