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
18 changes: 18 additions & 0 deletions agent_docs/map-travel.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ level 50 and Step 3 from it, arriving at portal 0. Louis (`1032004`,
keys (`shane.*`, `louis.ask`) are `unsourced`. `forest_of_patience.rs` checks
them through the network harness.

### Level limits

The v83 client reads neither `info/lvLimit` (672 maps) nor
`info/lvForceMove` (150), so the server applies them (`net::map_rules`).
Nothing shows how a v83 server did; these are the recommended defaults
from `v83ref/lanes/QUESTIONS.md` (row 196, 2026-10-22). Walking through a
portal into a map whose `lvLimit` the player hasn't reached is refused with
BLOCKED_MAP 2 ("You cannot go to that place."), as at Leafre's `west00`
(level 15). NPCs, scripts, rocks and map-id warps don't check it. A player of
level 20 or more who enters one of the five Training Centers (`910060000`,
`910120000`, `910220000`, `910310000`, `912030000`, five maps each,
`lvForceMove` 20) is sent at once to its `returnMap`, portal 0, by an
expired map timer. That happens on every entry, a login or a return from
the Cash Shop included; a player who reaches level 20 inside stays until
they next enter. The other `lvForceMove` maps (Kerning Square's 51,
the Mushroom Castle's 39) are left alone. GMs pass both checks.
`map_level_rules.rs` checks them through the network harness.

## Menu warps

These NPCs warp straight off a menu choice, with no question after it
Expand Down
39 changes: 39 additions & 0 deletions game-data/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ pub struct FieldTemplate {
/// is set (only Orbis Tower<B2>, 200082300): the view range that narrows
/// the map's bounds (`Footholds::bounds_within`).
pub view_limit: Option<(i32, i32, i32, i32)>,
/// `info/lvLimit`: the level a player needs to walk in through a portal.
/// Only the server reads it.
pub level_limit: Option<u8>,
/// `info/lvForceMove`: the level at which a player is moved out to
/// `return_map` (applied on the Training Centers only, see
/// `net::map_rules`). Only the server reads it.
pub level_force_move: Option<u8>,
/// `info/recovery`: how much faster players recover HP and MP at rest,
/// e.g. 2 in the Sleepywood sauna.
pub recovery: f64,
Expand Down Expand Up @@ -597,6 +604,16 @@ fn build_field_template(
}
_ => None,
};
let level_key = |key| -> Result<Option<u8>, GameDataError> {
Ok(match nx.child_by_name(map_node_idx, "info")? {
Some(info) => nx
.int_child(info, key)?
.and_then(|level| u8::try_from(level).ok())
.filter(|&level| level > 0),
None => None,
})
};
let (level_limit, level_force_move) = (level_key("lvLimit")?, level_key("lvForceMove")?);
let time_limit = nx
.child_by_name(map_node_idx, "info")?
.map(|info| nx.int_child(info, "timeLimit"))
Expand Down Expand Up @@ -700,6 +717,8 @@ fn build_field_template(
reactors,
mob_rate,
view_limit,
level_limit,
level_force_move,
recovery,
town,
field_limit,
Expand Down Expand Up @@ -1315,6 +1334,8 @@ mod tests {
reactors: Vec::new(),
mob_rate: 1.0,
view_limit: None,
level_limit: None,
level_force_move: None,
recovery: 1.0,
town: false,
field_limit: 0,
Expand Down Expand Up @@ -1391,6 +1412,8 @@ mod tests {
mob_spawns: Vec::new(),
mob_rate: 1.0,
view_limit: None,
level_limit: None,
level_force_move: None,
recovery: 1.0,
town: false,
field_limit: 0,
Expand Down Expand Up @@ -1438,6 +1461,22 @@ mod tests {
assert!(henesys.may_use(2000000) && henesys.may_drop());
}

#[test]
fn level_limits_come_from_the_map_info() {
let Some(path) = test_assets::path("Map.nx") else {
return;
};
let game_data = GameData::load_from_nx_map(&path).expect("load map nx");
let field = |map_id| game_data.field(map_id).expect("map");
// Leafre's forest, past the town's west00.
assert_eq!(field(240010000).level_limit, Some(15));
assert_eq!(field(240000000).level_limit, None);
let center = field(910060000);
assert_eq!(center.level_force_move, Some(20));
assert_eq!(center.level_limit, None);
assert_eq!(field(103040100).level_force_move, Some(51));
}

#[test]
fn orbis_towers_view_range_narrows_its_bounds() {
let Some(path) = test_assets::path("Map.nx") else {
Expand Down
59 changes: 59 additions & 0 deletions integration-harness/tests/map_level_rules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Map level rules: a portal into a map with `info/lvLimit` is closed to
//! players below it ("You cannot go to that place."), and a Training Center
//! (`info/lvForceMove` 20) sends players of level 20 and up back to town.

use integration_harness::database::set_job;
use integration_harness::packets::build_change_map;
use integration_harness::preconditions::load_harness_config_or_fail;
use integration_harness::trace::{barrier, read_for, send, until, warp, Trace};
use integration_harness::{login_to_world_session, relogin};
use net::packet::op::SendOpcode;
use tokio::time::Duration;

const LEAFRE: i32 = 240_000_000;
/// Past Leafre's `west00`, with lvLimit 15.
const FOREST: i32 = 240_010_000;
const TRAINING_CENTER: i32 = 910_060_000;

#[tokio::test]
async fn level_limits_close_portals_and_training_centers_send_veterans_home() {
let config = load_harness_config_or_fail().await;
let m = config.character_name.clone();
let mut session = login_to_world_session(&config).await.unwrap();
let id = session.character_id;
let c = &mut session.connection;

// At level 1, Leafre's west portal is closed.
let mut t = Trace::default();
assert_eq!(warp(c, LEAFRE, "", &mut t).await, LEAFRE);
send(c, build_change_map(-1, "west00").unwrap()).await;
until(c, &mut t, |t| !t.blocked.is_empty()).await;
assert_eq!(t.blocked, [(SendOpcode::BlockedMap as i16, 2)]);
assert_eq!(t.warps, [LEAFRE]);

// A Training Center keeps a beginner.
let mut t = Trace::default();
assert_eq!(warp(c, TRAINING_CENTER, "", &mut t).await, TRAINING_CENTER);
barrier(c, &m, &mut t).await;
read_for(c, &mut t, 500).await;
assert_eq!(t.warps, [TRAINING_CENTER]);
// Leave before logging out, so the next login lands in Leafre.
assert_eq!(warp(c, LEAFRE, "", &mut t).await, LEAFRE);

drop(session);
drop(relogin(&config).await);
tokio::time::sleep(Duration::from_millis(500)).await;
set_job(id, 0, 20, 50).await.expect("level 20");
let mut session = relogin(&config).await;
let c = &mut session.connection;

// At level 20 the portal opens, and the Training Center sends the
// player to its return map at once.
let mut t = Trace::default();
assert_eq!(warp(c, -1, "west00", &mut t).await, FOREST);
let mut t = Trace::default();
assert_eq!(warp(c, TRAINING_CENTER, "", &mut t).await, TRAINING_CENTER);
until(c, &mut t, |t| t.warps.len() == 2).await;
assert_ne!(t.warps[1], TRAINING_CENTER);
assert_eq!(t.arrivals.last().map(|arrival| arrival.1), Some(0));
}
1 change: 1 addition & 0 deletions net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod items;
mod load;
pub mod login_world;
pub mod loot;
pub mod map_rules;
pub mod medals;
pub mod meso_explosion;
pub mod mobs;
Expand Down
90 changes: 90 additions & 0 deletions net/src/map_rules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! The level rules of maps (Map.nx `info/lvLimit` and `info/lvForceMove`).
//! The v83 client reads neither, so the server enforces them; nothing shows
//! how a v83 server did, so these follow the recommended defaults in
//! `v83ref/lanes/QUESTIONS.md` (2026-10-22, row 196).

use game::Player;
use game_data::FieldTemplate;

/// The five Training Centers (`910060000`, ... five maps each), whose
/// `lvForceMove` 20 matches their entrance NPCs' "only for those under level
/// 20" (as in Cosmic's scripts). The other maps with the key (Kerning
/// Square's 51, the Mushroom Castle's 39) have no second source and are left
/// alone.
const TRAINING_CENTERS: [i32; 5] = [910060000, 910120000, 910220000, 910310000, 912030000];

fn training_center(map_id: i32) -> bool {
TRAINING_CENTERS.contains(&(map_id - map_id % 100))
}

/// Whether `player` may walk into `field` through a portal: they need its
/// `lvLimit`, unless they are a GM. Other ways in (NPCs, scripts, rocks,
/// map-id warps) are not checked.
pub fn may_walk_in(player: &Player, field: &FieldTemplate) -> bool {
player.gm_level > 0
|| field
.level_limit
.is_none_or(|level| player.stats.level >= level)
}

/// Where `player`, having entered `field`, is moved out to: a Training
/// Center's `returnMap` once they reach its `lvForceMove`, unless they are a
/// GM.
pub fn moved_out_to(player: &Player, field: &FieldTemplate) -> Option<i32> {
let level = field.level_force_move?;
if player.gm_level > 0 || player.stats.level < level || !training_center(field.map_id) {
return None;
}
field.return_map.filter(|&map_id| map_id != field.map_id)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_training_center_moves_out_players_from_level_20() {
let data = crate::game_data::get().unwrap();
let mut centers = data
.field_ids()
.filter(|&map_id| data.field(map_id).unwrap().level_force_move == Some(20))
.collect::<Vec<_>>();
centers.sort_unstable();
assert_eq!(centers.len(), 25);
assert!(centers.iter().all(|&map_id| training_center(map_id)));

let center = data.field(910060000).unwrap();
let mut player = crate::codec::stats::sample_player();
player.stats.level = 19;
assert_eq!(moved_out_to(&player, center), None);
player.stats.level = 20;
let town = moved_out_to(&player, center).unwrap();
assert_eq!(Some(town), center.return_map);
player.gm_level = 1;
assert_eq!(moved_out_to(&player, center), None);
// Kerning Square's 51 is not applied.
player.gm_level = 0;
player.stats.level = 60;
assert_eq!(moved_out_to(&player, data.field(103040100).unwrap()), None);
}

#[test]
fn walking_in_needs_the_maps_level() {
let data = crate::game_data::get().unwrap();
let forest = data.field(240010000).unwrap();
let leafre = data.field(240000000).unwrap();
assert_eq!(
leafre.portal_by_name("west00").unwrap().to_map,
Some(240010000)
);
let mut player = crate::codec::stats::sample_player();
player.stats.level = 14;
assert!(!may_walk_in(&player, forest));
assert!(may_walk_in(&player, data.field(240000000).unwrap()));
player.gm_level = 1;
assert!(may_walk_in(&player, forest));
player.gm_level = 0;
player.stats.level = 15;
assert!(may_walk_in(&player, forest));
}
}
9 changes: 9 additions & 0 deletions net/src/packet/handle/world/change_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ impl WorldHandler for ChangeMapHandler {
BlockedMap::PortalClosed,
)?));
};
// Below the map's `lvLimit`: "You cannot go to that place."
if !game_data
.field(destination_map)
.is_none_or(|field| crate::map_rules::may_walk_in(player, field))
{
return Ok(HandlerResult::reply(build::world::map::build_blocked_map(
BlockedMap::CannotGo,
)?));
}
let spawn_point = u8::try_from(spawn_portal.id).map_err(|_| {
NetworkError::PacketHandlerError("Destination spawn portal id out of range")
})?;
Expand Down
17 changes: 15 additions & 2 deletions runtime/src/actor/client/time_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,29 @@ impl ClientActor {
/// `npc_scripts::time_limit`) and another map to return to, the client
/// shows the countdown and the player has that long. A ride
/// (`rides::ride_on`) takes its own time and arrives at its own map. Any
/// other map clears the timer, so coming back starts it again.
/// other map clears the timer, so coming back starts it again. A player
/// too high for a Training Center (`net::map_rules::moved_out_to`) has
/// no time at all there.
pub(super) async fn start_map_timer(&mut self) -> Result<(), RuntimeError> {
self.map_timer = None;
let map_id = self.in_game()?.player.stats.map_id;
let player = &self.in_game()?.player;
let map_id = player.stats.map_id;
let Ok(data) = net::get_game_data() else {
return Ok(());
};
let Some(field) = data.field(map_id) else {
return Ok(());
};
if let Some(to) =
net::map_rules::moved_out_to(player, field).filter(|&to| data.field_exists(to))
{
self.map_timer = Some(MapTimer {
map_id,
ends: Instant::now(),
to: (to, 0),
});
return Ok(());
}
let limit = match net::rides::ride_on(map_id) {
Some(ride) => Some((ride.countdown(), ride.to)),
None => field
Expand Down
Loading