diff --git a/agent_docs/combat.md b/agent_docs/combat.md index a66eef54..24e98d11 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -846,9 +846,11 @@ take 4/5 of the table amount plus up to 2/5 more (OrionAlpha's what scales them). A mob worth no EXP still drops and counts for quests. Quest drops are the entries the overrides tag with a quest, and quest items (`info/quest`) that only one -quest's completion check (`Check.img//1/item`) asks for. They roll -only while that quest is started and the player holds fewer than the check -asks for (any number if it doesn't name the item). Without a content pack, +quest's completion check (`Check.img//1/item`) asks for. For a +player out of a party they roll only while that quest is started and the +player holds fewer than the check asks for (any number if it doesn't name the +item); in a party they roll anyway, as Cosmic's do, for the members who need +them. Without a content pack, mobs drop only the overrides. The client actor rolls (it has the player's quests) and asks the field to place the loot. @@ -859,10 +861,18 @@ entering see lying drops (mode 2). Each drop lands on the map's footholds ground below its column, searched from 50 px above the mob as v83 servers do. A wall between the mob and the drop's column, or no ground under it, keeps the drop on the ground below the mob, so drops don't end up inside a cliff or -in the air. Quest drops are shown only to their owner. +in the air. A quest drop is shown only to the players on the map who still +need it (`net::loot::wants`): the field hands its packet to each occupant's +client actor as `ServerMessage::QuestDrop`, which checks the player's quests +and inventory, as Cosmic's `spawnDrop` does. Removals go to everyone; clients +ignore drops they never saw. For 15 s only the owner may pick a drop up, or with a party, the owner's -party as it was at the drop (quest drops: always only the owner). Drops of +party as it was at the drop; a player outside it who needs a quest drop +sees it but waits like anyone else. A quest drop is also refused to anyone +who doesn't need it, the owner included: the pickup carries a `QuestNeeds` +snapshot of the taker's started quests and the quest items they already hold +enough of. Drops of mobs with `publicReward` are anyone's at once, as are those of mobs with `explosiveReward`, which fly out 40 px apart instead of 25. The packet's ownership type tells the client which (v83 FUN_0050483a, whose pets follow diff --git a/agent_docs/server-content.md b/agent_docs/server-content.md index 00647670..33abb51c 100644 --- a/agent_docs/server-content.md +++ b/agent_docs/server-content.md @@ -41,8 +41,9 @@ tests. pack's drops rather than replace them: `like = ` borrows another reactor's drops (Amherst's box 2001 has none of its own, so it borrows 2000's), and `quest_items` makes items quest drops that always drop during - that quest, adding them if the pack lacks them. So Pio's materials for - quest 1008 drop from the boxes even without a pack. + that quest (for a player who still needs them, or anyone in a party), + adding them if the pack lacks them. So Pio's materials for quest 1008 drop + from the boxes even without a pack. - **Shops** (`ShopTable`): without `shops.json` no NPC keeps a shop, and shopkeepers fall back to their normal speech. diff --git a/integration-harness/tests/drops_and_pickup.rs b/integration-harness/tests/drops_and_pickup.rs index 5857e848..8ce83f02 100644 --- a/integration-harness/tests/drops_and_pickup.rs +++ b/integration-harness/tests/drops_and_pickup.rs @@ -1,15 +1,21 @@ //! Mob drops: rolling, ownership, pickup into the inventory or meso total, //! persistence, and quest-only drops. -use integration_harness::hunting::{attack, pickup, MapDrop}; +use integration_harness::hunting::{attack, pickup, walk_to, MapDrop}; use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::quests::{act, START}; use integration_harness::trace::{read_for, send, stat, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; const FOREST: i32 = 50_000; const SNAIL: i32 = 100100; const EAST_ENTRANCE: i32 = 30_000; const SENTINEL: i32 = 9300018; +const TODD: i32 = 2004; +const SHELLPIECE: i32 = 4031802; #[tokio::test] async fn snail_drops_go_to_the_killer_first_and_persist() { @@ -133,3 +139,91 @@ async fn the_shellpiece_only_drops_during_todds_quest() { // override) does not happen. assert!(seen.field.drops.is_empty(), "{:?}", seen.field.drops); } + +fn party_operation(op: u8) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::PartyOperation as i16).unwrap(); + p.write_byte(op).unwrap(); + p +} + +#[tokio::test] +async fn a_party_members_quest_drop_shows_only_to_the_one_who_needs_it() { + let hunter_config = load_harness_config_or_fail().await; + let helper_config = load_harness_config_or_fail().await; + let mut hunter = login_to_world_session(&hunter_config).await.unwrap(); + let mut helper = login_to_world_session(&helper_config).await.unwrap(); + let (mut h, mut p) = (Trace::default(), Trace::default()); + + // The helper invites the hunter into a party. + let mut invite = party_operation(4); + invite + .write_str_with_length(&hunter.character_name) + .unwrap(); + send(&mut helper.connection, invite).await; + until(&mut hunter.connection, &mut h, |t| { + !t.party_invites.is_empty() + }) + .await; + let mut accept = party_operation(3); + accept.write_int(h.party_invites[0].0).unwrap(); + send(&mut hunter.connection, accept).await; + until(&mut helper.connection, &mut p, |t| { + t.party_ops.contains(&0x0F) + }) + .await; + + // Only the helper is on Todd's hunt. + assert_eq!( + warp(&mut helper.connection, EAST_ENTRANCE, "", &mut p).await, + EAST_ENTRANCE + ); + let marker = helper_config.character_name.clone(); + act(&mut helper.connection, &mut p, &marker, START, 1035, TODD).await; + assert_eq!(p.states.get(&1035), Some(&1)); + assert_eq!( + warp(&mut hunter.connection, EAST_ENTRANCE, "", &mut h).await, + EAST_ENTRANCE + ); + until(&mut hunter.connection, &mut h, |t| { + !t.field.alive(SENTINEL).is_empty() + }) + .await; + + // The hunter's kill drops the shellpiece for the party, but only the + // helper, who needs it, sees it. + let sentinel = h.field.alive(SENTINEL)[0]; + send(&mut hunter.connection, attack(sentinel, 8)).await; + until(&mut helper.connection, &mut p, |t| { + t.field.item_drop(SHELLPIECE).is_some() + }) + .await; + read_for(&mut hunter.connection, &mut h, 500).await; + assert!(h.field.killed.contains(&sentinel)); + assert!( + h.field.item_drop(SHELLPIECE).is_none(), + "{:?}", + h.field.drops + ); + + // The hunter can't take it; the helper can. + let shellpiece = p.field.item_drop(SHELLPIECE).unwrap(); + send(&mut hunter.connection, walk_to(shellpiece.x, shellpiece.y)).await; + send( + &mut hunter.connection, + pickup(shellpiece.object_id, shellpiece.x, shellpiece.y), + ) + .await; + read_for(&mut helper.connection, &mut p, 500).await; + assert!(p.field.removals(shellpiece.object_id).is_empty()); + send(&mut helper.connection, walk_to(shellpiece.x, shellpiece.y)).await; + send( + &mut helper.connection, + pickup(shellpiece.object_id, shellpiece.x, shellpiece.y), + ) + .await; + until(&mut helper.connection, &mut p, |t| { + t.added.iter().any(|added| added.2 == SHELLPIECE) + }) + .await; +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 9a2c304b..682b09c2 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -676,7 +676,13 @@ pub enum FieldRequest { Kite { item_id: i32, message: String }, /// Pick up the drop `object_id`, answered with `ServerMessage::PickedUp`; /// the player stands at (x, y). - Pickup { object_id: i32, x: i16, y: i16 }, + Pickup { + object_id: i32, + x: i16, + y: i16, + /// The quest drops the player may take. + needs: crate::loot::QuestNeeds, + }, /// Throw `loot` down where the player stands; anyone may pick it up at /// once. With `vanish` it falls and fades instead (quest items). Throw { @@ -745,6 +751,8 @@ pub enum FieldRequest { mesos: bool, items: bool, ignored: Vec, + /// The quest drops the player may take. + needs: crate::loot::QuestNeeds, }, /// The player's client moved their pet at `index` in their pet list. PetMove { diff --git a/net/src/loot.rs b/net/src/loot.rs index 63b17f64..76da5525 100644 --- a/net/src/loot.rs +++ b/net/src/loot.rs @@ -14,7 +14,8 @@ pub enum Loot { Item { item_id: i32, quantity: i16, - /// Only for its owner, while they have this quest started. + /// Seen and taken only by those who still need it for this quest + /// (`QuestNeeds`). quest: Option, }, Mesos(i32), @@ -25,9 +26,18 @@ pub enum Loot { impl Loot { pub fn quest(&self) -> Option { + self.quest_item().map(|(quest, _)| quest) + } + + /// The quest a quest drop is for, and its item. + pub fn quest_item(&self) -> Option<(i16, i32)> { match self { - Loot::Item { quest, .. } => *quest, - Loot::Mesos(_) | Loot::Dropped(_) => None, + Loot::Item { + item_id, + quest: Some(quest), + .. + } => Some((*quest, *item_id)), + Loot::Item { .. } | Loot::Mesos(_) | Loot::Dropped(_) => None, } } @@ -45,18 +55,20 @@ impl Loot { /// Roll one mob's drops for `player` at `now`, each `showdown` percent more /// likely (Taunt's) and scaled by the world's and their cards' rates -/// (`crate::rates`), then its Monster Book card. +/// (`crate::rates`), then its Monster Book card. `in_party` rolls quest +/// drops the player doesn't need too, for their party. pub fn roll( mob_id: i32, showdown: i32, player: &Player, + in_party: bool, now: SystemTime, rng: &mut impl Rng, ) -> Vec { let rate = f64::from(100 + showdown) / 100.0 * crate::rates::drop(player, now); let mesos = crate::rates::meso(player, now); let entries = crate::content::rewards().drops(mob_id); - let mut loot = roll_at(entries, (rate, mesos), player, rng); + let mut loot = roll_at(entries, (rate, mesos), player, in_party, rng); if let Some(card) = crate::monster_book::card_for(mob_id) { if rng.gen_bool(crate::monster_book::drop_rate()) { loot.push(Loot::Item { @@ -69,17 +81,25 @@ pub fn roll( loot } -/// Roll one broken reactor's drops for `player`. -pub fn roll_reactor(reactor_id: i32, player: &Player, rng: &mut impl Rng) -> Vec { - roll_entries( +/// Roll one broken reactor's drops for `player` (`in_party` as for +/// `roll`). +pub fn roll_reactor( + reactor_id: i32, + player: &Player, + in_party: bool, + rng: &mut impl Rng, +) -> Vec { + roll_at( crate::content::rewards().reactor_drops(reactor_id), + (1.0, 1.0), player, + in_party, rng, ) } pub fn roll_entries(entries: &[RewardEntry], player: &Player, rng: &mut impl Rng) -> Vec { - roll_at(entries, (1.0, 1.0), player, rng) + roll_at(entries, (1.0, 1.0), player, false, rng) } /// `roll_entries`, with every chance times `rate` and every meso amount @@ -88,21 +108,23 @@ fn roll_at( entries: &[RewardEntry], (rate, mesos): (f64, f64), player: &Player, + in_party: bool, rng: &mut impl Rng, ) -> Vec { let mut loot = Vec::new(); for entry in entries { // Quest drops only while the quest is started and the player holds - // fewer than it needs. + // fewer than it needs. In a party quest items roll anyway, as + // Cosmic's do, for the members who need them: only those who do see + // them (`QuestNeeds`). let quest = entry.quest.or_else(|| entry.item.and_then(quest_for)); if let Some(quest) = quest { - if player.quests.state(quest) != QuestState::Started { - continue; - } - if entry + let needed = entry .item - .is_some_and(|item| player.inventory.count(item) >= needed(quest, item)) - { + .map_or(player.quests.state(quest) == QuestState::Started, |item| { + wants(player, quest, item) + }); + if !needed && !(in_party && entry.item.is_some()) { continue; } } @@ -206,6 +228,54 @@ fn quest_for(item: i32) -> Option { asked_by.get(&item).copied().flatten() } +/// Whether `player` still needs `item` for `quest`: it is started and they +/// hold fewer than its completion asks for (Cosmic's `needQuestItem`). +pub fn wants(player: &Player, quest: i16, item: i32) -> bool { + player.quests.state(quest) == QuestState::Started + && player.inventory.count(item) < needed(quest, item) +} + +/// Which quest drops a player still needs, as `wants` has it, taken when +/// they reach for one: the field decides with it who may take a quest +/// drop. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestNeeds { + /// The quests the player has started. + pub started: Vec, + /// Items of those quests the player holds enough of. + pub enough: Vec<(i16, i32)>, +} + +impl QuestNeeds { + pub fn of(player: &Player) -> Self { + let started: Vec = player + .quests + .started() + .map(|record| record.quest_id) + .collect(); + let enough = started + .iter() + .filter_map(|&quest| { + let catalog = crate::quests::catalog().ok()?; + let items = &catalog.quest(quest)?.end_items; + Some( + items + .iter() + .filter(|&&(item, count)| player.inventory.count(item) >= count) + .map(move |&(item, _)| (quest, item)) + .collect::>(), + ) + }) + .flatten() + .collect(); + QuestNeeds { started, enough } + } + + pub fn wants(&self, quest: i16, item: i32) -> bool { + self.started.contains(&quest) && !self.enough.contains(&(quest, item)) + } +} + /// How many of `item` completing `quest` takes: any number when the quest /// doesn't ask for it. fn needed(quest: i16, item: i32) -> i32 { @@ -287,6 +357,41 @@ mod tests { assert!(roll_entries(&shellpiece, &player, &mut rng).is_empty()); } + #[test] + fn a_party_rolls_quest_items_and_their_takers_need_them() { + let mut rng = rand::rngs::StdRng::seed_from_u64(1); + let table = [ + entry(Some(4031802), None, 1.0, Some(1035)), + entry(None, Some(10), 1.0, Some(1035)), + ]; + let mut player = crate::codec::stats::sample_player(); + let rolled = roll_at(&table, (1.0, 1.0), &player, true, &mut rng); + assert_eq!( + rolled, + [Loot::Item { + item_id: 4031802, + quantity: 1, + quest: Some(1035) + }] + ); + let agree = |player: &Player| { + let needs = QuestNeeds::of(player); + assert_eq!(needs.wants(1035, 4031802), wants(player, 1035, 4031802)); + needs.wants(1035, 4031802) + }; + assert!(!agree(&player)); + player.quests.upsert(QuestRecord { + quest_id: 1035, + status: QuestStatus::Started { kills: Vec::new() }, + }); + assert!(agree(&player)); + player + .inventory + .add(game::Item::stack(4031802, 1), 100) + .unwrap(); + assert!(!agree(&player)); + } + #[test] fn quest_drops_continue_until_the_quest_has_enough() { let mut rng = rand::rngs::StdRng::seed_from_u64(4); @@ -362,7 +467,10 @@ mod tests { let half = [entry(Some(4000019), None, 0.5, None)]; // Twice as likely: always. for _ in 0..50 { - assert_eq!(roll_at(&half, (2.0, 1.0), &player, &mut rng).len(), 1); + assert_eq!( + roll_at(&half, (2.0, 1.0), &player, false, &mut rng).len(), + 1 + ); } } @@ -372,7 +480,8 @@ mod tests { let player = crate::codec::stats::sample_player(); let mesos = [entry(None, Some(100), 1.0, None)]; for _ in 0..50 { - let [Loot::Mesos(amount)] = roll_at(&mesos, (1.0, 2.0), &player, &mut rng)[..] else { + let [Loot::Mesos(amount)] = roll_at(&mesos, (1.0, 2.0), &player, false, &mut rng)[..] + else { panic!("one meso drop"); }; assert!((160..=240).contains(&amount) && amount % 2 == 0); diff --git a/net/src/packet/handle/world/pet.rs b/net/src/packet/handle/world/pet.rs index fcd0a2c7..39389b3f 100644 --- a/net/src/packet/handle/world/pet.rs +++ b/net/src/packet/handle/world/pet.rs @@ -162,6 +162,7 @@ impl WorldHandler for PetLootHandler { mesos, items, ignored, + needs: crate::loot::QuestNeeds::of(player), })), ) } diff --git a/net/src/packet/handle/world/pickup.rs b/net/src/packet/handle/world/pickup.rs index 4a619381..de47b564 100644 --- a/net/src/packet/handle/world/pickup.rs +++ b/net/src/packet/handle/world/pickup.rs @@ -24,6 +24,7 @@ impl WorldHandler for ItemPickupHandler { object_id, x, y, + needs: crate::loot::QuestNeeds::of(ctx.player), })), ) } diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 7b66e161..895c49a6 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1310,6 +1310,16 @@ impl ClientActor { ServerMessage::Trade { room, event } => self.trade_event(room, event).await?, ServerMessage::PersonalShop { room, event } => self.shop_event(room, event).await?, ServerMessage::SendPacket(mut packet) => self.send(&mut packet).await?, + ServerMessage::QuestDrop { + quest, + item_id, + mut packet, + } => { + let player = &self.in_game()?.player; + if net::loot::wants(player, quest, item_id) { + self.send(&mut packet).await?; + } + } } Ok(()) } diff --git a/runtime/src/actor/client/loot.rs b/runtime/src/actor/client/loot.rs index 46149873..6fd9d096 100644 --- a/runtime/src/actor/client/loot.rs +++ b/runtime/src/actor/client/loot.rs @@ -26,7 +26,15 @@ impl ClientActor { ) -> Result<(), RuntimeError> { let player = &self.in_game()?.player; let now = std::time::SystemTime::now(); - let loot = loot::roll(mob_id, showdown, player, now, &mut rand::thread_rng()); + let in_party = self.party_id.is_some(); + let loot = loot::roll( + mob_id, + showdown, + player, + in_party, + now, + &mut rand::thread_rng(), + ); let ownership = match net::mobs::info(mob_id) { Some(mob) if mob.explosive_reward => Ownership::Explosive, Some(mob) if mob.public_reward => Ownership::FreeForAll, @@ -84,7 +92,9 @@ impl ClientActor { } return Ok(()); } - let loot = loot::roll_reactor(reactor_id, &self.in_game()?.player, &mut rand::thread_rng()); + let in_party = self.party_id.is_some(); + let player = &self.in_game()?.player; + let loot = loot::roll_reactor(reactor_id, player, in_party, &mut rand::thread_rng()); self.spawn_loot(loot, self.ownership(), object_id, x, y) .await } diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index ad9a0212..08d3b2d8 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -545,9 +545,12 @@ impl FieldActor { FieldRequest::Chair(item_id) => self.handle_chair(from, item_id), FieldRequest::Weather { item_id, message } => self.blow_weather(item_id, message), FieldRequest::Kite { item_id, message } => self.fly_kite(from, item_id, message), - FieldRequest::Pickup { object_id, x, y } => { - self.handle_pickup(from, object_id, x, y) - } + FieldRequest::Pickup { + object_id, + x, + y, + needs, + } => self.handle_pickup(from, object_id, x, y, &needs), FieldRequest::Throw { loot, vanish } => self.handle_throw(from, loot, vanish), FieldRequest::HitReactor { object_id, @@ -601,6 +604,7 @@ impl FieldActor { mesos, items, ignored, + needs, } => { let now = Instant::now(); let party = self.occupants.get(&from).and_then(|o| o.party); @@ -612,6 +616,7 @@ impl FieldActor { now, (mesos, items), &ignored, + &needs, ); if let (Ok(drop), Some(occupant)) = (taken, self.occupants.get(&from)) { occupant @@ -648,13 +653,13 @@ impl FieldActor { if !taken { self.drops.restore(drop); } else if let Ok(packet) = build_pet_loot_drop(drop.object_id, looter, pet) { - self.send_drop_packet(&drop, packet); + self.send_drop_packet(packet); } } FieldMessage::ReturnDrop { drop } => { let now = Instant::now(); if let Ok(packet) = build_drop(&drop.view(now), DropAppearance::Lying) { - self.send_drop_packet(&drop, packet); + self.show_drop(&drop, packet); } self.drops.restore(drop); } @@ -810,7 +815,7 @@ impl FieldActor { }) .collect(); for (drop, packet) in packets { - self.send_drop_packet(&drop, packet); + self.show_drop(&drop, packet); } } @@ -834,7 +839,7 @@ impl FieldActor { }; if let Ok(packet) = build_drop(&drop.view(now), falling) { let drop = drop.clone(); - self.send_drop_packet(&drop, packet); + self.show_drop(&drop, packet); } } } @@ -911,7 +916,7 @@ impl FieldActor { (drop, falling) }; if let Ok(packet) = build_drop(&drop.view(now), appearance) { - self.send_drop_packet(&drop, packet); + self.show_drop(&drop, packet); } } @@ -963,7 +968,7 @@ impl FieldActor { }; if let Some(drop) = self.drops.remove(on.drop) { if let Ok(packet) = build_remove_drop(drop.object_id, None) { - self.send_drop_packet(&drop, packet); + self.send_drop_packet(packet); } } self.show_reactor_hit(owner, hit, 0); @@ -976,27 +981,41 @@ impl FieldActor { true } - /// Send a drop packet to everyone who may see the drop. - fn send_drop_packet(&self, drop: &Drop, packet: Packet) { - for (&client, occupant) in &self.occupants { - if drop.visible_to(client) { - occupant.handle.send_packet(packet.clone()); - } + /// Send a drop packet to everyone here. Those who don't see a quest + /// drop ignore it going away. + fn send_drop_packet(&self, packet: Packet) { + for occupant in self.occupants.values() { + occupant.handle.send_packet(packet.clone()); } } - fn handle_pickup(&mut self, from: ClientId, object_id: i32, x: i16, y: i16) { + /// Show a drop to everyone here who may see it: a quest drop only to + /// those who still need it, which their client actors decide. + fn show_drop(&self, drop: &Drop, packet: Packet) { + for occupant in self.occupants.values() { + show_drop_to(&occupant.handle, drop, packet.clone()); + } + } + + fn handle_pickup( + &mut self, + from: ClientId, + object_id: i32, + x: i16, + y: i16, + needs: &net::loot::QuestNeeds, + ) { let Some(occupant) = self.occupants.get(&from) else { return; }; let handle = occupant.handle.clone(); match self .drops - .take(from, occupant.party, object_id, x, y, Instant::now()) + .take(from, occupant.party, object_id, x, y, Instant::now(), needs) { Ok(drop) => { if let Ok(packet) = build_remove_drop(object_id, Some(from)) { - self.send_drop_packet(&drop, packet); + self.send_drop_packet(packet); } handle.send(ServerMessage::PickedUp { drop }); } @@ -1249,10 +1268,10 @@ impl FieldActor { let mut mesos = Vec::new(); for (index, &object_id) in blast.drops.iter().enumerate() { let drop = self.drops.explode(from, party, object_id, reaches, now); - if let Some(drop) = &drop { + if drop.is_some() { let delay = meso_explosion::delay(blast.delay, index); if let Ok(packet) = build_explode_drop(object_id, delay) { - self.send_drop_packet(drop, packet); + self.send_drop_packet(packet); } } mesos.push(drop.and_then(|drop| match drop.loot { @@ -2314,9 +2333,9 @@ impl FieldActor { } let now = Instant::now(); - for drop in self.drops.all().filter(|drop| drop.visible_to(client_id)) { + for drop in self.drops.all() { if let Ok(packet) = build_drop(&drop.view(now), DropAppearance::Lying) { - handle.send_packet(packet); + show_drop_to(&handle, drop, packet); } } @@ -2490,6 +2509,23 @@ impl FieldActor { } /// Whether `viewer` sees `avatar`: a hidden GM shows only to GMs. +/// Show `drop` to one player: a quest drop goes through their client actor, +/// which shows it only if they still need it. +fn show_drop_to(handle: &ClientHandle, drop: &Drop, packet: Packet) { + match drop.quest_item() { + Some((quest, item_id)) => { + handle.send(ServerMessage::QuestDrop { + quest, + item_id, + packet, + }); + } + None => { + handle.send_packet(packet); + } + } +} + fn sees(viewer: &FieldAvatar, avatar: &FieldAvatar) -> bool { !avatar.hidden || viewer.gm } diff --git a/runtime/src/actor/field_drops.rs b/runtime/src/actor/field_drops.rs index b46237a0..8b3129bc 100644 --- a/runtime/src/actor/field_drops.rs +++ b/runtime/src/actor/field_drops.rs @@ -4,7 +4,7 @@ use crate::actor::object_ids::{self, ObjectIds}; use game::pet::IGNORED_MESOS; use game_data::footholds::Footholds; -use net::loot::Loot; +use net::loot::{Loot, QuestNeeds}; use net::packet::build::world::drop::DropView; use net::ClientId; use std::collections::BTreeMap; @@ -77,9 +77,10 @@ impl Drop { } } - /// Who may see it: quest items only show to their owner. - pub fn visible_to(&self, client: ClientId) -> bool { - self.loot.quest().is_none() || self.owner == client + /// The quest a quest drop is for, and its item: only players who still + /// need it see it (Cosmic's `spawnDrop`). + pub fn quest_item(&self) -> Option<(i16, i32)> { + self.loot.quest_item() } /// Still kept for its owner or their party. @@ -87,15 +88,25 @@ impl Drop { !self.player_drop && now < self.dropped_at + OWNERSHIP } - /// Whether `client`, in `party`, may pick it up: quest items only their - /// owner, others whoever its ownership allows until it runs out. - fn takeable_by(&self, client: ClientId, party: Option, now: Instant) -> bool { + /// Whether `client`, in `party`, may pick it up: whoever its ownership + /// allows until it runs out, and quest drops only if they still need + /// them (`needs`). + fn takeable_by( + &self, + client: ClientId, + party: Option, + needs: &QuestNeeds, + now: Instant, + ) -> bool { + if self + .quest_item() + .is_some_and(|(quest, item)| !needs.wants(quest, item)) + { + return false; + } if self.owner == client { return true; } - if self.loot.quest().is_some() { - return false; - } match self.ownership { Ownership::Owner => !self.owned(now), Ownership::Party(owners) => party == Some(owners) || !self.owned(now), @@ -251,6 +262,7 @@ impl DropSet { } /// Take a drop for `client`, in `party`, standing at (x, y). + #[allow(clippy::too_many_arguments)] pub fn take( &mut self, client: ClientId, @@ -259,6 +271,7 @@ impl DropSet { x: i16, y: i16, now: Instant, + needs: &QuestNeeds, ) -> Result { let drop = self.drops.get(&object_id).ok_or(PickupError::Gone)?; if drop.loot.blocked() { @@ -269,7 +282,7 @@ impl DropSet { { return Err(PickupError::TooFar); } - if !drop.takeable_by(client, party, now) { + if !drop.takeable_by(client, party, needs, now) { return Err(PickupError::NotYours); } self.drops.remove(&object_id).ok_or(PickupError::Gone) @@ -279,6 +292,7 @@ impl DropSet { /// mesos and items as `takes` says, but not the ids `ignored` (mesos /// as `IGNORED_MESOS`). As in v83 (FUN_0050483a), pets leave what /// players threw away. + #[allow(clippy::too_many_arguments)] pub fn take_for_pet( &mut self, client: ClientId, @@ -288,6 +302,7 @@ impl DropSet { now: Instant, (mesos, items): (bool, bool), ignored: &[i32], + needs: &QuestNeeds, ) -> Result { let drop = self.drops.get(&object_id).ok_or(PickupError::Gone)?; let (wanted, id) = match &drop.loot { @@ -298,7 +313,7 @@ impl DropSet { if drop.player_drop || !wanted || ignored.contains(&id) { return Err(PickupError::NotForPets); } - self.take(client, party, object_id, x, y, now) + self.take(client, party, object_id, x, y, now, needs) } /// Set off mesos for `client`, in `party`, with Meso Explosion: the @@ -313,7 +328,10 @@ impl DropSet { ) -> Option { let drop = self.drops.get(&object_id)?; let mesos = matches!(drop.loot, Loot::Mesos(_)); - if !mesos || !drop.takeable_by(client, party, now) || !reaches(drop.x, drop.y) { + if !mesos + || !drop.takeable_by(client, party, &QuestNeeds::default(), now) + || !reaches(drop.x, drop.y) + { return None; } self.drops.remove(&object_id) @@ -382,22 +400,41 @@ mod tests { assert_eq!(ids.len(), 2); assert_eq!(drops.all().map(|d| d.x).collect::>(), [88, 112]); assert_eq!( - drops.take(2, None, ids[0], 100, 0, now).unwrap_err(), + drops + .take(2, None, ids[0], 100, 0, now, &QuestNeeds::default()) + .unwrap_err(), PickupError::NotYours ); assert_eq!( - drops.take(1, None, ids[0], 2000, 0, now).unwrap_err(), + drops + .take(1, None, ids[0], 2000, 0, now, &QuestNeeds::default()) + .unwrap_err(), PickupError::TooFar ); assert_eq!( - drops.take(1, None, ids[0], 100, 0, now).unwrap().loot, + drops + .take(1, None, ids[0], 100, 0, now, &QuestNeeds::default()) + .unwrap() + .loot, Loot::Mesos(5) ); assert_eq!( - drops.take(1, None, ids[0], 100, 0, now).unwrap_err(), + drops + .take(1, None, ids[0], 100, 0, now, &QuestNeeds::default()) + .unwrap_err(), PickupError::Gone ); - assert!(drops.take(2, None, ids[1], 100, 0, now + OWNERSHIP).is_ok()); + assert!(drops + .take( + 2, + None, + ids[1], + 100, + 0, + now + OWNERSHIP, + &QuestNeeds::default() + ) + .is_ok()); } #[test] @@ -429,20 +466,47 @@ mod tests { // A meso magnet alone leaves items, and no pet takes what was thrown. assert_eq!( drops - .take_for_pet(1, None, ids[1], at, now, (true, false), &[]) + .take_for_pet( + 1, + None, + ids[1], + at, + now, + (true, false), + &[], + &QuestNeeds::default() + ) .unwrap_err(), PickupError::NotForPets ); assert_eq!( drops - .take_for_pet(1, None, thrown, at, now, (true, true), &[]) + .take_for_pet( + 1, + None, + thrown, + at, + now, + (true, true), + &[], + &QuestNeeds::default() + ) .unwrap_err(), PickupError::NotForPets ); // Someone else's pet waits for the owner's time to pass. assert_eq!( drops - .take_for_pet(2, None, ids[0], at, now, (true, true), &[]) + .take_for_pet( + 2, + None, + ids[0], + at, + now, + (true, true), + &[], + &QuestNeeds::default() + ) .unwrap_err(), PickupError::NotYours ); @@ -450,13 +514,31 @@ mod tests { for (id, ignored) in [(ids[0], IGNORED_MESOS), (ids[1], 4000019)] { assert_eq!( drops - .take_for_pet(1, None, id, at, now, (true, true), &[ignored]) + .take_for_pet( + 1, + None, + id, + at, + now, + (true, true), + &[ignored], + &QuestNeeds::default() + ) .unwrap_err(), PickupError::NotForPets ); } let drop = drops - .take_for_pet(1, None, ids[0], at, now, (true, false), &[4000019]) + .take_for_pet( + 1, + None, + ids[0], + at, + now, + (true, false), + &[4000019], + &QuestNeeds::default(), + ) .unwrap(); assert_eq!(drop.loot, Loot::Mesos(5)); } @@ -475,15 +557,21 @@ mod tests { assert_eq!(view(&drops, party, now), (7, 1)); assert_eq!(view(&drops, party, now + OWNERSHIP), (1, 2)); assert_eq!( - drops.take(2, Some(8), party, 0, 0, now).unwrap_err(), + drops + .take(2, Some(8), party, 0, 0, now, &QuestNeeds::default()) + .unwrap_err(), PickupError::NotYours ); - assert!(drops.take(2, Some(7), party, 0, 0, now).is_ok()); + assert!(drops + .take(2, Some(7), party, 0, 0, now, &QuestNeeds::default()) + .is_ok()); let public = drops.spawn(vec![Loot::Mesos(5)], 1, Ownership::FreeForAll, 0, 0, 9, now)[0].object_id; assert_eq!(view(&drops, public, now), (1, 2)); - assert!(drops.take(2, None, public, 0, 0, now).is_ok()); + assert!(drops + .take(2, None, public, 0, 0, now, &QuestNeeds::default()) + .is_ok()); // Flung wider than the usual 25 apart. let flung: Vec<(i32, i16)> = drops @@ -501,7 +589,9 @@ mod tests { .collect(); assert_eq!(flung.iter().map(|&(_, x)| x).collect::>(), [-20, 20]); assert_eq!(view(&drops, flung[0].0, now), (1, 3)); - assert!(drops.take(2, None, flung[0].0, 0, 0, now).is_ok()); + assert!(drops + .take(2, None, flung[0].0, 0, 0, now, &QuestNeeds::default()) + .is_ok()); } #[test] @@ -517,13 +607,16 @@ mod tests { (2, 0, true) ); assert_eq!( - drops.take(2, None, id, 40, 7, now).unwrap().loot, + drops + .take(2, None, id, 40, 7, now, &QuestNeeds::default()) + .unwrap() + .loot, Loot::Dropped(item) ); } #[test] - fn quest_items_stay_with_their_owner_and_drops_expire() { + fn quest_drops_go_to_whoever_needs_them_and_drops_expire() { let now = Instant::now(); let mut drops = DropSet::default(); let id = drops.spawn( @@ -533,7 +626,7 @@ mod tests { quest: Some(1035), }], 1, - Ownership::Owner, + Ownership::Party(7), 0, 0, 9, @@ -541,13 +634,26 @@ mod tests { )[0] .object_id; let drop = drops.all().next().unwrap(); - assert!(drop.visible_to(1) && !drop.visible_to(2)); - assert_eq!( - drops - .take(2, None, id, 0, 0, now + OWNERSHIP * 2) - .unwrap_err(), - PickupError::NotYours - ); + assert_eq!(drop.quest_item(), Some((1035, 4031802))); + let needing = QuestNeeds { + started: vec![1035], + enough: Vec::new(), + }; + let done = QuestNeeds { + started: vec![1035], + enough: vec![(1035, 4031802)], + }; + // Not even its owner takes it once they hold enough; outside the + // party only after the 15 s. + for (client, party, needs) in [(1, Some(7), &done), (3, Some(8), &needing)] { + assert_eq!( + drops.take(client, party, id, 0, 0, now, needs).unwrap_err(), + PickupError::NotYours + ); + } + assert!(drops.take(2, Some(7), id, 0, 0, now, &needing).is_ok()); + + let id = drops.spawn(vec![Loot::Mesos(5)], 1, Ownership::Owner, 0, 0, 9, now)[0].object_id; assert_eq!(drops.expire(now + LIFETIME), [id]); assert_eq!(drops.all().count(), 0); } diff --git a/runtime/src/message.rs b/runtime/src/message.rs index dae2f988..3ed3cf02 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -254,6 +254,13 @@ pub enum ServerMessage { stage: u8, }, SendPacket(Packet), + /// A quest drop's packet, which the player sees only if they still need + /// `item_id` for `quest` (`net::loot::wants`). + QuestDrop { + quest: i16, + item_id: i32, + packet: Packet, + }, } /// A cheap, clonable way to reach one client connection.