Build static NPCs from zone mobiles with their CZONE runes and equipment

NPC.createStaticNPC fills a TRAINER zone mobile; static NPCs send the CZONE
rune list ordered by kind (RuneType.mobileRuneKind) and wear the
mobile_equipment list. DB NPCs keep the contract rune rebuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MagicBotandClaude Opus 5.5 committed 2026-10-28 08:54:14 -04:00
1 parent 2fcdcf9f0d
commit 6a3480a0dd
4 files changed
+242 -102

No files matched your search

+10
View File
@@ -510,6 +510,16 @@ public enum NPCManager {
public static void applyRunesForNPC(NPC npc) {
npc.runes = new ArrayList<>();
if (!npc.staticRuneIDs.isEmpty()) {
for (int runeID : npc.staticRuneIDs) {
RuneBase rb = RuneBase.getRuneBase(runeID);
if (rb != null)
npc.runes.add(new CharacterRune(rb, npc.getObjectUUID()));
}
return;
}
RuneBase shopkeeperBase = RuneBase.getRuneBase(252620);
CharacterRune shopkeeper = new CharacterRune(shopkeeperBase, npc.getObjectUUID());
npc.runes.add(shopkeeper);
+11 -4
View File
@@ -1471,10 +1471,17 @@ public class mbEnums {
// rune_type values from the client cache data (COBJECTS/RUNE)
public enum RuneType {
CLASS,
DISCIPLINE,
RACE,
TALENT
CLASS(3),
DISCIPLINE(4),
RACE(1),
TALENT(5);
// Rune kind live servers sent for mobs and NPCs (World Boot Plan findings §5, Task 3.1); players send base classes as 2
public final int mobileRuneKind;
RuneType(int mobileRuneKind) {
this.mobileRuneKind = mobileRuneKind;
}
}
public enum ShrineType {
+22
View File
@@ -16,6 +16,7 @@ import engine.loot.BootySetEntry;
import engine.math.Vector3fImmutable;
import engine.mbEnums;
import engine.parsers.cache.CacheParserUtil;
import engine.parsers.objects.CZoneDrop;
import engine.parsers.objects.RuneTemplate;
import engine.server.MBServerStatics;
import org.pmw.tinylog.Logger;
@@ -158,6 +159,27 @@ public class MobBase extends AbstractGameObject {
return equip;
}
// Worn items straight from a zone mobile's mobile_equipment list (World Boot Plan 3.2); no booty set ID
public static ConcurrentHashMap<mbEnums.EquipSlotType, Item> loadEquipment(ArrayList<CZoneDrop> equipmentList) {
ConcurrentHashMap<mbEnums.EquipSlotType, Item> equip = new ConcurrentHashMap<>();
for (CZoneDrop equipmentEntry : equipmentList) {
Item item = new Item(equipmentEntry.item_id);
if (item.template.getItemEqSlotsOr().isEmpty())
continue;
item.objectUUID = ItemManager.lastNegativeID.decrementAndGet();
item.drop_chance = equipmentEntry.chance;
item.equipSlot = item.template.getItemEqSlotsOr().iterator().next();
equip.put(item.equipSlot, item);
}
return equip;
}
public static MobBase getMobBase(int id) {
return DbManager.MobBaseQueries.GET_MOBBASE(id);
}
+199 -98
View File
@@ -25,6 +25,10 @@ import engine.net.Dispatch;
import engine.net.client.msg.PetMsg;
import engine.net.client.msg.PlaceAssetMsg;
import engine.parsers.objects.CObjectTemplate;
import engine.parsers.objects.CZoneDrop;
import engine.parsers.objects.CZoneMobile;
import engine.parsers.objects.CZonePlacement;
import engine.parsers.objects.RuneTemplate;
import engine.server.MBServerStatics;
import org.joda.time.DateTime;
import org.pmw.tinylog.Logger;
@@ -44,6 +48,7 @@ import static engine.util.StringUtils.wordCount;
public class NPC extends AbstractCharacter {
public static int SVR_CLOSE_WINDOW = 4;
public static final int SHOPKEEPER_RUNE = 252620;
public static HashMap<Integer, ArrayList<String>> _pirateNames = new HashMap<>();
// Used for thread safety
public final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
@@ -74,6 +79,9 @@ public class NPC extends AbstractCharacter {
public int parentZoneUUID;
public int equipmentSetID = 0;
private int repairCost = 5;
// Static zone trainers (World Boot Plan 3.2): the CZONE rune list as given, and worn equipment
public final ArrayList<Integer> staticRuneIDs = new ArrayList<>();
private ArrayList<CZoneDrop> staticEquipment = null;
// New NPC constructor. Fill in the blanks and then call
// PERSIST.
@@ -188,106 +196,18 @@ public class NPC extends AbstractCharacter {
public static void serializeForClientMsgOtherPlayer(NPC npc, ByteBufferWriter writer) {
writer.putInt(0);
writer.putInt(0);
//num Runes
int cnt = 3;
boolean isVamp = false, isHealer = false, isArcher = false, isTrainer = false;
int contractID = 0, classID = 0;
int extraRune = 0;
if (npc.contract != null) {
contractID = npc.contract.getContractID();
classID = npc.contract.getClassID();
extraRune = npc.contract.getExtraRune();
if (extraRune == contractID)
extraRune = 0;
}
if ((contractID > 252642 && contractID < 252647) || contractID == 252652) {
isVamp = true;
cnt++;
}
if (contractID == 252582 || contractID == 252579 || contractID == 252581
|| contractID == 252584 || contractID == 252597 || contractID == 252598
|| contractID == 252628 || extraRune == 252582 || extraRune == 252579
|| extraRune == 252581 || extraRune == 252584 || extraRune == 252597
|| extraRune == 252598 || extraRune == 252628) {
isHealer = true;
cnt++;
}
if (contractID == 252570) {
isArcher = true;
cnt++;
}
if (classID != 0)
cnt++;
if (extraRune != 0 && extraRune != contractID)
cnt++;
writer.putInt(cnt);
//Race
writer.putInt(1);
writer.putInt(0);
if (npc.mobBase != null)
writer.putInt(npc.mobBase.getObjectUUID());
else
writer.putInt(2011);
writer.putInt(GameObjectType.NPCRaceRune.ordinal());
writer.putInt(npc.currentID);
//Class/Trainer/Whatever
writer.putInt(5);
writer.putInt(0);
int classID = 0;
if (npc.contract != null)
writer.putInt(contractID);
else
writer.putInt(2500);
classID = npc.contract.getClassID();
writer.putInt(GameObjectType.NPCClassRune.ordinal());
writer.putInt(npc.currentID);
//vampire trainer
cnt = 0;
if (extraRune != 0)
cnt = serializeExtraRune(npc, extraRune, cnt, writer);
if (isVamp)
cnt = serializeExtraRune(npc, 252647, cnt, writer);
//Healer trainer
if (isHealer)
cnt = serializeExtraRune(npc, 252592, cnt, writer);
if (classID != 0) {
writer.putInt(4);
writer.putInt(0);
writer.putInt(classID);
writer.putInt(GameObjectType.NPCExtraRune.ordinal());
writer.putInt(npc.currentID);
}
//Scout trainer
if (isArcher)
cnt = serializeExtraRune(npc, 252654, cnt, writer);
//Shopkeeper
writer.putInt(5);
writer.putInt(0);
writer.putInt(0x3DACC);
writer.putInt(GameObjectType.NPCShopkeeperRune.ordinal());
writer.putInt(npc.currentID);
writer.putInt(0);
if (npc.staticRuneIDs.isEmpty())
serializeContractRunes(npc, writer);
else
serializeStaticRunes(npc, writer);
//Send Stats
writer.putInt(5);
@@ -431,6 +351,140 @@ public class NPC extends AbstractCharacter {
writer.put((byte) 0);
}
// DB NPCs rebuild their runes from the contract
private static void serializeContractRunes(NPC npc, ByteBufferWriter writer) {
int cnt = 3;
boolean isVamp = false, isHealer = false, isArcher = false, isTrainer = false;
int contractID = 0, classID = 0;
int extraRune = 0;
if (npc.contract != null) {
contractID = npc.contract.getContractID();
classID = npc.contract.getClassID();
extraRune = npc.contract.getExtraRune();
if (extraRune == contractID)
extraRune = 0;
}
if ((contractID > 252642 && contractID < 252647) || contractID == 252652) {
isVamp = true;
cnt++;
}
if (contractID == 252582 || contractID == 252579 || contractID == 252581
|| contractID == 252584 || contractID == 252597 || contractID == 252598
|| contractID == 252628 || extraRune == 252582 || extraRune == 252579
|| extraRune == 252581 || extraRune == 252584 || extraRune == 252597
|| extraRune == 252598 || extraRune == 252628) {
isHealer = true;
cnt++;
}
if (contractID == 252570) {
isArcher = true;
cnt++;
}
if (classID != 0)
cnt++;
if (extraRune != 0 && extraRune != contractID)
cnt++;
writer.putInt(cnt);
//Race
writer.putInt(1);
writer.putInt(0);
if (npc.mobBase != null)
writer.putInt(npc.mobBase.getObjectUUID());
else
writer.putInt(2011);
writer.putInt(GameObjectType.NPCRaceRune.ordinal());
writer.putInt(npc.currentID);
//Class/Trainer/Whatever
writer.putInt(5);
writer.putInt(0);
if (npc.contract != null)
writer.putInt(contractID);
else
writer.putInt(2500);
writer.putInt(GameObjectType.NPCClassRune.ordinal());
writer.putInt(npc.currentID);
//vampire trainer
cnt = 0;
if (extraRune != 0)
cnt = serializeExtraRune(npc, extraRune, cnt, writer);
if (isVamp)
cnt = serializeExtraRune(npc, 252647, cnt, writer);
//Healer trainer
if (isHealer)
cnt = serializeExtraRune(npc, 252592, cnt, writer);
if (classID != 0) {
writer.putInt(4);
writer.putInt(0);
writer.putInt(classID);
writer.putInt(GameObjectType.NPCExtraRune.ordinal());
writer.putInt(npc.currentID);
}
//Scout trainer
if (isArcher)
cnt = serializeExtraRune(npc, 252654, cnt, writer);
//Shopkeeper
writer.putInt(5);
writer.putInt(0);
writer.putInt(0x3DACC);
writer.putInt(GameObjectType.NPCShopkeeperRune.ordinal());
writer.putInt(npc.currentID);
}
// Static trainers send the CZONE rune list ordered by kind, as live (World Boot Plan findings §5, Task 3.1)
private static void serializeStaticRunes(NPC npc, ByteBufferWriter writer) {
ArrayList<Integer> runeIDs = npc.getStaticRunesByKind();
GameObjectType[] otherRuneTypes = {GameObjectType.NPCClassRune, GameObjectType.NPCClassRuneTwo,
GameObjectType.NPCClassRuneThree, GameObjectType.NPCExtraRune};
GameObjectType runeObjectType;
RuneTemplate rune;
int otherRunes = 0;
writer.putInt(runeIDs.size());
for (int runeID : runeIDs) {
rune = RuneTemplate._runes.get(runeID);
// Each rune object needs its own type, since all share the NPC's ID
if (rune.rune_type == RuneType.RACE)
runeObjectType = GameObjectType.NPCRaceRune;
else if (runeID == SHOPKEEPER_RUNE)
runeObjectType = GameObjectType.NPCShopkeeperRune;
else
runeObjectType = otherRuneTypes[Math.min(otherRunes++, otherRuneTypes.length - 1)];
writer.putInt(rune.rune_type.mobileRuneKind);
writer.putInt(0);
writer.putInt(runeID);
writer.putInt(runeObjectType.ordinal());
writer.putInt(npc.currentID);
}
}
private static int serializeExtraRune(NPC npc, int runeID, int cnt, ByteBufferWriter writer) {
writer.putInt(5);
@@ -488,6 +542,39 @@ public class NPC extends AbstractCharacter {
return npc;
}
// World Boot Plan 3.2: a TRAINER zone mobile. The caller adds it to the cache and runs runAfterLoad.
public static NPC createStaticNPC(int npcID, Zone parentZone, CZoneMobile mobile, int contractID, Building building, Guild guild) {
NPC npc = new NPC();
CZonePlacement placement = mobile.mobile_base_zone;
npc.setObjectUUID(npcID);
npc.currentID = npcID;
npc.setStaticWorldObject(true);
npc.setObjectTypeMask(MBServerStatics.MASK_NPC);
npc.gridObjectType = GridObjectType.STATIC;
npc.parentZoneUUID = parentZone.getObjectUUID();
npc.name = placement.basic_zone_name_override;
npc.contractUUID = contractID;
npc.loadID = mobile.mobile_rune_stone_ids.get(0);
npc.level = (short) mobile.mobile_level;
npc.buildingUUID = building == null ? 0 : building.getObjectUUID();
npc.guildUUID = guild == null ? 0 : guild.getObjectUUID();
npc.statLat = placement.basic_zone_spawn_location.x;
npc.statAlt = placement.basic_zone_spawn_location.y;
npc.statLon = placement.basic_zone_spawn_location.z;
npc.buyPercent = .33f;
npc.sellPercent = 1;
// W8: zone mobiles face π − y_rot, like props
npc.setRotation(Quaternion.fromYaw((float) (Math.PI - placement.basic_zone_y_rot)));
npc.staticRuneIDs.addAll(mobile.mobile_rune_stone_ids);
npc.staticEquipment = mobile.mobile_equipment;
return npc;
}
public static NPC getNPC(int id) {
if (id == 0)
@@ -563,6 +650,16 @@ public class NPC extends AbstractCharacter {
return 0;
}
// Stable sort: CZONE order within a kind
public ArrayList<Integer> getStaticRunesByKind() {
ArrayList<Integer> runeIDs = new ArrayList<>(this.staticRuneIDs);
runeIDs.sort((first, second) -> Integer.compare(RuneTemplate._runes.get(first).rune_type.mobileRuneKind,
RuneTemplate._runes.get(second).rune_type.mobileRuneKind));
return runeIDs;
}
public boolean isStatic() {
return this.isStatic;
}
@@ -787,7 +884,8 @@ public class NPC extends AbstractCharacter {
this.contract = DbManager.ContractQueries.GET_CONTRACT(this.contractUUID);
if (this.equipmentSetID == 0 && this.contract != null)
// Static trainers wear only their CZONE equipment
if (this.equipmentSetID == 0 && this.contract != null && this.staticEquipment == null)
this.equipmentSetID = this.contract.equipmentSet;
// Default to contract load ID
@@ -883,7 +981,10 @@ public class NPC extends AbstractCharacter {
if (equipmentSetID != 0 && LootManager._bootySetMap.get(equipmentSetID) == null)
Logger.error("Invalid equipSet: " + equipmentSetID + " contract: " + this.contractUUID + " npc: " + this.getObjectUUID());
this.charItemManager.equipped = loadEquipmentSet(this.equipmentSetID);
if (this.staticEquipment != null)
this.charItemManager.equipped = MobBase.loadEquipment(this.staticEquipment);
else
this.charItemManager.equipped = loadEquipmentSet(this.equipmentSetID);
// Create NPC bounds object
Bounds npcBounds = Bounds.borrow();