Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Maple2.Database/Context/MetadataContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@ private static void ConfigureNpcMetadata(EntityTypeBuilder<NpcMetadata> builder)
builder.Property(npc => npc.DropInfo).HasJsonConversion();
builder.Property(npc => npc.Action).HasJsonConversion();
builder.Property(npc => npc.Dead).HasJsonConversion();
builder.Property(npc => npc.Corpse).HasJsonConversion();
builder.Property(npc => npc.LookAtTarget).HasJsonConversion();
}

Expand Down
11 changes: 10 additions & 1 deletion Maple2.File.Ingest/Mapper/NpcMapper.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ protected override IEnumerable<NpcMetadata> Map() {
DeadGlobalDropBoxIds: data.dropiteminfo.globalDeadDropBoxId,
IndividualDropBoxIds: data.dropiteminfo.individualDropBoxId,
GlobalHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId,
IndividualHitDropBoxIds: data.dropiteminfo.globalHitDropBoxId),
IndividualHitDropBoxIds: data.dropiteminfo.individualHitDropBoxId),
Action: new NpcMetadataAction(
RotateSpeed: data.speed.rotation,
WalkSpeed: data.speed.walk,
Expand All@@ -114,6 +114,15 @@ protected override IEnumerable<NpcMetadata> Map() {
Count: data.dead.count,
LifeTime: data.dead.lifeTime,
ExtendRoomTime: data.dead.extendRoomTime),
Corpse: data.corpse.hitAble == 1 ? new NpcMetadataCorpse(
Width: data.corpse.width,
Height: data.corpse.height,
Depth: data.corpse.depth,
Added: data.corpse.added,
OffsetNametag: data.corpse.offsetNametag,
CorpseEffect: data.corpse.corpseEffect,
HitAble: true,
Rotation: data.corpse.rotation) : null,
LookAtTarget: new NpcMetadataLookAtTarget(
data.lookattarget.targetdummy,
data.lookattarget.lookAtMyPCWhenTalking == 1,
Expand Down
11 changes: 11 additions & 0 deletions Maple2.Model/Metadata/NpcMetadata.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ public record NpcMetadata(
NpcMetadataDropInfo DropInfo,
NpcMetadataAction Action,
NpcMetadataDead Dead,
NpcMetadataCorpse? Corpse,
NpcMetadataLookAtTarget LookAtTarget) : ISearchResult;

public record NpcMetadataModel(
Expand DownExpand Up@@ -105,6 +106,16 @@ public record NpcMetadataDead(
float LifeTime,
int ExtendRoomTime);

public record NpcMetadataCorpse(
float Width,
float Height,
float Depth,
float Added,
float OffsetNametag,
string CorpseEffect,
bool HitAble,
Vector3 Rotation);

public record NpcMetadataLookAtTarget(
string TargetDummy,
bool LookAtMyPlayerWhenTalking,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1047,6 +1047,9 @@ public void OnAddPlayer(FieldPlayer added) {
}
foreach (FieldNpc fieldNpc in Npcs.Values.Concat(Mobs.Values)) {
added.Session.Send(FieldPacket.AddNpc(fieldNpc));
if (fieldNpc.IsCorpse) {
added.Session.Send(NpcControlPacket.Dead(fieldNpc));
}
}
foreach (FieldPet fieldPet in Pets.Values) {
added.Session.Send(FieldPacket.AddPet(fieldPet));
Expand Down
7 changes: 6 additions & 1 deletion Maple2.Server.Game/Manager/Items/ItemDropManager.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,8 @@ public ItemDropManager(FieldManager field) {
this.field = field;
}

// level is the NPC's level. It gates both group-level and item-level min/max level checks within
// globalDropItemBox/globalDropItemSet (e.g. meso amounts in group 103 scale with NPC level).
Comment thread
AngeloTadeucci marked this conversation as resolved.
public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0) {
if (!field.ServerTableMetadata.GlobalDropItemBoxTable.DropGroups.TryGetValue(globalDropBoxId, out Dictionary<int, IList<GlobalDropItemBoxTable.Group>>? dropGroup)) {
return new List<Item>();
Expand All@@ -29,7 +31,7 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
continue;
}

// Check if player meets level requirements.
// Check if NPC level meets the group's level requirements.
if (group.MinLevel > level || (group.MaxLevel > 0 && group.MaxLevel < level)) {
continue;
}
Expand DownExpand Up@@ -95,6 +97,9 @@ public ICollection<Item> GetGlobalDropItems(int globalDropBoxId, int level = 0)
return results;
}

// level gates which drop groups are eligible. For mob drops this is the player's character level
// (MinLevel in individualDropItemTable is a player requirement, not NPC level — e.g. Shinjo's Feather
// requires player level 30+). For other callers (e.g. housing rewards) it may be a different score.
public ICollection<Item> GetIndividualDropItems(GameSession session, int level, int individualDropBoxId, int index = -1, int dropGroupId = -1) {
if (!field.ServerTableMetadata.IndividualDropItemTable.Entries.TryGetValue(individualDropBoxId, out IDictionary<int, IndividualDropItemTable.Entry>? entryDict)) {
return new List<Item>();
Expand Down
169 changes: 143 additions & 26 deletions Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,9 +85,15 @@ public short SequenceId {
public readonly SkillMetadata?[] Skills;

public int SpawnPointId = 0;
public bool IsCorpse { get; private set; }
private long lastCorpseBroadcastTick;
public Action<FieldNpc>? WorldBossDeathCallback { get; set; }
public long LastDamageTick { get; private set; }
private int lastAttackerObjectId;
// The first player to attack this mob; used to assign drop ownership for regular mobs.
// TODO: If the mob loses aggro on all players it should heal to full and clear firstAttackerObjectId and DamageDealers,
// resetting the tag so the next attacker becomes the new owner.
private int firstAttackerObjectId;

public MS2PatrolData? Patrol { get; private set; }
private int currentWaypointIndex;
Expand DownExpand Up@@ -141,7 +147,14 @@ protected override void Dispose(bool disposing) { }
private long nextDebugPacket = 0;

public override void Update(long tickCount) {
if (IsDead) return;
if (IsDead) {
if (IsCorpse && tickCount - lastCorpseBroadcastTick >= 1000) {
lastCorpseBroadcastTick = tickCount;
SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base.Update(tickCount);

Expand DownExpand Up@@ -320,14 +333,28 @@ public override void KeyframeEvent(string keyName) {

protected override void OnDamageReceived(IActor caster, long amount) {
LastDamageTick = Environment.TickCount64;
if (firstAttackerObjectId == 0 && caster is FieldPlayer) {
firstAttackerObjectId = caster.ObjectId;
}
lastAttackerObjectId = caster.ObjectId;
if (caster is FieldPlayer hitPlayer) {
DropHitLoot(hitPlayer);
}
}

protected override void OnDeath() {
WorldBossDeathCallback?.Invoke(this);
Owner?.Despawn(ObjectId);
SendControl = false;

SequenceCounter++;
Field.Broadcast(NpcControlPacket.Dead(this));

if (Value.Metadata.Corpse?.HitAble == true) {
IsCorpse = true;
lastCorpseBroadcastTick = Environment.TickCount64;
}

HandleDamageDealers();

Remove(delay: TimeSpan.FromSeconds(Value.Metadata.Dead.Time));
Expand DownExpand Up@@ -357,24 +384,97 @@ public void StopTalk() {
}
}

public void DropLoot(FieldPlayer firstPlayer) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
public override void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadataAttack attack) {
if (IsCorpse) {
// Corpse loot intentionally drops on every hit — players are expected to keep
// attacking the corpse to collect loot. A per-player rate limit (e.g. once per
// second) could be added here if spamming turns out to be an issue, but whether
// the original server enforced one is unknown.
if (caster is FieldPlayer player) {
DropCorpseLoot(player);
}
SequenceCounter++;
Field.Broadcast(NpcControlPacket.CorpseHit(this));
return;
}
Comment thread
AngeloTadeucci marked this conversation as resolved.
base.ApplyDamage(caster, damage, attack);
}

ICollection<Item> itemDrops = new List<Item>();
// Drop box semantics (confirmed via KMS2 video analysis):
// - GlobalDropBoxIds : drops spawned on death, shared (no per-player lock unless receiverCharacterId is set).
// - GlobalHitDropBoxIds : drops spawned each time the NPC is hit while alive (triggered in OnDamageReceived).
// - DeadGlobalDropBoxIds : drops spawned when a player hits the NPC corpse (IsCorpse == true).
// - IndividualDropBoxIds : per-player drops on death (each damage dealer gets their own).
// - IndividualHitDropBoxIds: per-player drops each time the NPC is hit while alive.
//
// NOTE on globalDropItemBox vs globalDropItemSet naming:
// NPC XML uses globalDropBoxId which references a DROP BOX (defined by dropBoxID in globalDropItemBox).
// Each drop box then references item GROUPs (defined by dropGroupID in globalDropItemSet).
// These are separate namespaces — e.g. drop BOX 4 (Doondun's death box) contains only mesos and
// CN-locale items, while item GROUP 4 ("boss equipment drop") is a completely different entity
// only reachable via drop BOX 10, which no NPC uses. Equipment drops for bosses come from
// IndividualDropBoxIds instead (keyed to the NPC id, e.g. individualDropBoxId="23000013" for Doondun).
private void DropGlobalLoot(long receiverCharacterId = 0) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalDropId in dropInfo.GlobalDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level)).ToList();
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: receiverCharacterId);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void DropHitLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int globalHitDropId in dropInfo.GlobalHitDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(globalHitDropId, Value.Metadata.Basic.Level));
}

foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this);
}

foreach (int individualHitDropId in dropInfo.IndividualHitDropBoxIds) {
foreach (Item item in Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualHitDropId)) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}
}

private void DropIndividualLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var individualDrops = new List<Item>();
foreach (int individualDropId in dropInfo.IndividualDropBoxIds) {
itemDrops = itemDrops.Concat(Field.ItemDrop.GetIndividualDropItems(firstPlayer.Session, Value.Metadata.Basic.Level, individualDropId)).ToList();
individualDrops.AddRange(Field.ItemDrop.GetIndividualDropItems(player.Session, player.Value.Character.Level, individualDropId));
}

foreach (Item item in itemDrops) {
float x = Random.Shared.Next((int) Position.X - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.X + Value.Metadata.DropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - Value.Metadata.DropInfo.DropDistanceRandom, (int) Position.Y + Value.Metadata.DropInfo.DropDistanceRandom);
var position = new Vector3(x, y, Position.Z);
foreach (Item item in individualDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

public void DropCorpseLoot(FieldPlayer player) {
NpcMetadataDropInfo dropInfo = Value.Metadata.DropInfo;
var globalDrops = new List<Item>();
foreach (int deadGlobalDropId in dropInfo.DeadGlobalDropBoxIds) {
globalDrops.AddRange(Field.ItemDrop.GetGlobalDropItems(deadGlobalDropId, Value.Metadata.Basic.Level));
}

Field.DropItem(position, Rotation, item, owner: this, characterId: firstPlayer.Value.Character.Id);
foreach (Item item in globalDrops) {
float x = Random.Shared.Next((int) Position.X - dropInfo.DropDistanceRandom, (int) Position.X + dropInfo.DropDistanceRandom);
float y = Random.Shared.Next((int) Position.Y - dropInfo.DropDistanceRandom, (int) Position.Y + dropInfo.DropDistanceRandom);
Field.DropItem(new Vector3(x, y, Position.Z), Rotation, item, owner: this, characterId: player.Value.Character.Id);
}
}

Expand All@@ -400,26 +500,43 @@ public NpcTask CastAiSkill(int id, short level, int faceTarget, Vector3 facePos,

// mob drops, exp, etc.
private void HandleDamageDealers() {
// TODO: Fix drop loot. Right now we're getting the first player in damage dealers as the receiver of the loot.
// How it should work is the person who instigated the first attack on the mob gets tagged. As long as the mob is in aggro, it stays on them, regardless if aggro changes.
// If the mob stops aggro to everyone, it resets this and heals/removes all damage records.
// Boss drop loot is different. They drop for everyone who did damage to them.
if (Value.IsBoss) {
// Boss drops: global items once (no receiver lock), individual items per dealer.
DropGlobalLoot();
foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

if (!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out FieldPlayer? firstPlayer)) {
return;
}
DropIndividualLoot(player);
GiveExp(player);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
} else {
// Regular mob: first attacker is tagged and receives all drops.
if (!Field.TryGetPlayer(firstAttackerObjectId, out FieldPlayer? taggedPlayer) &&
!Field.TryGetPlayer(DamageDealers.FirstOrDefault().Key, out taggedPlayer)) {
return;
}

DropLoot(firstPlayer);
GiveExp(player);
DropGlobalLoot(taggedPlayer!.Value.Character.Id);
DropIndividualLoot(taggedPlayer);

foreach (KeyValuePair<int, DamageRecordTarget> damageDealer in DamageDealers) {
if (!Field.TryGetPlayer(damageDealer.Key, out FieldPlayer? player)) {
continue;
}

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
GiveExp(player);

player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId);
foreach (string tag in Value.Metadata.Basic.MainTags) {
player.Session.ConditionUpdate(ConditionType.npc_race, codeString: tag);
}
}
}
}
Expand Down
Loading
Loading