forked from MagicBane/Server
RuneBase(RuneTemplate) fills the race, class and discipline maps from item_race_req / item_class_req / item_disc_req (names to rune ids through CObjectIndex; class runes with rune_is_standard_character_creation go to baseClass, the rest to promotionClass) with a restrict flag for each, and the level from item_level_req. static_rune_runereq is no longer used for cached rune bases; GET_RUNE_REQS stays until 2.11. ApplyRuneMsg follows ArcCharacter_MeetsItemReqs (0x475F40) and ArcItemRestrict_Check (0x4BAC60): restrict true = blacklist, false = whitelist. The class check passes when the base class or the promotion is listed. The discipline check is against the discs already held, using the new rune's list only. Parity (offline, scripts/p25a.py with feasible race/promotion pairs under the client rule; scripts/p25b.py over all 315 rune bases). Value changes, all decided JSON (D6, 2026-10-21): - 3001 Animator: + Irekei (4 pairs); Summoner blocks it - 3008 Commander: Saboteur blocks it - 3010 Duelist: level 20, 5 races, 7 promotions (had no DB rows; -111 pairs) - 3018 Enchanter, 3030 Conjurer: + Vampire (4 pairs each) - 3020 Summoner: - Thief/Warrior/Barbarian (28 pairs), + Centaur Druid/Priest (4); Animator blocks it - 3031 Darksworn: + Irekei Priest (2); 3032 Valkyr: + Human/Aelfborn Warrior (2) - 3037 Artillerist: + Sentinel (11); 3044 Strigoi: Belgosch blocks it - 252127 Master of Throwing: - Scout (16 pairs) - 70 more runes with no DB rows gain their JSON lists (class runes 2500-2526 except the Pet rune, creation talents and Bloodline talents); only runestones are checked - Stat chains: the DB "higher rank held" blocks leave the discipline map. Ranks 1-8 are still refused by the sub type rule; a rank-0 rune now replaces a higher rank as the client does (decided: follow the client) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
14 KiB
Java
394 lines
14 KiB
Java
// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ .
|
|
// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌·
|
|
// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀
|
|
// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌
|
|
// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀
|
|
// Magicbane Emulator Project © 2013 - 2026
|
|
// https://www.magicbane.com
|
|
|
|
|
|
package engine.net.client.msg;
|
|
|
|
import engine.gameManager.DbManager;
|
|
import engine.gameManager.DispatchManager;
|
|
import engine.mbEnums;
|
|
import engine.net.*;
|
|
import engine.net.client.ClientConnection;
|
|
import engine.objects.CharacterRune;
|
|
import engine.objects.PlayerCharacter;
|
|
import engine.objects.RuneBase;
|
|
import engine.objects.RuneBaseAttribute;
|
|
import engine.server.MBServerStatics;
|
|
import org.pmw.tinylog.Logger;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collection;
|
|
import java.util.Collections;
|
|
import java.util.HashSet;
|
|
import java.util.Set;
|
|
|
|
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;
|
|
private int runeBase;
|
|
private int runeType;
|
|
private int runeID;
|
|
private Boolean isPromo;
|
|
|
|
/**
|
|
* This is the general purpose constructor.
|
|
*/
|
|
public ApplyRuneMsg(int targetType, int targetID, int runeBase, int runeType, int runeID, Boolean isPromo) {
|
|
super(Protocol.SETRUNE);
|
|
this.targetType = targetType;
|
|
this.targetID = targetID;
|
|
this.runeBase = runeBase;
|
|
this.runeType = runeType;
|
|
this.runeID = runeID;
|
|
this.isPromo = isPromo;
|
|
this.removeRuneBase = 0;
|
|
}
|
|
|
|
public ApplyRuneMsg(int targetType, int targetID, int removeRuneBase) {
|
|
super(Protocol.SETRUNE);
|
|
this.targetType = targetType;
|
|
this.targetID = targetID;
|
|
this.runeBase = 0;
|
|
this.runeType = 0;
|
|
this.runeID = 0;
|
|
this.isPromo = false;
|
|
this.removeRuneBase = removeRuneBase;
|
|
}
|
|
|
|
/**
|
|
* This constructor is used by NetMsgFactory. It attempts to deserialize the
|
|
* ByteBuffer into a message. If a BufferUnderflow occurs (based on reading
|
|
* past the limit) then this constructor Throws that Exception to the
|
|
* caller.
|
|
*/
|
|
public ApplyRuneMsg(AbstractConnection origin, ByteBufferReader reader) {
|
|
super(Protocol.SETRUNE, origin, reader);
|
|
}
|
|
|
|
public static boolean applyRune(int runeID, ClientConnection origin, PlayerCharacter playerCharacter) {
|
|
|
|
RuneBase rb = RuneBase.getRuneBase(runeID);
|
|
Dispatch dispatch;
|
|
|
|
if (playerCharacter == null || origin == null || rb == null) {
|
|
return false;
|
|
}
|
|
|
|
ArrayList<CharacterRune> runes = playerCharacter.getRunes();
|
|
HashSet<Integer> classIDs = new HashSet<>();
|
|
HashSet<Integer> heldDiscIDs = new HashSet<>();
|
|
|
|
// Client ArcCharacter_MeetsItemReqs (0x475F40)
|
|
if (!meetsListReq(rb.getRace().keySet(), rb.isRaceRestrict(), Collections.singleton(playerCharacter.getRaceID())))
|
|
return false;
|
|
|
|
// A listed base class covers its promotions: base or promotion listed is enough
|
|
classIDs.addAll(rb.getBaseClass().keySet());
|
|
classIDs.addAll(rb.getPromotionClass().keySet());
|
|
|
|
if (!meetsListReq(classIDs, rb.isClassRestrict(), Arrays.asList(playerCharacter.getBaseClassID(), playerCharacter.getPromotionClassID())))
|
|
return false;
|
|
|
|
// Only the new rune's list is checked, not the held discs' lists
|
|
for (CharacterRune cr : runes)
|
|
if (isDiscipline(cr.getRuneBaseID()))
|
|
heldDiscIDs.add(cr.getRuneBaseID());
|
|
|
|
if (!meetsListReq(rb.getDiscipline().keySet(), rb.isDiscRestrict(), heldDiscIDs))
|
|
return false;
|
|
|
|
int discCount = 0;
|
|
for (CharacterRune cr : runes) {
|
|
int runeBaseID = cr.getRuneBaseID();
|
|
//count number of discipline runes
|
|
if (runeBaseID > 3000 && runeBaseID < 3049) {
|
|
discCount++;
|
|
}
|
|
//see if rune is already applied
|
|
if (runeBaseID == runeID) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
//Check level is met
|
|
if (playerCharacter.getLevel() < rb.getLevelRequired()) {
|
|
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;
|
|
int intTotal = 0;
|
|
int spiTotal = 0;
|
|
int cost = 0;
|
|
|
|
//Check any attributes are met
|
|
ArrayList<RuneBaseAttribute> attrs = rb.getAttrs();
|
|
|
|
if (rb.getAttrs() != null)
|
|
for (RuneBaseAttribute rba : attrs) {
|
|
int attrID = rba.getAttributeID();
|
|
int mod = rba.getModValue();
|
|
switch (attrID) {
|
|
case MBServerStatics.RUNE_COST_ATTRIBUTE_ID:
|
|
if (mod > playerCharacter.getUnusedStatPoints()) {
|
|
return false;
|
|
}
|
|
cost = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_STR_MIN_NEEDED_ATTRIBUTE_ID:
|
|
if ((int) playerCharacter.statStrBase < mod) {
|
|
return false;
|
|
}
|
|
strTotal = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_DEX_MIN_NEEDED_ATTRIBUTE_ID:
|
|
if ((int) playerCharacter.statDexBase < mod) {
|
|
return false;
|
|
}
|
|
dexTotal = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_CON_MIN_NEEDED_ATTRIBUTE_ID:
|
|
if ((int) playerCharacter.statConBase < mod) {
|
|
return false;
|
|
}
|
|
conTotal = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_INT_MIN_NEEDED_ATTRIBUTE_ID:
|
|
if ((int) playerCharacter.statIntBase < mod) {
|
|
return false;
|
|
}
|
|
intTotal = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_SPI_MIN_NEEDED_ATTRIBUTE_ID:
|
|
if ((int) playerCharacter.statSpiBase < mod) {
|
|
return false;
|
|
}
|
|
spiTotal = mod;
|
|
break;
|
|
case MBServerStatics.RUNE_STR_ATTRIBUTE_ID:
|
|
strTotal += mod;
|
|
break;
|
|
case MBServerStatics.RUNE_DEX_ATTRIBUTE_ID:
|
|
dexTotal += mod;
|
|
break;
|
|
case MBServerStatics.RUNE_CON_ATTRIBUTE_ID:
|
|
conTotal += mod;
|
|
break;
|
|
case MBServerStatics.RUNE_INT_ATTRIBUTE_ID:
|
|
intTotal += mod;
|
|
break;
|
|
case MBServerStatics.RUNE_SPI_ATTRIBUTE_ID:
|
|
spiTotal += mod;
|
|
break;
|
|
}
|
|
}
|
|
|
|
//if discipline, check number applied
|
|
if (isDiscipline(runeID)) {
|
|
if (playerCharacter.getLevel() < 70) {
|
|
if (discCount > 2) {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (discCount > 3) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
//Everything succeeded. Let's apply the rune
|
|
//Attempt add rune to database
|
|
CharacterRune runeWithoutID = new CharacterRune(rb, playerCharacter.getObjectUUID());
|
|
CharacterRune cr;
|
|
try {
|
|
cr = DbManager.CharacterRuneQueries.ADD_CHARACTER_RUNE(runeWithoutID);
|
|
} catch (Exception e) {
|
|
cr = null;
|
|
Logger.error(e);
|
|
}
|
|
if (cr == null) {
|
|
return false;
|
|
}
|
|
|
|
//remove the replaced talent from player
|
|
CharacterRune toRemove = null;
|
|
if (replacedRune != null) {
|
|
toRemove = playerCharacter.removeRune(replacedRune.getRuneBaseID());
|
|
}
|
|
|
|
//add rune to player
|
|
playerCharacter.addRune(cr);
|
|
|
|
// recalculate all bonuses/formulas/skills/powers
|
|
playerCharacter.recalculate();
|
|
|
|
//if overwriting a stat rune, add any amount granted from previous rune.
|
|
if (toRemove != null) {
|
|
RuneBase rbs = toRemove.getRuneBase();
|
|
if (rbs != null && rbs.getObjectUUID() > 249999 && rbs.getObjectUUID() < 250045) {
|
|
//add any additional stats to match old amount
|
|
int dif = strTotal - (int) playerCharacter.statStrBase;
|
|
if (dif > 0 && strTotal < (int) playerCharacter.statStrMax) {
|
|
playerCharacter.addStr(dif);
|
|
}
|
|
dif = dexTotal - (int) playerCharacter.statDexBase;
|
|
if (dif > 0 && dexTotal < (int) playerCharacter.statDexMax) {
|
|
playerCharacter.addDex(dif);
|
|
}
|
|
dif = conTotal - (int) playerCharacter.statConBase;
|
|
if (dif > 0 && conTotal < (int) playerCharacter.statConMax) {
|
|
playerCharacter.addCon(dif);
|
|
}
|
|
dif = intTotal - (int) playerCharacter.statIntBase;
|
|
if (dif > 0 && intTotal < (int) playerCharacter.statIntMax) {
|
|
playerCharacter.addInt(dif);
|
|
}
|
|
dif = spiTotal - (int) playerCharacter.statSpiBase;
|
|
if (dif > 0 && spiTotal < (int) playerCharacter.statSpiMax) {
|
|
playerCharacter.addSpi(dif);
|
|
}
|
|
|
|
// recalculate all bonuses/formulas/skills/powers
|
|
playerCharacter.recalculate();
|
|
}
|
|
}
|
|
|
|
if (cost > 0) {
|
|
ModifyStatMsg msm = new ModifyStatMsg((0 - cost), 0, 3);
|
|
dispatch = Dispatch.borrow(playerCharacter, msm);
|
|
DispatchManager.dispatchMsgDispatch(dispatch, mbEnums.DispatchChannel.SECONDARY);
|
|
}
|
|
|
|
//send apply rune message to client
|
|
ApplyRuneMsg arm = new ApplyRuneMsg(playerCharacter.getObjectType().ordinal(), playerCharacter.getObjectUUID(), runeID, cr.getObjectType().ordinal(), cr.getObjectUUID(), false);
|
|
dispatch = Dispatch.borrow(playerCharacter, arm);
|
|
DispatchManager.dispatchMsgDispatch(dispatch, mbEnums.DispatchChannel.SECONDARY);
|
|
|
|
|
|
//alert them of success
|
|
ErrorPopupMsg.sendErrorPopup(playerCharacter, 160);
|
|
|
|
//reapply bonuses
|
|
playerCharacter.applyBonuses();
|
|
|
|
return true;
|
|
}
|
|
|
|
public static boolean isDiscipline(int runeID) {
|
|
|
|
return runeID > 3000 && runeID < 3050;
|
|
}
|
|
|
|
// Client ArcItemRestrict_Check (0x4BAC60). An empty list has no requirement; no rune has an empty whitelist.
|
|
private static boolean meetsListReq(Set<Integer> listedIDs, boolean restrict, Collection<Integer> heldIDs) {
|
|
|
|
boolean listed = false;
|
|
|
|
if (listedIDs.isEmpty())
|
|
return true;
|
|
|
|
for (int heldID : heldIDs)
|
|
if (listedIDs.contains(heldID))
|
|
listed = true;
|
|
|
|
return listed != restrict;
|
|
}
|
|
|
|
/**
|
|
* Serializes the subclass specific items to the supplied NetMsgWriter.
|
|
*/
|
|
@Override
|
|
protected void _serialize(ByteBufferWriter writer) {
|
|
writer.putInt(this.targetType);
|
|
writer.putInt(this.targetID);
|
|
writer.putInt(0);
|
|
writer.putInt(this.removeRuneBase);
|
|
writer.putInt(0);
|
|
writer.putInt(this.runeBase);
|
|
writer.putInt(this.runeType);
|
|
writer.putInt(this.runeID);
|
|
if (this.isPromo) {
|
|
writer.put((byte) 1);
|
|
} else {
|
|
writer.put((byte) 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deserializes the subclass specific items from the supplied NetMsgReader.
|
|
*/
|
|
@Override
|
|
protected void _deserialize(ByteBufferReader reader) {
|
|
this.targetType = reader.getInt();
|
|
this.targetID = reader.getInt();
|
|
reader.getInt();
|
|
this.removeRuneBase = reader.getInt();
|
|
reader.getInt();
|
|
this.runeBase = reader.getInt();
|
|
this.runeType = reader.getInt();
|
|
this.runeID = reader.getInt();
|
|
this.isPromo = (reader.get() == 1) ? true : false;
|
|
}
|
|
|
|
public int getTargetType() {
|
|
return targetType;
|
|
}
|
|
|
|
public void setTargetType(int value) {
|
|
this.targetType = value;
|
|
}
|
|
|
|
public int getTargetID() {
|
|
return targetID;
|
|
}
|
|
|
|
public void setTargetID(int value) {
|
|
this.targetID = value;
|
|
}
|
|
|
|
public int getRuneID() {
|
|
return runeID;
|
|
}
|
|
|
|
public void setRuneID(int value) {
|
|
this.runeID = value;
|
|
}
|
|
}
|