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
17 changes: 10 additions & 7 deletions agent_docs/map-travel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<map id>` banners are used by the current maps, on the Adventurer
Training Centers and the revamp's Southperry (`2000000`). The other scripts,
Expand Down
24 changes: 23 additions & 1 deletion net/src/packet/handle/world/cash_shop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
));
Expand Down Expand Up @@ -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| {
Expand Down
33 changes: 32 additions & 1 deletion net/src/packet/handle/world/change_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
Expand All @@ -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]
));
}
}
76 changes: 76 additions & 0 deletions runtime/src/actor/directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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);
Expand Down
Loading