Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 84
World Boss Corpse Handling#655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
f51d840d77cb06b648a196a75de40233f83b4d31ab5026632e4b794bf4a6a2521cd2964ee3ed0c31fcb99b8e33011dacdbFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| @@ -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; | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| base.Update(tickCount); | ||
| @@ -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)); | ||
| @@ -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; | ||
| } | ||
AngeloTadeucci marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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); | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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); | ||
| } | ||
| } | ||
| @@ -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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.