diff --git a/src/ai.rs b/src/ai.rs index daab0c59..37e83101 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -1085,6 +1085,8 @@ impl AINode for CheckAttack { npc.get_id(), target_ids, false, + (None, None), + (None, None), state, clients, )); diff --git a/src/bin/shard/combat.rs b/src/bin/shard/combat.rs index b9086dca..c7e73b46 100644 --- a/src/bin/shard/combat.rs +++ b/src/bin/shard/combat.rs @@ -1,14 +1,21 @@ +use std::time::SystemTime; + use rusty_fusion::{ - entity::{Combatant, Entity}, + defines::EQUIP_SLOT_HAND, + entity::{Combatant, Entity, EntityID, Projectile, ProjectileKind}, + enums::{CharType, WeaponTargetMode}, error::*, net::{ packet::{PacketID::*, *}, - ClientMap, + ClientMap, FFClient, }, skills, state::ShardServerState, + unused, Position, }; +const BATTERY_BASE_COST: u32 = 6; + #[allow(non_camel_case_types)] #[allow(non_snake_case)] #[repr(packed(4))] @@ -17,11 +24,91 @@ use rusty_fusion::{ struct sTargetNpcId { pub iNPC_ID: i32, } +#[allow(non_camel_case_types)] +#[allow(non_snake_case)] +#[repr(packed(4))] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +struct sTargetAnyId { + pub iEntity_ID: i32, + pub eCT: i32, +} + impl FFPacket for sTargetNpcId {} +impl FFPacket for sTargetAnyId {} + +fn get_targets( + client: &mut FFClient, + state: &mut ShardServerState, + target_count: usize, + target_type: Option, + max_targets: Option, +) -> FFResult<(Vec, u32)> { + let mut target_ids = Vec::with_capacity(max_targets.unwrap_or(3)); + let mut weapon_boosts_needed = 0; + + for i in 0..target_count { + // TODO stricter anti-cheat. + // validate target count, range, attack cooldown, etc against weapon stats + if max_targets.is_some_and(|max| i >= max) { + log( + Severity::Warning, + &format!( + "Tried to attack {} entities (max {})", + target_count, + max_targets.unwrap() + ), + ); + return Ok((target_ids, weapon_boosts_needed)); + } + + match target_type { + Some(CharType::Player) => todo!(), + None | Some(CharType::All) => { + let Ok(trailer) = client.get_struct::() else { + break; + }; + let entity_id = trailer.iEntity_ID; + let Ok(char_type) = CharType::try_from(trailer.eCT) else { + continue; + }; + + let entity_id = match char_type { + CharType::Unknown => continue, + CharType::Player => EntityID::Player(entity_id), + CharType::NPC | CharType::Mob => EntityID::NPC(entity_id), + CharType::All => continue, + }; + + let Ok(combatant) = state.get_combatant(entity_id) else { + continue; + }; + weapon_boosts_needed += BATTERY_BASE_COST + combatant.get_level() as u32; + target_ids.push(combatant.get_id()); + } + Some(_) => { + let Ok(trailer) = client.get_struct::() else { + break; + }; + let npc_id = trailer.iNPC_ID; + let npc = match state.get_npc(npc_id) { + Ok(npc) => npc, + Err(e) => { + log_error(&e); + continue; + } + }; + weapon_boosts_needed += BATTERY_BASE_COST + npc.get_level() as u32; + target_ids.push(npc.get_id()); + } + } + } + + Ok((target_ids, weapon_boosts_needed)) +} pub fn pc_attack_npcs(clients: &mut ClientMap, state: &mut ShardServerState) -> FFResult<()> { const MAX_TARGETS: usize = 3; - const BATTERY_BASE_COST: u32 = 6; let client = clients.get_self(); let pc_id = client.get_player_id()?; @@ -31,45 +118,194 @@ pub fn pc_attack_npcs(clients: &mut ClientMap, state: &mut ShardServerState) -> return Ok(()); } - let mut target_ids = Vec::with_capacity(MAX_TARGETS); - let mut weapon_boosts_needed = 0; - for i in 0..target_count { - // TODO stricter anti-cheat. - // validate target count, range, attack cooldown, etc against weapon stats - if i >= MAX_TARGETS { - return Err(FFError::build( - Severity::Warning, - format!( - "Player {} tried to attack {} NPCs (max {})", - pc_id, pkt.iNPCCnt, MAX_TARGETS - ), - )); - } - let npc_id = client.get_struct::()?.iNPC_ID; - let npc = match state.get_npc(npc_id) { - Ok(npc) => npc, - Err(e) => { - log_error(&e); - continue; - } - }; - weapon_boosts_needed += BATTERY_BASE_COST + npc.get_level() as u32; - target_ids.push(npc.get_id()); - } + let (target_ids, weapon_boosts_needed) = get_targets( + client, + state, + target_count, + Some(CharType::Mob), + Some(MAX_TARGETS), + )?; - // consume weapon boosts let player = state.get_player_mut(pc_id)?; - let weapon_boosts = player.get_weapon_boosts(); - let charged = if weapon_boosts >= weapon_boosts_needed { - player.set_weapon_boosts(weapon_boosts - weapon_boosts_needed); - true + let charged = player.consume_weapon_boosts(weapon_boosts_needed); + + // attack handler + skills::do_basic_attack( + player.get_id(), + &target_ids, + charged, + (None, None), + (None, None), + state, + clients, + )?; + + Ok(()) +} + +pub fn pc_fire_rocket(clients: &mut ClientMap, state: &mut ShardServerState) -> FFResult<()> { + let pkt: sP_CL2FE_REQ_PC_ROCKET_STYLE_FIRE = *clients + .get_self() + .get_packet(P_CL2FE_REQ_PC_ROCKET_STYLE_FIRE)?; + + let new_pkt = sP_CL2FE_REQ_PC_GRENADE_STYLE_FIRE { + iSkillID: pkt.iSkillID, + iToX: pkt.iToX, + iToY: pkt.iToY, + iToZ: pkt.iToZ, + }; + + pc_fire_projectile(clients, state, new_pkt, false) +} + +pub fn pc_fire_grenade(clients: &mut ClientMap, state: &mut ShardServerState) -> FFResult<()> { + let pkt: sP_CL2FE_REQ_PC_GRENADE_STYLE_FIRE = *clients + .get_self() + .get_packet(P_CL2FE_REQ_PC_GRENADE_STYLE_FIRE)?; + + pc_fire_projectile(clients, state, pkt, true) +} + +fn pc_fire_projectile( + clients: &mut ClientMap, + state: &mut ShardServerState, + pkt: sP_CL2FE_REQ_PC_GRENADE_STYLE_FIRE, + is_grenade: bool, +) -> FFResult<()> { + let client = clients.get_self(); + let pc_id = client.get_player_id()?; + let player = state.get_player_mut(pc_id)?; + + let action_message = if is_grenade { + "throw grenade" + } else { + "fire rocket" + }; + let expected_target_mode = if is_grenade { + WeaponTargetMode::Grenade } else { - player.set_weapon_boosts(0); - false + WeaponTargetMode::Rocket }; - // attack handler - skills::do_basic_attack(player.get_id(), &target_ids, charged, state, clients)?; + let Some(weapon) = player.get_equipped()[EQUIP_SLOT_HAND as usize] else { + return Err(FFError::build( + Severity::Warning, + format!("Tried to {} but no weapon in hand", action_message), + )); + }; + + let weapon_data = weapon.get_stats()?; + + if weapon_data.target_mode != Some(expected_target_mode) { + return Err(FFError::build( + Severity::Warning, + format!("Tried to {} but holding wrong weapon type", action_message), + )); + } + + let weapon_boosts_needed = BATTERY_BASE_COST + weapon_data.required_level as u32; + let charged = player.consume_weapon_boosts(weapon_boosts_needed); + + let projectile = Projectile { + projectile_kind: if is_grenade { + ProjectileKind::Grenade + } else { + ProjectileKind::Rocket(weapon.id) + }, + single_power: player.get_single_power(), + multi_power: player.get_multi_power(), + charged, + end_time: SystemTime::now() + weapon_data.projectile_time.unwrap(), + start_pos: Position { + z: player.get_position().z + 100, + ..player.get_position() + }, + // todo: validate + end_pos: Position { + x: pkt.iToX, + y: pkt.iToY, + z: pkt.iToZ + 100, + }, + }; + + let Some(bullet_id) = player.add_projectile(projectile.clone()) else { + return Err(FFError::build( + Severity::Warning, + format!( + "Attempted to {} but player has too many projectiles", + action_message + ), + )); + }; + + let resp = sP_FE2CL_REP_PC_GRENADE_STYLE_FIRE_SUCC { + iSkillID: unused!(), + iToX: projectile.end_pos.x, + iToY: projectile.end_pos.y, + iToZ: projectile.end_pos.z, + iBulletID: bullet_id, + Bullet: projectile.clone().into(), + iBatteryW: player.get_weapon_boosts() as i32, + bNanoDeactive: unused!(), + iNanoID: unused!(), + iNanoStamina: unused!(), + }; + + let bcast = sP_FE2CL_PC_GRENADE_STYLE_FIRE { + iPC_ID: pc_id, + iToX: pkt.iToX, + iToY: pkt.iToY, + iToZ: pkt.iToZ, + iBulletID: bullet_id, + Bullet: projectile.into(), + bNanoDeactive: unused!(), + }; + + state + .entity_map + .for_each_around(EntityID::Player(pc_id), clients, |c| { + c.send_packet(P_FE2CL_PC_GRENADE_STYLE_FIRE, &bcast) + }); + clients + .get_self() + .send_packet(P_FE2CL_REP_PC_GRENADE_STYLE_FIRE_SUCC, &resp) +} + +pub fn pc_projectile_hit(clients: &mut ClientMap, state: &mut ShardServerState) -> FFResult<()> { + const EXPLOSION_RADIUS: u32 = 300; // TODO pull from tdata + let client = clients.get_self(); + let pc_id = client.get_player_id()?; + let player = state.get_player_mut(pc_id)?; + + // Rocket and grenade hit are indentical + let pkt: sP_CL2FE_REQ_PC_ROCKET_STYLE_HIT = *client.get_packet_unchecked()?; + + let Some(projectile) = player.remove_projectile(pkt.iBulletID) else { + return Ok(()); + }; + + let (target_ids, _weapon_boosts_needed) = + get_targets(client, state, pkt.iTargetCnt as usize, None, Some(100))?; + let player = state.get_player(pc_id)?; + + // TODO: validate + let hit_position = Position::new(pkt.iX, pkt.iY, pkt.iZ); + let target_ids = state.entity_map.filter_ids_in_proximity( + hit_position, + player.instance_id, + &target_ids, + EXPLOSION_RADIUS, + ); + + skills::do_basic_attack( + EntityID::Player(pc_id), + &target_ids, + projectile.charged, + (Some(projectile.single_power), Some(projectile.multi_power)), + (Some(pkt.iBulletID), Some(projectile.clone())), + state, + clients, + )?; Ok(()) } diff --git a/src/bin/shard/main.rs b/src/bin/shard/main.rs index c9beda20..7239f59d 100644 --- a/src/bin/shard/main.rs +++ b/src/bin/shard/main.rs @@ -307,6 +307,10 @@ fn handle_packet( P_CL2FE_REQ_PC_AVATAR_EMOTES_CHAT => chat::pc_avatar_emotes_chat(&mut clients, state), // P_CL2FE_REQ_PC_ATTACK_NPCs => combat::pc_attack_npcs(&mut clients, state), + P_CL2FE_REQ_PC_GRENADE_STYLE_FIRE => combat::pc_fire_grenade(&mut clients, state), + P_CL2FE_REQ_PC_ROCKET_STYLE_FIRE => combat::pc_fire_rocket(&mut clients, state), + P_CL2FE_REQ_PC_ROCKET_STYLE_HIT => combat::pc_projectile_hit(&mut clients, state), + P_CL2FE_REQ_PC_GRENADE_STYLE_HIT => combat::pc_projectile_hit(&mut clients, state), // P_CL2FE_REQ_ITEM_MOVE => item::item_move(&mut clients, state), P_CL2FE_REQ_PC_ITEM_DELETE => item::item_delete(clients.get_self(), state), diff --git a/src/chunk.rs b/src/chunk.rs index 006d80a5..31182aef 100644 --- a/src/chunk.rs +++ b/src/chunk.rs @@ -287,6 +287,29 @@ impl EntityMap { Ok(()) } + pub fn filter_ids_in_proximity( + &self, + point: Position, + instance_id: InstanceID, + ids: &[EntityID], + range: u32, + ) -> Vec { + let mut within_range = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(entry) = self.registry.get(id) { + if entry.entity.get_chunk_coords().i != instance_id { + continue; + } + if entry.entity.get_position().distance_to(&point) > range { + continue; + } + + within_range.push(*id); + } + } + within_range + } + pub fn gen_next_pc_id(&mut self) -> i32 { let id = self.next_pc_id; if id == u32::MAX { diff --git a/src/entity/player.rs b/src/entity/player.rs index 1de3fe1a..44e976f5 100644 --- a/src/entity/player.rs +++ b/src/entity/player.rs @@ -284,6 +284,36 @@ pub struct PreWarpData { pub position: Position, } +#[derive(Debug, Clone)] +pub enum ProjectileKind { + Grenade, + Rocket(i16), +} + +#[derive(Debug, Clone)] +pub struct Projectile { + pub projectile_kind: ProjectileKind, + pub single_power: i32, + pub multi_power: i32, + pub charged: bool, + pub start_pos: Position, + pub end_pos: Position, + pub end_time: SystemTime, +} + +impl From for sPCBullet { + fn from(value: Projectile) -> Self { + Self { + eAT: 0, + iID: match value.projectile_kind { + ProjectileKind::Grenade => 1, + ProjectileKind::Rocket(item_id) => item_id as i32, + }, + bCharged: value.charged as i32, + } + } +} + #[derive(Debug, Clone)] pub struct BuddyListEntry { pub pc_uid: i64, @@ -428,6 +458,8 @@ pub struct Player { fusion_matter: u32, nano_potions: u32, weapon_boosts: u32, + owned_projectiles: HashMap, + last_projectile_id: i8, pub buddy_list_synced: bool, buddy_list: BuddyList, pub buddy_offered_to: Option, @@ -1211,6 +1243,35 @@ impl Player { self.nano_potions } + pub fn consume_weapon_boosts(&mut self, amount: u32) -> bool { + let weapon_boosts = self.get_weapon_boosts(); + if weapon_boosts >= amount { + self.set_weapon_boosts(weapon_boosts - amount); + true + } else { + self.set_weapon_boosts(0); + false + } + } + + pub fn add_projectile(&mut self, projectile: Projectile) -> Option { + if self.owned_projectiles.len() == SIZEOF_PC_BULLET_SLOT as usize { + return None; + } + let bullet_id = self.last_projectile_id; + self.last_projectile_id += 1; + if self.last_projectile_id == SIZEOF_PC_BULLET_SLOT as i8 { + self.last_projectile_id = 0 + }; + + self.owned_projectiles.insert(bullet_id, projectile); + Some(bullet_id) + } + + pub fn remove_projectile(&mut self, projectile_id: i8) -> Option { + self.owned_projectiles.remove(&projectile_id) + } + pub fn set_pre_warp(&mut self) { // we only save pre-warp when we're not in an instance if self.instance_id.instance_num.is_none() { diff --git a/src/enums.rs b/src/enums.rs index 388faca7..516e20a9 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -80,7 +80,8 @@ ffenum!(CharType, i32, 5, { Unknown = 0, Player = 1, NPC = 2, - Mob = 3, + Mob = 4, + All = 255, }); // eTaskTypeProperty.cs @@ -148,6 +149,17 @@ ffenum!(ItemType, i16, { SkillBuffEffect = 138, /*eItemType_SkillBuffEffect*/ }); +// eWeaponTargetMode.cs +ffenum!(WeaponTargetMode, i32, { + Hand = 0, + Melee = 1, + Pistol = 2, + Shotgun = 3, + Sniper = 4, + Rocket = 5, + Grenade = 6, +}); + // eTransportationType.cs ffenum!(TransportationType, i32, { /*eTT_None*/ diff --git a/src/item.rs b/src/item.rs index 310369ab..1e01d945 100644 --- a/src/item.rs +++ b/src/item.rs @@ -1,9 +1,12 @@ -use std::{cmp::min, time::SystemTime}; +use std::{ + cmp::min, + time::{Duration, SystemTime}, +}; use crate::{ defines::*, entity::RewardData, - enums::{ItemType, RewardCategory, RewardType}, + enums::{ItemType, RewardCategory, RewardType, WeaponTargetMode}, error::{panic_log, FFError, FFResult}, net::packet::*, tabledata::tdata_get, @@ -160,6 +163,8 @@ pub struct ItemStats { pub gender: Option, pub single_power: Option, pub multi_power: Option, + pub target_mode: Option, + pub projectile_time: Option, pub defense: Option, pub speed: Option, } diff --git a/src/lib.rs b/src/lib.rs index e2b6e7e1..f51cc8cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,10 @@ pub struct Position { pub z: i32, } impl Position { + pub fn new(x: i32, y: i32, z: i32) -> Self { + Self { x, y, z } + } + pub fn distance_to_weighted(&self, other: &Position, weights: (f32, f32, f32)) -> u32 { // scaling down for the multiplication helps to avoid overflow here const DIST_MATH_SCALE: f32 = 100.0; diff --git a/src/net/ffclient.rs b/src/net/ffclient.rs index 3d4c0f61..d8082c41 100644 --- a/src/net/ffclient.rs +++ b/src/net/ffclient.rs @@ -154,11 +154,11 @@ impl PacketBuffer { let from = self.ptr; let to = from + sz; if to > self.buf.len() { - panic_log(&format!( - "Payload too big for output ({} > {})", - sz, - self.buf.len() - )); + log( + Severity::Warning, + &format!("Payload too big for output ({} > {})", to, self.buf.len()), + ); + return; } self.buf[from..to].copy_from_slice(dat); @@ -309,6 +309,10 @@ impl FFClient { self.in_buf.get_packet(pkt_id) } + pub fn get_packet_unchecked(&mut self) -> FFResult<&T> { + self.in_buf.get_packet(self.peek_packet_id()?) + } + pub fn get_struct(&mut self) -> FFResult<&T> { self.in_buf.get_struct() } diff --git a/src/skills.rs b/src/skills.rs index c5c55e50..c3da43d5 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -2,7 +2,7 @@ use rand::Rng; use crate::{ defines::*, - entity::{Combatant, EntityID}, + entity::{Combatant, EntityID, Projectile}, enums::CombatStyle, error::*, net::{ @@ -23,6 +23,8 @@ pub fn do_basic_attack( attacker_id: EntityID, target_ids: &[EntityID], charged: bool, + powers: (Option, Option), + projectile: (Option, Option), state: &mut ShardServerState, clients: &mut ClientMap, ) -> FFResult<()> { @@ -32,9 +34,9 @@ pub fn do_basic_attack( let mut attacker_client = attacker.get_client(clients); let power = if target_ids.len() == 1 { - attacker.get_single_power() + powers.0.unwrap_or(attacker.get_single_power()) } else { - attacker.get_multi_power() + powers.1.unwrap_or(attacker.get_multi_power()) }; let basic_attack = BasicAttack { power, @@ -129,22 +131,32 @@ pub fn do_basic_attack( let mut payload = PacketBuffer::default(); match attacker_id { EntityID::Player(pc_id) => { - // response packet - let player = state.get_player(pc_id).unwrap(); - let resp = sP_FE2CL_PC_ATTACK_NPCs_SUCC { - iBatteryW: player.get_weapon_boosts() as i32, - iNPCCnt: npc_attack_count as i32, - }; - if let Some(client) = &mut attacker_client { - client.queue_packet(P_FE2CL_PC_ATTACK_NPCs_SUCC, &resp); - } + if projectile.0.is_some() && projectile.1.is_some() { + let resp = sP_FE2CL_PC_GRENADE_STYLE_HIT { + iPC_ID: pc_id, + iBulletID: projectile.0.unwrap(), + Bullet: projectile.1.unwrap().into(), + iTargetCnt: npc_attack_count as i32, + }; + payload.queue_packet(P_FE2CL_PC_GRENADE_STYLE_HIT, &resp); + } else { + // response packet + let player = state.get_player(pc_id).unwrap(); + let resp = sP_FE2CL_PC_ATTACK_NPCs_SUCC { + iBatteryW: player.get_weapon_boosts() as i32, + iNPCCnt: npc_attack_count as i32, + }; + if let Some(client) = &mut attacker_client { + client.queue_packet(P_FE2CL_PC_ATTACK_NPCs_SUCC, &resp); + } - // broadcast packet - let pkt = sP_FE2CL_PC_ATTACK_NPCs { - iPC_ID: pc_id, - iNPCCnt: npc_attack_count as i32, - }; - payload.queue_packet(P_FE2CL_PC_ATTACK_NPCs, &pkt); + // broadcast packet + let pkt = sP_FE2CL_PC_ATTACK_NPCs { + iPC_ID: pc_id, + iNPCCnt: npc_attack_count as i32, + }; + payload.queue_packet(P_FE2CL_PC_ATTACK_NPCs, &pkt); + } } EntityID::NPC(npc_id) => { let pkt = sP_FE2CL_NPC_ATTACK_CHARs { diff --git a/src/tabledata.rs b/src/tabledata.rs index 6735fc5a..86c1972e 100644 --- a/src/tabledata.rs +++ b/src/tabledata.rs @@ -994,6 +994,12 @@ fn load_item_data( gender: data.m_iReqSex.map(|v| v as i8), single_power: data.m_iPointRat, multi_power: data.m_iGroupRat, + target_mode: if let Some(v) = data.m_iTargetMode { + Some(v.try_into().map_err(|e: FFError| e.get_msg().to_string())?) + } else { + None + }, + projectile_time: data.m_iDeliverTime.map(|v| Duration::from_millis(v as u64)), defense: data.m_iDefenseRat, speed: data.m_iUp_runSpeed, };