From 1580d4439b2a2a3f9e110cf7a216884fd1207d64 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 00:55:39 -0400 Subject: [PATCH 1/2] Exploration medals: explorationPoint maps count toward their region's explorer title from its level, highposition portals toward Touched the Sky; places are kept in explored_maps (outlasting a forfeit), sent at login under the info record, and the last one sends the title line, QUEST_CLEAR and Dalair's note; Dalair (9000040) gives the medal --- agent_docs/map-travel.md | 3 +- agent_docs/quests.md | 25 +++ .../2026-11-08-000100_explored_maps/down.sql | 1 + .../2026-11-08-000100_explored_maps/up.sql | 8 + db/src/player.rs | 28 ++- db/src/schema.rs | 9 + game-data/placeholder_text.toml | 4 + game-data/server_content.toml | 4 + game-data/src/lib.rs | 4 + game-data/src/quests.rs | 55 ++++++ game-data/src/server_content.rs | 11 ++ game-data/supported_quests.txt | 9 + game/src/quest.rs | 62 ++++++- integration-harness/src/database.rs | 19 ++ integration-harness/src/trace.rs | 11 ++ integration-harness/tests/medal_explorer.rs | 146 +++++++++++++++ net/src/codec/character.rs | 20 +- net/src/lib.rs | 1 + net/src/medals.rs | 173 ++++++++++++++++++ net/src/packet/build/world/quest.rs | 28 +++ net/src/packet/op/send.rs | 4 + net/src/quests.rs | 9 + net/src/scripts.rs | 80 ++++++-- runtime/src/actor/client/npc_script.rs | 26 +++ 24 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 db/migrations/2026-11-08-000100_explored_maps/down.sql create mode 100644 db/migrations/2026-11-08-000100_explored_maps/up.sql create mode 100644 integration-harness/tests/medal_explorer.rs create mode 100644 net/src/medals.rs diff --git a/agent_docs/map-travel.md b/agent_docs/map-travel.md index df4e9773c..90444822c 100644 --- a/agent_docs/map-travel.md +++ b/agent_docs/map-travel.md @@ -140,7 +140,8 @@ nothing. A script returns effects that the client actor sends: | --- | --- | | `go` (entry) | Map name banner, FIELD_EFFECT `maplemap/enter/` | | `TD_NC_title`, `TD_MC_title` (entry) | The Time Gate's and the Mushroom Kingdom's theme banners, FIELD_EFFECT `temaD/enter/`: `teraForest` on `240070000`, `neoCity1`-`neoCity6` on `240070100`-`240070600`, `mushCatle` on `106020000`. Neither script is in Cosmic; the pairing is by name and by the maps' years | -| `explorationPoint` (entry) | Lith Harbor's (`104000000`) name banner; in the Disciples' hideout (`100000006`), while 2175 is taken, the Disciple (`9300156`) comes out at (-1027, 216) unless one is out already (`Effect::BringOutMob`, under the player's control, never respawning). The exploration medals' counting isn't in | +| `explorationPoint` (entry) | Lith Harbor's (`104000000`) name banner; in the Disciples' hideout (`100000006`), while 2175 is taken, the Disciple (`9300156`) comes out at (-1027, 216) unless one is out already (`Effect::BringOutMob`, under the player's control, never respawning). It also counts the map toward its region's exploration medal (`net::medals`, see "Exploration medals" in `quests.md`) | +| `highposition` (touch) | Counts the map toward Touched the Sky (29004); it takes the player nowhere | | `infoMinimap` | `UI/tutorial.img/25` once 1031 is started | | `infoAttack`, `infoPickup` | `/20`, `/21` once 1035 is started | | `infoSkill` | `/23` once 1035 is completed | diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 311609b7b..9540f3250 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -229,6 +229,31 @@ Completing a quest also plays the v83 "Quest Clear" effect (`effect::build_own_effect` and `effect::build_foreign_effect` with `CharEffect::QuestClear`). +## Exploration medals + +The explorer titles (`game_data::quests::EXPLORER_QUESTS`: 29004-29011 and +29014) count places rather than monsters. Their `Check/1` names an info record +(`infoNumber`, e.g. 27010 for Beginner Explorer) and the number of places +(`infoex/0/value`), read into `QuestTemplate::explored`; completion needs at +least that many (`QuestRejection::Explored`), and Dalair (9000040) gives the +medal through the usual `QUEST_ACTION`. + +`net::medals::region` picks the medal for a map by id range, as +`explorationPoint` does; the `highposition` touch portals count toward +Touched the Sky (29004). A map counts once, from the medal's `lvmin`, until +the quest is completed (`medals::explore`, `Effect::Explore`). The first +place starts the quest. Each new place sends the info record with the count +(SHOW_STATUS_INFO mode 1), a progress line (SCRIPT_PROGRESS_MESSAGE, 0x7A) +and an info text; the place that reaches the count sends the title line, +QUEST_CLEAR (0x31) and the "see Dalair" text instead. + +Places seen are `QuestLog::explored`, keyed by the info record, and are stored +in `explored_maps (character_id, record_id, map_id)`; they outlast a forfeit. +At login each record with places is sent among the started quests with its +count, so the client's quest window shows it. Ossyria Explorer (29012) and +Maple Explorer (29013), which need other medals and an info record 27020 that +nothing sets, are not supported. + ## Conversations in the client `Character/QuestDialogue.h` is a pure state machine over one side of diff --git a/db/migrations/2026-11-08-000100_explored_maps/down.sql b/db/migrations/2026-11-08-000100_explored_maps/down.sql new file mode 100644 index 000000000..1c9015a27 --- /dev/null +++ b/db/migrations/2026-11-08-000100_explored_maps/down.sql @@ -0,0 +1 @@ +DROP TABLE explored_maps; diff --git a/db/migrations/2026-11-08-000100_explored_maps/up.sql b/db/migrations/2026-11-08-000100_explored_maps/up.sql new file mode 100644 index 000000000..ec111c73f --- /dev/null +++ b/db/migrations/2026-11-08-000100_explored_maps/up.sql @@ -0,0 +1,8 @@ +-- The maps a character has visited toward each explorer medal, keyed by +-- the info record (27010 to 27019) that shows the medal's count. +CREATE TABLE explored_maps ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + record_id SMALLINT NOT NULL, + map_id INTEGER NOT NULL, + PRIMARY KEY (character_id, record_id, map_id) +); diff --git a/db/src/player.rs b/db/src/player.rs index f780d74ba..8a2601120 100644 --- a/db/src/player.rs +++ b/db/src/player.rs @@ -9,8 +9,8 @@ use crate::keybinding::KeybindType; use crate::quest::QuestProgress; use crate::schema::{ accounts, auto_potions, character_mounts, character_quests, characters, equipment_stats, - gacha_exp, inventory_items, keybindings, monster_book_cards, quick_slots, saved_locations, - skill_macros, skills, teleport_rocks, + explored_maps, gacha_exp, inventory_items, keybindings, monster_book_cards, quick_slots, + saved_locations, skill_macros, skills, teleport_rocks, }; use diesel::pg::PgConnection; use diesel::prelude::*; @@ -190,6 +190,12 @@ pub fn load_player(character_id: i32) -> crate::Result { character_quests::table .filter(character_quests::character_id.eq(character_id)) .load::(&mut conn)?, + ) + .with_explored( + explored_maps::table + .filter(explored_maps::character_id.eq(character_id)) + .select((explored_maps::record_id, explored_maps::map_id)) + .load::<(i16, i32)>(&mut conn)?, ); let mut player = Player::new( @@ -456,6 +462,24 @@ pub(crate) fn write_player(conn: &mut PgConnection, player: &Player) -> QueryRes .values(&markers) .execute(conn)?; } + // Explorer medal visits are only ever added. + let visits: Vec<_> = player + .quests + .visits() + .map(|(record, map_id)| { + ( + explored_maps::character_id.eq(id), + explored_maps::record_id.eq(record), + explored_maps::map_id.eq(map_id), + ) + }) + .collect(); + if !visits.is_empty() { + diesel::insert_into(explored_maps::table) + .values(&visits) + .on_conflict_do_nothing() + .execute(conn)?; + } // Kill progress of started quests. for record in player.quests.started() { let kills = player.quests.kills(record.quest_id); diff --git a/db/src/schema.rs b/db/src/schema.rs index 0a2137180..c3c200349 100644 --- a/db/src/schema.rs +++ b/db/src/schema.rs @@ -232,6 +232,14 @@ diesel::table! { } } diesel::joinable!(monster_book_cards -> characters (character_id)); +diesel::table! { + explored_maps (character_id, record_id, map_id) { + character_id -> Int4, + record_id -> Int2, + map_id -> Int4, + } +} +diesel::joinable!(explored_maps -> characters (character_id)); diesel::table! { teleport_rocks (character_id, vip, place) { character_id -> Int4, @@ -545,6 +553,7 @@ diesel::allow_tables_to_appear_in_same_query!( cash_sales, wish_lists, monster_book_cards, + explored_maps, teleport_rocks, skill_macros, saved_locations, diff --git a/game-data/placeholder_text.toml b/game-data/placeholder_text.toml index 1c7da9606..e5bf16c68 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -263,6 +263,10 @@ "portal.treasure_room.occupied" = "[placeholder] Someone is already in the treasure room." "portal.solo_map.occupied" = "[placeholder] Someone is already in there. Try again once they come out." "portal.s4berserk_move.monsters_left" = "[placeholder] The way up stays shut while any monster here still stands." +"medal.explorer.progress" = "[placeholder] {name}: {count} of {needed} places seen." +"medal.explorer.progress_info" = "[placeholder] Another place seen toward the {name} title ({count} of {needed})." +"medal.explorer.earned" = "[placeholder] {name}: every place seen." +"medal.explorer.earned_info" = "[placeholder] The {name} title is yours; Dalair keeps its medal for you." "portal.disguise.occupied" = "[placeholder] Someone else is searching that forest." "portal.disguise.already_found" = "[placeholder] You found him already; tell the Chief Knight." "portal.mysterious_force" = "[placeholder] Something keeps you out." diff --git a/game-data/server_content.toml b/game-data/server_content.toml index 5722cba3e..0a0f80ade 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -491,6 +491,10 @@ unsourced = [ "portal.treasure_room.occupied", "portal.solo_map.occupied", "portal.s4berserk_move.monsters_left", + "medal.explorer.progress", + "medal.explorer.progress_info", + "medal.explorer.earned", + "medal.explorer.earned_info", "portal.disguise.occupied", "portal.disguise.already_found", "portal.mysterious_force", diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index 09b73e76f..57316c799 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -533,6 +533,10 @@ impl GameData { self.fields.get(&map_id) } + pub fn field_ids(&self) -> impl Iterator + '_ { + self.fields.keys().copied() + } + pub fn field_exists(&self, map_id: i32) -> bool { self.fields.contains_key(&map_id) } diff --git a/game-data/src/quests.rs b/game-data/src/quests.rs index e05af6a11..7372eccb8 100644 --- a/game-data/src/quests.rs +++ b/game-data/src/quests.rs @@ -64,6 +64,14 @@ pub const SCRIPTS: &[&str] = &[ /// differs: its classes stay done. pub const MEREN_INFO_QUESTS: &[i16] = &[6029, 6030, 6031, 6032]; const INFO_KEYS: &[&str] = &["info", "infoNumber", "infoex"]; +/// The explorer medals: each counts the maps of a region visited +/// (`map/onUserEnter/explorationPoint`, or for 29004 the `highposition` +/// portals) into its info record, and completes at Dalair once that count is +/// at least `Check/1/infoex` (cond 1, v83 FUN_00721d2c). The server keeps the +/// maps; see [`QuestTemplate::explored`]. +pub const EXPLORER_QUESTS: &[i16] = &[ + 29004, 29005, 29006, 29007, 29008, 29009, 29010, 29011, 29014, +]; /// "What Meren Dropped" (6034) repeats its start requirements in `Act/0`, /// with a `skill` 1007 entry (`acquire` 65535) that teaches nothing (Cosmic /// has the same data); none of it is an action. @@ -171,6 +179,17 @@ pub struct QuestTemplate { /// offers the quest wherever the player is. pub auto_start: bool, pub auto_complete: bool, + /// An explorer medal's count: its info record and the maps it needs. + pub explored: Option, +} + +/// What an explorer medal (`EXPLORER_QUESTS`) counts toward. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Explored { + /// The quest whose record holds the count (`Check/1/infoNumber`). + pub record: i16, + /// Maps needed to complete (`Check/1/infoex/0/value`). + pub needed: u32, } /// One `Act//item` entry. @@ -332,6 +351,11 @@ impl QuestCatalog { keys.skill_keys.extend(child_names(&nx, skill)?); } } + if EXPLORER_QUESTS.contains(&id) { + if let Some(keys) = &mut keys.end_check { + keys.retain(|key| !INFO_KEYS.contains(&key.as_str())); + } + } if MEREN_INFO_QUESTS.contains(&id) { for keys in [&mut keys.start_check, &mut keys.end_check] .into_iter() @@ -459,6 +483,11 @@ fn template(nx: &NxDataFile, id: i16) -> Result { None => false, }) }; + let explored = if EXPLORER_QUESTS.contains(&id) { + Some(explored(nx, end)?) + } else { + None + }; // See `classify`: quests without a completion NPC go back to the start NPC. let start_npc = requirements.npc; Ok(QuestTemplate { @@ -491,6 +520,32 @@ fn template(nx: &NxDataFile, id: i16) -> Result { .transpose()?, auto_start: flag("autoStart")?, auto_complete: flag("autoComplete")?, + explored, + }) +} + +/// An explorer medal's `infoNumber` and its one `infoex` entry, a count to +/// reach (cond 1), kept as a string. +fn explored(nx: &NxDataFile, end: u32) -> Result { + let record = nx + .int_child(end, "infoNumber")? + .ok_or_else(|| invalid("explorer medal without infoNumber"))?; + let entry = match nx.child_by_name(end, "infoex")? { + Some(infoex) => nx.child_by_name(infoex, "0")?, + None => None, + } + .ok_or_else(|| invalid("explorer medal without infoex"))?; + if nx.int_child(entry, "cond")? != Some(1) { + return Err(invalid("explorer medal infoex isn't a count")); + } + // A numeric string, which reads as a number. + let needed = nx + .int_child(entry, "value")? + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| invalid("explorer medal infoex value isn't a count"))?; + Ok(Explored { + record: small(record, "quest id")?, + needed, }) } diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index 07516fbda..f5acca829 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -374,6 +374,13 @@ pub mod keys { /// The Berserk room's way up to the statues (`s4berserk_move`) while a /// monster is left. pub const MONSTERS_LEFT: &str = "portal.s4berserk_move.monsters_left"; + /// An explorer medal's count went up (`{name}`, `{count}`, `{needed}`): + /// the floating message and the info line. + pub const MEDAL_PROGRESS: &str = "medal.explorer.progress"; + pub const MEDAL_PROGRESS_INFO: &str = "medal.explorer.progress_info"; + /// It reached what the medal needs (`{name}`). + pub const MEDAL_EARNED: &str = "medal.explorer.earned"; + pub const MEDAL_EARNED_INFO: &str = "medal.explorer.earned_info"; /// The Master of Disguise's forests (`enterDisguise1` to `5`): someone /// else is searching, or he has already been found. pub const DISGUISE_FOREST_OCCUPIED: &str = "portal.disguise.occupied"; @@ -1090,6 +1097,10 @@ mod tests { keys::TREASURE_ROOM_OCCUPIED, keys::SOLO_MAP_OCCUPIED, keys::MONSTERS_LEFT, + keys::MEDAL_PROGRESS, + keys::MEDAL_PROGRESS_INFO, + keys::MEDAL_EARNED, + keys::MEDAL_EARNED_INFO, keys::DISGUISE_FOREST_OCCUPIED, keys::DISGUISE_ALREADY_FOUND, keys::MYSTERIOUS_FORCE, diff --git a/game-data/supported_quests.txt b/game-data/supported_quests.txt index b97b906a4..b60cc950d 100644 --- a/game-data/supported_quests.txt +++ b/game-data/supported_quests.txt @@ -1372,6 +1372,15 @@ 28283 28325 29003 +29004 +29005 +29006 +29007 +29008 +29009 +29010 +29011 +29014 29015 29300 29301 diff --git a/game/src/quest.rs b/game/src/quest.rs index 319721479..e055a8937 100644 --- a/game/src/quest.rs +++ b/game/src/quest.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::SystemTime; /// Quest ids from here up are not in Quest.nx (its last is 29933). Scripts @@ -47,15 +47,59 @@ pub struct QuestRecord { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct QuestLog { records: BTreeMap, + /// The maps visited toward each explorer medal, keyed by the info + /// record that shows their count (27010 for Victoria's, ...). They stay + /// through a forfeit, so a restarted medal carries on. + explored: BTreeMap>, } impl QuestLog { pub fn from_records(records: impl IntoIterator) -> Self { Self { records: records.into_iter().map(|r| (r.quest_id, r)).collect(), + explored: BTreeMap::new(), } } + /// The log with these `(info record, map)` visits. + pub fn with_explored(mut self, visits: impl IntoIterator) -> Self { + for (record, map_id) in visits { + self.explore(record, map_id); + } + self + } + + /// Count a visit to `map_id` toward the medal whose info record is + /// `record`; `false` if it was visited before. + pub fn explore(&mut self, record: i16, map_id: i32) -> bool { + self.explored.entry(record).or_default().insert(map_id) + } + + /// Whether `map_id` was visited toward the medal with info record `record`. + pub fn has_explored(&self, record: i16, map_id: i32) -> bool { + self.explored + .get(&record) + .is_some_and(|maps| maps.contains(&map_id)) + } + + /// How many maps were visited toward the medal with info record `record`. + pub fn explored(&self, record: i16) -> usize { + self.explored.get(&record).map_or(0, BTreeSet::len) + } + + /// Every info record with a visit and its count. + pub fn explored_counts(&self) -> impl Iterator + '_ { + (self.explored.iter()) + .filter(|(_, maps)| !maps.is_empty()) + .map(|(&record, maps)| (record, maps.len())) + } + + /// Every `(info record, map)` visit. + pub fn visits(&self) -> impl Iterator + '_ { + (self.explored.iter()) + .flat_map(|(&record, maps)| maps.iter().map(move |&map| (record, map))) + } + pub fn upsert(&mut self, record: QuestRecord) { self.records.insert(record.quest_id, record); } @@ -187,4 +231,20 @@ mod tests { assert_eq!(log.kills(1037), []); assert_eq!(log.kills_mut(1037), None); } + + #[test] + fn explored_maps_count_once_and_outlast_a_forfeit() { + let mut log = QuestLog::default().with_explored([(27010, 100000000)]); + assert!(!log.explore(27010, 100000000)); + assert!(log.explore(27010, 101000000)); + assert!(log.explore(27011, 200000000)); + assert_eq!(log.explored(27010), 2); + assert_eq!(log.explored(27012), 0); + log.remove(29005); + assert_eq!( + log.explored_counts().collect::>(), + [(27010, 2), (27011, 1)] + ); + assert_eq!(log.visits().count(), 3); + } } diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index 384f4131b..1bab67d52 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -450,6 +450,25 @@ pub async fn set_quest( .await } +/// Record that the logged-out character `character_id` has explored +/// `map_id` toward the medal counted in quest record `record_id`. +pub async fn explore_map( + character_id: i32, + record_id: i16, + map_id: i32, +) -> Result<(), HarnessError> { + change_one_row("explore map", move |conn| { + diesel::sql_query( + "INSERT INTO explored_maps (character_id, record_id, map_id) VALUES ($1, $2, $3)", + ) + .bind::(character_id) + .bind::(record_id) + .bind::(map_id) + .execute(conn) + }) + .await +} + /// Make the logged-out character `character_id`'s completion of quest /// `quest_id` look `minutes` minutes older. pub async fn backdate_quest( diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index 9f12dbab1..da059a969 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -195,6 +195,12 @@ pub struct Trace { /// Every family packet (FAMILY_CHART_RESULT to /// FAMILY_NOTIFY_LOGIN_OR_LOGOUT): (opcode, body), in order. pub family: Vec<(i16, Vec)>, + /// SHOW_STATUS_INFO mode 9: the info lines, in order. + pub info_texts: Vec, + /// SCRIPT_PROGRESS_MESSAGE lines, in order. + pub script_messages: Vec, + /// QUEST_CLEAR: the quests whose titles were earned, in order. + pub quest_clears: Vec, /// Every PLAYER_INTERACTION body (mode first), in order. pub rooms: Vec>, /// Every UPDATE_CHAR_BOX body (character id first), in order. @@ -316,6 +322,7 @@ impl Trace { } 4 => self.fame_gains.push(r.read_int().unwrap()), 5 => self.meso_gains.push(r.read_int().unwrap()), + 9 => self.info_texts.push(r.read_str_with_length().unwrap()), 8 => { for _ in 0..r.read_byte().unwrap() { self.expired.push(r.read_int().unwrap()); @@ -573,6 +580,10 @@ impl Trace { .contains(&op) { self.family.push((op, p.bytes[2..].to_vec())); + } else if op == SendOpcode::ScriptProgressMessage as i16 { + self.script_messages.push(r.read_str_with_length().unwrap()); + } else if op == SendOpcode::QuestClear as i16 { + self.quest_clears.push(r.read_short().unwrap()); } else if op == SendOpcode::PlayerInteraction as i16 { self.rooms.push(p.bytes[2..].to_vec()); } else if op == SendOpcode::UpdateCharBox as i16 { diff --git a/integration-harness/tests/medal_explorer.rs b/integration-harness/tests/medal_explorer.rs new file mode 100644 index 000000000..b8c88b191 --- /dev/null +++ b/integration-harness/tests/medal_explorer.rs @@ -0,0 +1,146 @@ +//! Exploration medals: entering a region's explorationPoint maps from the +//! medal's level counts each map once toward its title, the count is kept +//! across logins, and the last map earns the title, which Dalair turns into +//! the medal. The highposition portals count toward Touched the Sky. + +use integration_harness::database::{explore_map, set_job}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::quests::{act, COMPLETE}; +use integration_harness::trace::{barrier, step, warp, Trace}; +use integration_harness::{login_to_world_session, relogin, WorldSession}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +const BEGINNER_EXPLORER: i16 = 29005; +const BEGINNER_RECORD: i16 = 27010; +const BEGINNER_MEDAL: i32 = 1142127; +const TOUCHED_THE_SKY: i16 = 29004; +const SKY_RECORD: i16 = 27018; +const DALAIR: i32 = 9000040; +const HENESYS_PARK: i32 = 100000200; +const HIGH_LOOKOUT: i32 = 101010103; +/// The 20 explorationPoint maps of Victoria Island counted toward Beginner +/// Explorer. +const VICTORIA: [i32; 20] = [ + 100000000, 100000006, 100010000, 100040000, 100040100, 101000000, 101010103, 101020000, + 101030104, 101030406, 102000000, 102020300, 102050000, 103000000, 103010001, 103030200, + 104000000, 104010001, 104020000, 110000000, +]; + +fn enter(portal: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::ChangeMapSpecial as i16).unwrap(); + p.write_byte(0).unwrap(); + p.write_str_with_length(portal).unwrap(); + p.write_short(0).unwrap(); + p +} + +/// Go to `map` and wait for what its arrival brings. +async fn visit(player: &mut WorldSession, map: i32) -> Trace { + let mut trace = Trace::default(); + let c = &mut player.connection; + assert_eq!(warp(c, map, "", &mut trace).await, map); + barrier(c, &player.character_name, &mut trace).await; + trace +} + +#[tokio::test] +async fn victorias_places_earn_the_beginner_explorer_medal() { + let config = load_harness_config_or_fail().await; + let mut player = login_to_world_session(&config).await.unwrap(); + let id = player.character_id; + + // Below the medal's level 15 nothing is counted. + let low = visit(&mut player, VICTORIA[0]).await; + assert_eq!(low.states.get(&BEGINNER_EXPLORER), None); + assert_eq!(low.kills.get(&BEGINNER_RECORD), None); + drop(player); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(id, 0, 20, 100).await.unwrap(); + let mut player = relogin(&config).await; + + // The first map starts the medal's quest and counts one. + let first = visit(&mut player, VICTORIA[0]).await; + assert_eq!(first.states.get(&BEGINNER_EXPLORER), Some(&1)); + assert_eq!( + first.kills.get(&BEGINNER_RECORD).map(String::as_str), + Some("1") + ); + assert_eq!( + first.script_messages.len(), + 1, + "{:?}", + first.script_messages + ); + assert_eq!(first.info_texts.len(), 1, "{:?}", first.info_texts); + assert!(first.quest_clears.is_empty()); + + let second = visit(&mut player, VICTORIA[1]).await; + assert_eq!( + second.kills.get(&BEGINNER_RECORD).map(String::as_str), + Some("2") + ); + // A map seen before counts nothing. + let again = visit(&mut player, VICTORIA[0]).await; + assert_eq!(again.kills.get(&BEGINNER_RECORD), None); + assert!(again.script_messages.is_empty()); + + // Dalair has no medal for two maps. + let mut early = visit(&mut player, HENESYS_PARK).await; + let name = player.character_name.clone(); + let c = &mut player.connection; + act(c, &mut early, &name, COMPLETE, BEGINNER_EXPLORER, DALAIR).await; + assert_ne!(early.states.get(&BEGINNER_EXPLORER), Some(&2)); + drop(player); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Seen all but the last, the count comes back at login. + for map in &VICTORIA[2..19] { + explore_map(id, BEGINNER_RECORD, *map).await.unwrap(); + } + let mut player = relogin(&config).await; + assert_eq!(player.quests.get(&BEGINNER_EXPLORER), Some(&1)); + assert_eq!( + player + .quest_progress + .get(&BEGINNER_RECORD) + .map(String::as_str), + Some("19") + ); + + // The last earns the title. + let last = visit(&mut player, VICTORIA[19]).await; + assert_eq!( + last.kills.get(&BEGINNER_RECORD).map(String::as_str), + Some("20") + ); + assert_eq!(last.quest_clears, [BEGINNER_EXPLORER]); + assert_eq!(last.script_messages.len(), 1, "{:?}", last.script_messages); + assert_eq!(last.info_texts.len(), 1, "{:?}", last.info_texts); + + // Dalair gives the medal. + let mut done = visit(&mut player, HENESYS_PARK).await; + let c = &mut player.connection; + act(c, &mut done, &name, COMPLETE, BEGINNER_EXPLORER, DALAIR).await; + assert_eq!(done.states.get(&BEGINNER_EXPLORER), Some(&2)); + assert!( + done.items.contains(&(BEGINNER_MEDAL, 1)), + "{:?}", + done.items + ); + + // Once it is done its maps count nothing more; the lookout's + // highposition counts toward Touched the Sky without taking the player + // anywhere. + let lookout = visit(&mut player, HIGH_LOOKOUT).await; + assert_eq!(lookout.kills.get(&BEGINNER_RECORD), None); + let up = step(&mut player.connection, &name, enter("highposition")).await; + assert!(up.arrivals.is_empty(), "{:?}", up.arrivals); + assert_eq!(up.states.get(&TOUCHED_THE_SKY), Some(&1)); + assert_eq!(up.kills.get(&SKY_RECORD).map(String::as_str), Some("1")); + let twice = step(&mut player.connection, &name, enter("highposition")).await; + assert_eq!(twice.kills.get(&SKY_RECORD), None); +} diff --git a/net/src/codec/character.rs b/net/src/codec/character.rs index 75c42c45b..e95ab9941 100644 --- a/net/src/codec/character.rs +++ b/net/src/codec/character.rs @@ -38,16 +38,26 @@ pub fn encode_character_data(packet: &mut Packet, player: &Player) -> Result<(), } packet.write_short(0)?; // cooldowns - // Script markers are the server's own. - let started: Vec<_> = player + // Script markers are the server's own. The explorer medals' info + // records follow, holding how many maps were visited. + let mut started: Vec<_> = player .quests .started() .filter(|quest| quest.quest_id < SCRIPT_MARKERS) + .map(|quest| { + let kills = encode_kills(player.quests.kills(quest.quest_id)); + (quest.quest_id, kills) + }) .collect(); + for (record, count) in player.quests.explored_counts() { + if started.iter().all(|&(id, _)| id != record) { + started.push((record, count.to_string())); + } + } packet.write_short(started.len() as i16)?; - for quest in started { - packet.write_short(quest.quest_id)?; - packet.write_str_with_length(&encode_kills(player.quests.kills(quest.quest_id)))?; + for (quest_id, progress) in started { + packet.write_short(quest_id)?; + packet.write_str_with_length(&progress)?; } let completed: Vec<_> = player.quests.completed().collect(); packet.write_short(completed.len() as i16)?; diff --git a/net/src/lib.rs b/net/src/lib.rs index 601806923..4161f8cd8 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 medals; pub mod meso_explosion; pub mod mobs; pub mod monster_book; diff --git a/net/src/medals.rs b/net/src/medals.rs new file mode 100644 index 000000000..e048a3cfc --- /dev/null +++ b/net/src/medals.rs @@ -0,0 +1,173 @@ +//! The explorer medals (quests 29004 to 29011 and 29014). Entering a map of +//! a region (`explorationPoint`), or touching a `highposition` portal on one +//! of the highest places, counts the map toward that region's medal: the +//! first one starts the quest, each new one raises the count in the medal's +//! info record, and Dalair gives the medal once the count reaches what +//! `Check/1/infoex` asks. +//! +//! Choices with no source either way (v83ref lanes/QUESTIONS.md, 2026-10-22): +//! a map counts only from the quest's `lvmin`, 29012 and 29013 (which also +//! need a record nothing sets) are left out, and a forfeit keeps the maps. + +use crate::content; +use crate::error::NetworkError; +use crate::packet::build::world::quest::{ + build_info_text, build_quest_clear, build_script_progress, +}; +use crate::quests::{catalog, QuestTemplate}; +use crate::scripts::Effect; +use game::{Player, QuestState}; +use game_data::server_content::keys; +use packet::Packet; + +/// One Who's Touched the Sky: the `highposition` portals' medal. +pub const TOUCHED_THE_SKY: i16 = 29004; + +/// The medal whose region holds `map_id`. +pub fn region(map_id: i32) -> Option { + Some(match map_id { + 110000000 | 100000000..=105040299 => 29005, + 105040300..=105090900 => 29014, + 200000000..=211041800 => 29006, + 220000000..=222020000 => 29007, + 230000000..=230040401 => 29008, + 240000000..=240050000 => 29011, + 250000000..=251010500 => 29009, + 260000000..=261030000 => 29010, + _ => return None, + }) +} + +/// A visit to `map_id` toward `medal`, unless the medal is done, the player +/// is below its level, or the map already counted. +pub fn explore(medal: i16, map_id: i32, player: &Player) -> Option { + let quest = catalog().ok()?.quest(medal)?; + let explored = quest.explored?; + let level = i16::from(player.stats.level); + if player.quests.state(medal) == QuestState::Completed + || quest.start.min_level.is_some_and(|min| level < min) + || player.quests.has_explored(explored.record, map_id) + { + return None; + } + Some(Effect::Explore { medal, map_id }) +} + +/// What the player is told when `quest`'s count reaches `count`: how far +/// they are, or, at the count needed, that the title is earned. Past it +/// (Minar Forest has two maps to spare) nothing more is said. +pub fn messages(quest: &QuestTemplate, count: usize) -> Result, NetworkError> { + let Some(explored) = quest.explored else { + return Ok(Vec::new()); + }; + let needed = explored.needed as usize; + let text = |key| { + content::text().get( + key, + &[ + ("name", &quest.name), + ("count", &count.to_string()), + ("needed", &needed.to_string()), + ], + ) + }; + Ok(match count.cmp(&needed) { + std::cmp::Ordering::Less => vec![ + build_script_progress(&text(keys::MEDAL_PROGRESS))?, + build_info_text(&text(keys::MEDAL_PROGRESS_INFO))?, + ], + std::cmp::Ordering::Equal => vec![ + build_script_progress(&text(keys::MEDAL_EARNED))?, + build_quest_clear(quest.id)?, + build_info_text(&text(keys::MEDAL_EARNED_INFO))?, + ], + std::cmp::Ordering::Greater => Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use game::{QuestRecord, QuestStatus}; + use std::collections::BTreeMap; + use std::time::SystemTime; + + fn player(level: u8) -> Player { + let mut player = crate::codec::stats::sample_player(); + player.stats.level = level; + player + } + + #[test] + fn each_region_has_the_maps_its_medal_needs() { + let Ok(quests) = catalog() else { + return; + }; + let data = crate::get_game_data().unwrap(); + let mut maps: BTreeMap = BTreeMap::new(); + for map_id in data.field_ids() { + let field = data.field(map_id).unwrap(); + if field.on_user_enter.as_deref() == Some("explorationPoint") { + if let Some(medal) = region(map_id) { + *maps.entry(medal).or_default() += 1; + } + } + } + for &medal in game_data::quests::EXPLORER_QUESTS { + if medal == TOUCHED_THE_SKY { + continue; + } + let needed = quests.quest(medal).unwrap().explored.unwrap().needed; + assert!(maps[&medal] >= needed as usize, "{medal}: {maps:?}"); + } + assert_eq!(maps.values().sum::(), 92); + } + + #[test] + fn a_map_counts_once_from_the_medals_level_until_it_is_done() { + if catalog().is_err() { + return; + } + let henesys = 100000000; + // Beginner Explorer counts from level 15. + assert_eq!(explore(29005, henesys, &player(14)), None); + let mut player = player(15); + let visit = Some(Effect::Explore { + medal: 29005, + map_id: henesys, + }); + assert_eq!(explore(29005, henesys, &player), visit); + player.quests.explore(27010, henesys); + assert_eq!(explore(29005, henesys, &player), None); + assert_eq!(explore(29005, 101000000, &player).is_some(), true); + player.quests.upsert(QuestRecord { + quest_id: 29005, + status: QuestStatus::Completed { + at: SystemTime::UNIX_EPOCH, + }, + }); + assert_eq!(explore(29005, 101000000, &player), None); + // Not a medal. + assert_eq!(explore(1000, henesys, &player), None); + } + + #[test] + fn the_title_is_told_once_at_its_count() { + let Ok(quests) = catalog() else { + return; + }; + let minar = quests.quest(29011).unwrap(); + assert_eq!(messages(minar, 9).unwrap().len(), 2); + assert_eq!(messages(minar, 10).unwrap().len(), 3); + assert_eq!(messages(minar, 11).unwrap().len(), 0); + } + + #[test] + fn regions_follow_the_map_ids() { + assert_eq!(region(110000000), Some(29005)); + assert_eq!(region(104000000), Some(29005)); + assert_eq!(region(105040300), Some(29014)); + assert_eq!(region(240050000), Some(29011)); + assert_eq!(region(910310000), None); + } +} diff --git a/net/src/packet/build/world/quest.rs b/net/src/packet/build/world/quest.rs index 3cbaa42aa..ccf502989 100644 --- a/net/src/packet/build/world/quest.rs +++ b/net/src/packet/build/world/quest.rs @@ -32,6 +32,34 @@ pub fn build_quest_progress( } Ok(p) } +/// A started record with its own data string, as the explorer medals' info +/// records (27010 ...) hold their counts. +pub fn build_quest_record(quest_id: i16, data: &str) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::ShowStatusInfo as i16)?; + p.write_byte(1)?; + p.write_short(quest_id)?; + p.write_byte(QuestState::Started as u8)?; + p.write_str_with_length(data)?; + Ok(p) +} + +/// `QUEST_CLEAR`: the quest's title is earned. +pub fn build_quest_clear(quest_id: i16) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::QuestClear as i16)?; + p.write_short(quest_id)?; + Ok(p) +} + +/// `SCRIPT_PROGRESS_MESSAGE`: a floating message on screen. +pub fn build_script_progress(text: &str) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::ScriptProgressMessage as i16)?; + p.write_str_with_length(text)?; + Ok(p) +} + /// `UPDATE_QUEST_INFO` success (mode 8): the quest action at `npc_id` /// succeeded. A nonzero `next_quest` makes the client continue straight into /// that quest's conversation with the same NPC (`Act/nextQuest`). diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index b2b18ef92..4fee62643 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -26,6 +26,8 @@ pub enum SendOpcode { MemoResult = 0x29, MapTransferResult = 0x2A, SetTamingMobInfo = 0x30, + /// A quest's title is earned (`u16` quest id) (0xa26d44). + QuestClear = 0x31, SkillBookResult = 0x33, GatherItemResult = 0x34, SortItemResult = 0x35, @@ -68,6 +70,8 @@ pub enum SendOpcode { FamilyRepGain = 0x65, /// A senior or junior came online or left. FamilyNotifyLoginOrLogout = 0x66, + /// A line among the floating script messages (0xa13f20). + ScriptProgressMessage = 0x7A, MacroSysDataInit = 0x7C, SetField = 0x7D, SetCashShop = 0x7F, diff --git a/net/src/quests.rs b/net/src/quests.rs index cfdde49d0..16992667d 100644 --- a/net/src/quests.rs +++ b/net/src/quests.rs @@ -71,6 +71,8 @@ pub enum QuestRejection { Date, /// Fewer mesos than the quest takes. Mesos, + /// Fewer maps visited than an explorer medal needs. + Explored, /// The item rewards don't fit. InventoryFull, /// A one-of-a-kind reward the player already has. @@ -216,6 +218,12 @@ pub fn check_complete( { return Err(QuestRejection::Level); } + // At least the count, as v83's own check (cond 1) has it. + if let Some(explored) = quest.explored { + if (player.quests.explored(explored.record) as u64) < u64::from(explored.needed) { + return Err(QuestRejection::Explored); + } + } Ok(()) } @@ -418,6 +426,7 @@ mod tests { next_quest: None, auto_start: false, auto_complete: false, + explored: None, } } diff --git a/net/src/scripts.rs b/net/src/scripts.rs index 8f8f71867..568f6e480 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -380,6 +380,12 @@ pub enum Effect { mob_id: i32, at: (i16, i16), }, + /// Count `map_id` toward an explorer medal (`crate::medals`), starting + /// it on the first. + Explore { + medal: i16, + map_id: i32, + }, /// Take the player's party into its own copy of an event's maps /// (`events::Event::team`). Nothing is applied unless the party still /// may go; the script says its `refused` text instead. @@ -442,18 +448,28 @@ pub fn map_enter(script: &str, player: &Player) -> Option> { .map(|(_, banner)| Effect::FieldEffect(format!("temaD/enter/{banner}"))) .collect(), ), - // The explorer medals' map visits aren't in yet; Lith Harbor shows - // its banner, and the Disciple shows himself to whoever hunts him. - "explorationPoint" => Some(match map_id { - LITH_HARBOR => vec![Effect::FieldEffect(format!("maplemap/enter/{map_id}"))], - DISCIPLES_HIDEOUT if player.quests.state(DISCIPLES_QUEST) == QuestState::Started => { - vec![Effect::BringOutMob { - mob_id: DISCIPLE, - at: (-1027, 216), - }] - } - _ => Vec::new(), - }), + // The map counts toward its region's explorer medal; Lith Harbor + // also shows its banner, and the Disciple shows himself to whoever + // hunts him. + "explorationPoint" => { + let mut effects = match map_id { + LITH_HARBOR => vec![Effect::FieldEffect(format!("maplemap/enter/{map_id}"))], + DISCIPLES_HIDEOUT + if player.quests.state(DISCIPLES_QUEST) == QuestState::Started => + { + vec![Effect::BringOutMob { + mob_id: DISCIPLE, + at: (-1027, 216), + }] + } + _ => Vec::new(), + }; + effects.extend( + crate::medals::region(map_id) + .and_then(|medal| crate::medals::explore(medal, map_id, player)), + ); + Some(effects) + } // go: the map's name banner. HeavenMS also unlocks the UI // here, which HeavenClient never locks. _ => { @@ -501,6 +517,19 @@ pub fn portal(script: &str, player: &Player) -> Option { "infoWorldmap" => hint(true, 26), "infoReactor" if quest(1008) == QuestState::Completed => hint(true, 22), "infoReactor" => hint(quest(1020) == QuestState::Completed, 27), + // The highest places count toward One Who's Touched the Sky; the + // portal goes nowhere. + "highposition" => ScriptOutcome { + effects: (crate::medals::explore( + crate::medals::TOUCHED_THE_SKY, + player.stats.map_id, + player, + )) + .into_iter() + .collect(), + block: false, + occupied: None, + }, "tutoChatNPC" => ScriptOutcome { effects: vec![Effect::OfferTutorialSkip], block: true, @@ -1389,6 +1418,33 @@ mod tests { ); } + #[test] + fn exploring_counts_toward_the_regions_medal() { + let mut player = at(LITH_HARBOR, &[]); + player.stats.level = 20; + assert_eq!( + map_enter("explorationPoint", &player), + Some(vec![ + Effect::FieldEffect("maplemap/enter/104000000".into()), + Effect::Explore { + medal: 29005, + map_id: LITH_HARBOR, + }, + ]) + ); + // The highest places' portals count toward their own medal. + player.stats.map_id = 101010103; + let outcome = portal("highposition", &player).unwrap(); + assert_eq!( + outcome.effects, + [Effect::Explore { + medal: crate::medals::TOUCHED_THE_SKY, + map_id: 101010103, + }] + ); + assert!(!outcome.block); + } + #[test] fn the_time_gate_and_mushroom_castle_show_their_theme_banners() { let data = crate::get_game_data().unwrap(); diff --git a/runtime/src/actor/client/npc_script.rs b/runtime/src/actor/client/npc_script.rs index 78ac5c291..b544315db 100644 --- a/runtime/src/actor/client/npc_script.rs +++ b/runtime/src/actor/client/npc_script.rs @@ -3,6 +3,7 @@ use super::ClientActor; use crate::db::spawn_db; use crate::error::RuntimeError; use crate::message::{DirectoryMessage, FieldMessage}; +use game::QuestState; use net::conti::{self, State}; use net::dialogue::Prompt; use net::npc_scripts::{self, NpcScript, Step}; @@ -344,6 +345,31 @@ impl ClientActor { packets.push(stat::build_stat_update(false, &[update])?); } Effect::StartQuest(id) => change = Some(QuestChange::Start(template(*id)?)), + Effect::Explore { medal, map_id } => { + let quest = template(*medal)?; + let Some(explored) = quest.explored else { + continue; + }; + if !next.quests.explore(explored.record, *map_id) { + continue; + } + changed = true; + // Starting it sends the medal's record; so does each + // visit after. + match next.quests.state(*medal) { + QuestState::NotStarted => change = Some(QuestChange::Start(quest)), + _ => packets.push(net::packet::build::world::quest::build_quest_progress( + *medal, + next.quests.status(*medal), + )?), + } + let count = next.quests.explored(explored.record); + packets.push(net::packet::build::world::quest::build_quest_record( + explored.record, + &count.to_string(), + )?); + afterwards.extend(net::medals::messages(quest, count)?); + } Effect::CompleteQuest(id) => change = Some(QuestChange::Complete(template(*id)?)), Effect::Warp(map_id) => warp = Some((*map_id, None)), Effect::WarpToPortal(map_id, portal) => warp = Some((*map_id, Some(*portal))), From 91bf07c3e82e6af7a1c4c794f4098f0e74fd031f Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 01:26:21 -0400 Subject: [PATCH 2/2] Medals review: a forfeited medal starts again on a map it has already counted, only a started medal's record is sent at login, QUEST_CLEAR comes before the title line; tests for the count at completion, the highposition level and an unseen map after the medal --- agent_docs/quests.md | 15 ++++++--- game/src/quest.rs | 13 ++------ integration-harness/tests/medal_explorer.rs | 37 ++++++++++++++++----- net/src/codec/character.rs | 21 ++++++++---- net/src/medals.rs | 21 +++++++++--- net/src/quests.rs | 15 +++++++++ net/src/scripts.rs | 3 ++ runtime/src/actor/client/npc_script.rs | 30 ++++++++--------- 8 files changed, 104 insertions(+), 51 deletions(-) diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 9540f3250..2bb790f89 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -244,13 +244,18 @@ Touched the Sky (29004). A map counts once, from the medal's `lvmin`, until the quest is completed (`medals::explore`, `Effect::Explore`). The first place starts the quest. Each new place sends the info record with the count (SHOW_STATUS_INFO mode 1), a progress line (SCRIPT_PROGRESS_MESSAGE, 0x7A) -and an info text; the place that reaches the count sends the title line, -QUEST_CLEAR (0x31) and the "see Dalair" text instead. +and an info text; the place that reaches the count sends QUEST_CLEAR (0x31), +the title line and the "see Dalair" text instead. The texts are the +`medal.explorer.*` keys (placeholders until the content pack has them). +HeavenClient doesn't handle 0x7A or 0x31 yet. Places seen are `QuestLog::explored`, keyed by the info record, and are stored -in `explored_maps (character_id, record_id, map_id)`; they outlast a forfeit. -At login each record with places is sent among the started quests with its -count, so the client's quest window shows it. Ossyria Explorer (29012) and +in `explored_maps (character_id, record_id, map_id)`. They outlast a forfeit: +the next region map, even one already seen, starts the medal again with its +old count. At login a started medal's info record is sent among the started +quests with its count, so the client's quest window shows it. The level gate, +the kept places and "at least the count" have no source either way (v83ref +`lanes/QUESTIONS.md`). Ossyria Explorer (29012) and Maple Explorer (29013), which need other medals and an info record 27020 that nothing sets, are not supported. diff --git a/game/src/quest.rs b/game/src/quest.rs index e055a8937..297ba49cf 100644 --- a/game/src/quest.rs +++ b/game/src/quest.rs @@ -87,13 +87,6 @@ impl QuestLog { self.explored.get(&record).map_or(0, BTreeSet::len) } - /// Every info record with a visit and its count. - pub fn explored_counts(&self) -> impl Iterator + '_ { - (self.explored.iter()) - .filter(|(_, maps)| !maps.is_empty()) - .map(|(&record, maps)| (record, maps.len())) - } - /// Every `(info record, map)` visit. pub fn visits(&self) -> impl Iterator + '_ { (self.explored.iter()) @@ -241,10 +234,8 @@ mod tests { assert_eq!(log.explored(27010), 2); assert_eq!(log.explored(27012), 0); log.remove(29005); - assert_eq!( - log.explored_counts().collect::>(), - [(27010, 2), (27011, 1)] - ); + assert_eq!(log.explored(27010), 2); + assert_eq!(log.explored(27011), 1); assert_eq!(log.visits().count(), 3); } } diff --git a/integration-harness/tests/medal_explorer.rs b/integration-harness/tests/medal_explorer.rs index b8c88b191..b2db15a7b 100644 --- a/integration-harness/tests/medal_explorer.rs +++ b/integration-harness/tests/medal_explorer.rs @@ -5,7 +5,7 @@ use integration_harness::database::{explore_map, set_job}; use integration_harness::preconditions::load_harness_config_or_fail; -use integration_harness::quests::{act, COMPLETE}; +use integration_harness::quests::{act, build_quest_action, COMPLETE, FORFEIT}; use integration_harness::trace::{barrier, step, warp, Trace}; use integration_harness::{login_to_world_session, relogin, WorldSession}; use net::packet::op::RecvOpcode; @@ -21,6 +21,8 @@ const SKY_RECORD: i16 = 27018; const DALAIR: i32 = 9000040; const HENESYS_PARK: i32 = 100000200; const HIGH_LOOKOUT: i32 = 101010103; +/// An explorationPoint map in no medal's region. +const STRAY: i32 = 910310000; /// The 20 explorationPoint maps of Victoria Island counted toward Beginner /// Explorer. const VICTORIA: [i32; 20] = [ @@ -93,14 +95,28 @@ async fn victorias_places_earn_the_beginner_explorer_medal() { let name = player.character_name.clone(); let c = &mut player.connection; act(c, &mut early, &name, COMPLETE, BEGINNER_EXPLORER, DALAIR).await; - assert_ne!(early.states.get(&BEGINNER_EXPLORER), Some(&2)); + assert_eq!(early.states.get(&BEGINNER_EXPLORER), None); + assert!(early.items.is_empty(), "{:?}", early.items); + + // Forfeited, it keeps its maps and starts again on one already seen. + let gone = step(c, &name, build_quest_action(FORFEIT, BEGINNER_EXPLORER, 0)).await; + assert_eq!(gone.states.get(&BEGINNER_EXPLORER), Some(&0)); + let back = visit(&mut player, VICTORIA[0]).await; + assert_eq!(back.states.get(&BEGINNER_EXPLORER), Some(&1)); + assert_eq!( + back.kills.get(&BEGINNER_RECORD).map(String::as_str), + Some("2") + ); + assert!(back.script_messages.is_empty()); drop(player); tokio::time::sleep(Duration::from_millis(500)).await; - // Seen all but the last, the count comes back at login. - for map in &VICTORIA[2..19] { + // Seen all but two (and one place in no region, which counts as + // well), the count comes back at login. + for map in &VICTORIA[2..18] { explore_map(id, BEGINNER_RECORD, *map).await.unwrap(); } + explore_map(id, BEGINNER_RECORD, STRAY).await.unwrap(); let mut player = relogin(&config).await; assert_eq!(player.quests.get(&BEGINNER_EXPLORER), Some(&1)); assert_eq!( @@ -132,11 +148,14 @@ async fn victorias_places_earn_the_beginner_explorer_medal() { done.items ); - // Once it is done its maps count nothing more; the lookout's - // highposition counts toward Touched the Sky without taking the player - // anywhere. - let lookout = visit(&mut player, HIGH_LOOKOUT).await; - assert_eq!(lookout.kills.get(&BEGINNER_RECORD), None); + // Once it is done its maps count nothing more. + let unseen = visit(&mut player, VICTORIA[18]).await; + assert_eq!(unseen.kills.get(&BEGINNER_RECORD), None); + assert!(unseen.script_messages.is_empty()); + + // The lookout's highposition counts toward Touched the Sky without + // taking the player anywhere. + visit(&mut player, HIGH_LOOKOUT).await; let up = step(&mut player.connection, &name, enter("highposition")).await; assert!(up.arrivals.is_empty(), "{:?}", up.arrivals); assert_eq!(up.states.get(&TOUCHED_THE_SKY), Some(&1)); diff --git a/net/src/codec/character.rs b/net/src/codec/character.rs index e95ab9941..77f45a149 100644 --- a/net/src/codec/character.rs +++ b/net/src/codec/character.rs @@ -3,7 +3,7 @@ use super::stats::encode_character_stats; use super::time::{expiration, filetime}; use crate::error::NetworkError; use game::quest::{encode_kills, SCRIPT_MARKERS}; -use game::Player; +use game::{Player, QuestState}; use packet::{io::write::PktWrite, Packet}; /// Write the full character data block sent on world entry @@ -38,8 +38,8 @@ pub fn encode_character_data(packet: &mut Packet, player: &Player) -> Result<(), } packet.write_short(0)?; // cooldowns - // Script markers are the server's own. The explorer medals' info - // records follow, holding how many maps were visited. + // Script markers are the server's own. A started explorer medal's info + // record follows, holding how many maps were visited. let mut started: Vec<_> = player .quests .started() @@ -49,9 +49,18 @@ pub fn encode_character_data(packet: &mut Packet, player: &Player) -> Result<(), (quest.quest_id, kills) }) .collect(); - for (record, count) in player.quests.explored_counts() { - if started.iter().all(|&(id, _)| id != record) { - started.push((record, count.to_string())); + let medals = (crate::quests::catalog().ok().into_iter()) + .flat_map(|catalog| { + game_data::quests::EXPLORER_QUESTS + .iter() + .map(move |&id| catalog.quest(id)) + }) + .flatten() + .filter(|quest| player.quests.state(quest.id) == QuestState::Started); + for explored in medals.filter_map(|quest| quest.explored) { + if started.iter().all(|&(id, _)| id != explored.record) { + let count = player.quests.explored(explored.record); + started.push((explored.record, count.to_string())); } } packet.write_short(started.len() as i16)?; diff --git a/net/src/medals.rs b/net/src/medals.rs index e048a3cfc..78d9c49ac 100644 --- a/net/src/medals.rs +++ b/net/src/medals.rs @@ -39,14 +39,16 @@ pub fn region(map_id: i32) -> Option { } /// A visit to `map_id` toward `medal`, unless the medal is done, the player -/// is below its level, or the map already counted. +/// is below its level, or the map already counted. A map already counted +/// still starts a forfeited medal again, whose maps were kept. pub fn explore(medal: i16, map_id: i32, player: &Player) -> Option { let quest = catalog().ok()?.quest(medal)?; let explored = quest.explored?; let level = i16::from(player.stats.level); - if player.quests.state(medal) == QuestState::Completed + let state = player.quests.state(medal); + if state == QuestState::Completed || quest.start.min_level.is_some_and(|min| level < min) - || player.quests.has_explored(explored.record, map_id) + || (state == QuestState::Started && player.quests.has_explored(explored.record, map_id)) { return None; } @@ -77,8 +79,8 @@ pub fn messages(quest: &QuestTemplate, count: usize) -> Result, Netw build_info_text(&text(keys::MEDAL_PROGRESS_INFO))?, ], std::cmp::Ordering::Equal => vec![ - build_script_progress(&text(keys::MEDAL_EARNED))?, build_quest_clear(quest.id)?, + build_script_progress(&text(keys::MEDAL_EARNED))?, build_info_text(&text(keys::MEDAL_EARNED_INFO))?, ], std::cmp::Ordering::Greater => Vec::new(), @@ -120,6 +122,8 @@ mod tests { let needed = quests.quest(medal).unwrap().explored.unwrap().needed; assert!(maps[&medal] >= needed as usize, "{medal}: {maps:?}"); } + // 97 maps run explorationPoint; 910310000-910310004 are in no + // region. assert_eq!(maps.values().sum::(), 92); } @@ -138,8 +142,15 @@ mod tests { }); assert_eq!(explore(29005, henesys, &player), visit); player.quests.explore(27010, henesys); + // Seen, but the quest isn't started (it was forfeited): the visit + // starts it again. + assert_eq!(explore(29005, henesys, &player), visit); + player.quests.upsert(QuestRecord { + quest_id: 29005, + status: QuestStatus::Started { kills: Vec::new() }, + }); assert_eq!(explore(29005, henesys, &player), None); - assert_eq!(explore(29005, 101000000, &player).is_some(), true); + assert!(explore(29005, 101000000, &player).is_some()); player.quests.upsert(QuestRecord { quest_id: 29005, status: QuestStatus::Completed { diff --git a/net/src/quests.rs b/net/src/quests.rs index 16992667d..8807771e3 100644 --- a/net/src/quests.rs +++ b/net/src/quests.rs @@ -505,6 +505,21 @@ mod tests { assert_eq!(check_complete(&q, 2102, &p), Err(QuestRejection::Mesos)); } + #[test] + fn an_explorer_title_needs_its_places_seen() { + let mut q = quest(); + q.explored = Some(game_data::quests::Explored { + record: 27010, + needed: 2, + }); + let mut p = player(30, 0); + set(&mut p, 1033, QuestState::Started); + p.quests.explore(27010, 100000000); + assert_eq!(check_complete(&q, 2102, &p), Err(QuestRejection::Explored)); + p.quests.explore(27010, 101000000); + assert_eq!(check_complete(&q, 2102, &p), Ok(())); + } + #[test] fn complete_checks_the_end_level() { let mut q = quest(); diff --git a/net/src/scripts.rs b/net/src/scripts.rs index 568f6e480..6a39b72b7 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -1443,6 +1443,9 @@ mod tests { }] ); assert!(!outcome.block); + // Touched the Sky counts from level 20. + player.stats.level = 19; + assert!(portal("highposition", &player).unwrap().effects.is_empty()); } #[test] diff --git a/runtime/src/actor/client/npc_script.rs b/runtime/src/actor/client/npc_script.rs index b544315db..da775b703 100644 --- a/runtime/src/actor/client/npc_script.rs +++ b/runtime/src/actor/client/npc_script.rs @@ -10,7 +10,7 @@ use net::npc_scripts::{self, NpcScript, Step}; use net::packet::build::world::effect::CharEffect; use net::packet::build::world::messaging::{build_broadcast, Broadcast}; use net::packet::build::world::stat::StatUpdate; -use net::packet::build::world::{drop, effect, guild, inventory, npc, skill, stat}; +use net::packet::build::world::{drop, effect, guild, inventory, npc, quest, skill, stat}; use net::quests::{self, QuestTemplate}; use net::scripts::Effect; use net::{FieldRequest, WorldAction}; @@ -127,9 +127,7 @@ impl ClientActor { } Effect::AvatarEffect(path) => afterwards.push(effect::build_avatar_effect(path)?), Effect::Pink(text) => afterwards.push(build_broadcast(Broadcast::Pink, text)?), - Effect::InfoText(text) => { - afterwards.push(net::packet::build::world::quest::build_info_text(text)?) - } + Effect::InfoText(text) => afterwards.push(quest::build_info_text(text)?), Effect::Hint { text, width, @@ -346,29 +344,31 @@ impl ClientActor { } Effect::StartQuest(id) => change = Some(QuestChange::Start(template(*id)?)), Effect::Explore { medal, map_id } => { - let quest = template(*medal)?; - let Some(explored) = quest.explored else { + let title = template(*medal)?; + let Some(explored) = title.explored else { continue; }; - if !next.quests.explore(explored.record, *map_id) { - continue; - } - changed = true; + let new = next.quests.explore(explored.record, *map_id); // Starting it sends the medal's record; so does each - // visit after. + // visit after. A forfeited medal starts again on a map + // it has already counted. match next.quests.state(*medal) { - QuestState::NotStarted => change = Some(QuestChange::Start(quest)), - _ => packets.push(net::packet::build::world::quest::build_quest_progress( + QuestState::NotStarted => change = Some(QuestChange::Start(title)), + _ if !new => continue, + _ => packets.push(quest::build_quest_progress( *medal, next.quests.status(*medal), )?), } + changed = true; let count = next.quests.explored(explored.record); - packets.push(net::packet::build::world::quest::build_quest_record( + packets.push(quest::build_quest_record( explored.record, &count.to_string(), )?); - afterwards.extend(net::medals::messages(quest, count)?); + if new { + afterwards.extend(net::medals::messages(title, count)?); + } } Effect::CompleteQuest(id) => change = Some(QuestChange::Complete(template(*id)?)), Effect::Warp(map_id) => warp = Some((*map_id, None)),