diff --git a/agent_docs/map-travel.md b/agent_docs/map-travel.md index 90444822c..6dab0fe17 100644 --- a/agent_docs/map-travel.md +++ b/agent_docs/map-travel.md @@ -214,16 +214,19 @@ it for the player, who goes in with the portal sound. The party members who were beside the leader follow to the copy's `out00` once the leader is in (`ServerMessage::ShipWarp`). A copy's `out00`, like `MD00` anywhere but the base map, leads back to the base map's `MD00`. When the player who took the copy -leaves it (by map change, logout, logging in again or channel change), -everyone still in it is sent to the base map's portal 0. An empty copy puts its mobs back, as a +leaves it (by map change, logout or logging in again), +everyone still in it is sent to the base map's portal 0. If the leader +hands the party to a member in the copy, the copy goes with it; if the new +leader is anywhere else, the run ends the same way, as in Cosmic +(`WorldDirectory::dungeon_leader_changed`). Nobody in a copy may change channel or +enter the Cash Shop: both get the client's own refusal (BLOCKED_SERVER 1 +and 2). An empty copy puts its mobs back, as a clone's room does (`npc_scripts::resets_when_empty`). The clock is each -player's own `MapTimer`, and a login or channel change into a copy lands +player's own `MapTimer`, and a login into a copy lands on its forced return map. -Not yet: the Cash Shop isn't closed to players in a copy, a party's new -leader doesn't close the run (Cosmic closes it when the leadership passes -to someone on another map), and the time limit runs per player rather than -per copy. +Not yet: the time limit runs per player rather than per copy (the members +follow the leader in within seconds, so the clocks hardly differ). Only the `go` banners are used by the current maps, on the Adventurer Training Centers and the revamp's Southperry (`2000000`). The other scripts, diff --git a/net/src/packet/handle/world/cash_shop.rs b/net/src/packet/handle/world/cash_shop.rs index 4ffff44f5..10b0c7f62 100644 --- a/net/src/packet/handle/world/cash_shop.rs +++ b/net/src/packet/handle/world/cash_shop.rs @@ -14,7 +14,9 @@ pub struct EnterCashShopHandler; impl WorldHandler for EnterCashShopHandler { fn handle(&self, _packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { - if ctx.player.stats.hp <= 0 { + // Nor does anyone leave a mini-dungeon's copy for the shop (as in + // Cosmic): the copy would be free for others while they are away. + if ctx.player.stats.hp <= 0 || crate::dungeons::of_copy(ctx.player.stats.map_id).is_some() { return Ok(HandlerResult::reply( build::world::map::build_blocked_server(BlockedServer::CashShop)?, )); @@ -234,6 +236,26 @@ mod tests { )); } + #[test] + fn nobody_leaves_a_mini_dungeon_for_the_cash_shop() { + let mut player = crate::codec::stats::sample_player(); + player.stats.map_id = 100020100; + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::EnterCashShop as i16) + .unwrap(); + let result = EnterCashShopHandler.handle(&mut packet, &mut ctx).unwrap(); + assert!(matches!( + result.actions.as_slice(), + [WorldAction::Reply(p)] if p.bytes == [0x84, 0, 2] + )); + } + #[test] fn an_unknown_balance_fails_the_purchase() { let result = handle(|p| { diff --git a/net/src/packet/handle/world/change_channel.rs b/net/src/packet/handle/world/change_channel.rs index 361a5e24d..5edcf8bf2 100644 --- a/net/src/packet/handle/world/change_channel.rs +++ b/net/src/packet/handle/world/change_channel.rs @@ -15,9 +15,11 @@ impl WorldHandler for ChangeChannelHandler { let _tick = reader.read_int()?; // The client waits on an answer before it sends another request, so - // a refusal still answers. + // a refusal still answers. A mini-dungeon's copy is kept on its + // channel, so nobody leaves it that way (as in Cosmic). if target_channel_id == ctx.channel_id || resolve_login_channel(ctx.player.world_id, target_channel_id).is_err() + || crate::dungeons::of_copy(ctx.player.stats.map_id).is_some() { return Ok(HandlerResult::reply( build::world::map::build_blocked_server(BlockedServer::Channel)?, @@ -29,3 +31,32 @@ impl WorldHandler for ChangeChannelHandler { })) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::op::RecvOpcode; + use packet::io::write::PktWrite; + + #[test] + fn nobody_changes_channel_inside_a_mini_dungeon() { + let mut player = crate::codec::stats::sample_player(); + player.stats.map_id = 100020100; + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::ChangeChannel as i16) + .unwrap(); + packet.write_byte(1).unwrap(); + packet.write_int(0).unwrap(); + let result = ChangeChannelHandler.handle(&mut packet, &mut ctx).unwrap(); + assert!(matches!( + result.actions.as_slice(), + [WorldAction::Reply(p)] if p.bytes == [0x84, 0, 1] + )); + } +} diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 4d8973666..86ff85e6c 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -539,6 +539,43 @@ impl WorldDirectory { } } + /// `from` handed their party to `to`. A mini-dungeon's copy `from` took + /// goes with it if `to` is in it too; if not, the run is over and + /// everyone in it goes back to the dungeon's map (as in Cosmic). + fn dungeon_leader_changed(&mut self, from: ClientId, to: ClientId) { + let Some(entry) = self.clients.get(&from) else { + return; + }; + let (channel_id, map_id) = (entry.channel_id, entry.presence.map_id); + let Some(run) = self + .dungeons + .get_mut(&(channel_id, map_id)) + .filter(|run| run.leader == from) + else { + return; + }; + let beside = self + .clients + .get(&to) + .is_some_and(|other| other.channel_id == channel_id && other.presence.map_id == map_id); + if beside { + run.leader = to; + return; + } + if let Some(dungeon) = net::dungeons::of_copy(map_id) { + for entry in self.clients.values() { + if entry.channel_id == channel_id && entry.presence.map_id == map_id { + entry.handle.send(ServerMessage::ShipWarp { + from_map: map_id, + map_id: dungeon.base, + portal: Portal::Id(0), + }); + } + } + } + self.dungeons.remove(&(channel_id, map_id)); + } + /// `client_id` left `map_id` on `channel_id`. If it is a mini-dungeon's /// copy they took, the run is over: whoever is still in it goes back to /// the dungeon's map. @@ -1208,6 +1245,7 @@ impl WorldDirectory { } if let Some(party) = self.parties.change_leader(from, member_id).cloned() { self.send_to_party(&party, |_| build_party_leader_changed(member_id)); + self.dungeon_leader_changed(from, member_id); } } PartyRequest::Chat { text } => { @@ -2411,6 +2449,44 @@ mod tests { assert_eq!(ship_warps(&mut bobby), [(FIRST, PIG_FARM)]); } + #[test] + fn a_mini_dungeon_follows_a_new_leader_in_it_and_closes_for_one_outside() { + const PIG_FARM: i32 = 100020000; + const FIRST: i32 = 100020100; + let (_tx, rx) = mpsc::channel(1); + let mut directory = WorldDirectory::new(rx); + let mut alice = online(&mut directory, 1, "Alice", PIG_FARM); + let mut bobby = online(&mut directory, 2, "Bobby", PIG_FARM); + let _carol = online(&mut directory, 3, "Carol", 20000); + let now = Instant::now(); + for name in ["Bobby", "Carol"] { + let name = name.into(); + directory.party_request(1, PartyRequest::Invite { name }, now); + } + for id in [2, 3] { + directory.party_request(id, PartyRequest::Accept { party_id: 1 }, now); + } + assert_eq!(directory.enter_dungeon(1, FIRST, 1, now), Ok(FIRST)); + directory.gather_team(1, FIRST, 0); + directory.update_presence(1, presence("Alice", FIRST)); + directory.update_presence(2, presence("Bobby", FIRST)); + ship_warps(&mut bobby); + + // Bobby, in the copy with her, takes it over: Alice may leave. + directory.party_request(1, PartyRequest::ChangeLeader { member_id: 2 }, now); + directory.update_presence(1, presence("Alice", PIG_FARM)); + assert_eq!(ship_warps(&mut bobby), []); + assert!(directory.dungeons.contains_key(&(0, FIRST))); + + // Carol is elsewhere, so handing her the party ends the run. + directory.update_presence(1, presence("Alice", FIRST)); + ship_warps(&mut alice); + directory.party_request(2, PartyRequest::ChangeLeader { member_id: 3 }, now); + assert_eq!(ship_warps(&mut alice), [(FIRST, PIG_FARM)]); + assert_eq!(ship_warps(&mut bobby), [(FIRST, PIG_FARM)]); + assert!(!directory.dungeons.contains_key(&(0, FIRST))); + } + #[test] fn duplicate_login_kicks_the_previous_connection_only() { let (_tx, rx) = mpsc::channel(1);