From dbb516dc8324ab077d4ac87d2aff8b6672ae181a Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 03:11:00 -0400 Subject: [PATCH 1/2] Map rules: lvLimit closes portals to players below it, Training Centers send players of level 20 and up home (recommended defaults, row 196) --- agent_docs/map-travel.md | 16 ++++ game-data/src/lib.rs | 39 +++++++++ integration-harness/tests/map_level_rules.rs | 58 +++++++++++++ net/src/lib.rs | 1 + net/src/map_rules.rs | 90 ++++++++++++++++++++ net/src/packet/handle/world/change_map.rs | 9 ++ runtime/src/actor/client/time_limit.rs | 17 +++- 7 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 integration-harness/tests/map_level_rules.rs create mode 100644 net/src/map_rules.rs diff --git a/agent_docs/map-travel.md b/agent_docs/map-travel.md index 6dab0fe1..c58f7716 100644 --- a/agent_docs/map-travel.md +++ b/agent_docs/map-travel.md @@ -105,6 +105,22 @@ 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. 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 diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index ab02f164..3d74b7da 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -244,6 +244,13 @@ pub struct FieldTemplate { /// is set (only Orbis Tower, 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, + /// `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, /// `info/recovery`: how much faster players recover HP and MP at rest, /// e.g. 2 in the Sleepywood sauna. pub recovery: f64, @@ -597,6 +604,16 @@ fn build_field_template( } _ => None, }; + let level_key = |key| -> Result, 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")) @@ -700,6 +717,8 @@ fn build_field_template( reactors, mob_rate, view_limit, + level_limit, + level_force_move, recovery, town, field_limit, @@ -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, @@ -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, @@ -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 { diff --git a/integration-harness/tests/map_level_rules.rs b/integration-harness/tests/map_level_rules.rs new file mode 100644 index 00000000..30fd478d --- /dev/null +++ b/integration-harness/tests/map_level_rules.rs @@ -0,0 +1,58 @@ +//! 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]); + + 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, LEAFRE, "", &mut t).await, LEAFRE); + 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)); +} diff --git a/net/src/lib.rs b/net/src/lib.rs index 4161f8cd..d323e75c 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -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; diff --git a/net/src/map_rules.rs b/net/src/map_rules.rs new file mode 100644 index 00000000..818f368a --- /dev/null +++ b/net/src/map_rules.rs @@ -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 { + 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::>(); + 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)); + } +} diff --git a/net/src/packet/handle/world/change_map.rs b/net/src/packet/handle/world/change_map.rs index 35dd4c18..d3db9972 100644 --- a/net/src/packet/handle/world/change_map.rs +++ b/net/src/packet/handle/world/change_map.rs @@ -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") })?; diff --git a/runtime/src/actor/client/time_limit.rs b/runtime/src/actor/client/time_limit.rs index fb551cf8..e6c1f7a4 100644 --- a/runtime/src/actor/client/time_limit.rs +++ b/runtime/src/actor/client/time_limit.rs @@ -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 From 0506e8fd522b34fc7865ab5f8a8809fbe09b6ca1 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 03:15:25 -0400 Subject: [PATCH 2/2] Harness: leave the Training Center before relogging --- agent_docs/map-travel.md | 4 +++- integration-harness/tests/map_level_rules.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agent_docs/map-travel.md b/agent_docs/map-travel.md index c58f7716..d3c4376b 100644 --- a/agent_docs/map-travel.md +++ b/agent_docs/map-travel.md @@ -117,7 +117,9 @@ BLOCKED_MAP 2 ("You cannot go to that place."), as at Leafre's `west00` 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. The other `lvForceMove` maps (Kerning Square's 51, +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. diff --git a/integration-harness/tests/map_level_rules.rs b/integration-harness/tests/map_level_rules.rs index 30fd478d..8ffb2593 100644 --- a/integration-harness/tests/map_level_rules.rs +++ b/integration-harness/tests/map_level_rules.rs @@ -37,6 +37,8 @@ async fn level_limits_close_portals_and_training_centers_send_veterans_home() { 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); @@ -48,7 +50,6 @@ async fn level_limits_close_portals_and_training_centers_send_veterans_home() { // 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, LEAFRE, "", &mut t).await, LEAFRE); 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);