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
3 changes: 2 additions & 1 deletion agent_docs/map-travel.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ nothing. A script returns effects that the client actor sends:
| --- | --- |
| `go<map id>` (entry) | Map name banner, FIELD_EFFECT `maplemap/enter/<map id>` |
| `TD_NC_title`, `TD_MC_title` (entry) | The Time Gate's and the Mushroom Kingdom's theme banners, FIELD_EFFECT `temaD/enter/<name>`: `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 |
Expand Down
30 changes: 30 additions & 0 deletions agent_docs/quests.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,36 @@ 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 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:
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.

## Conversations in the client

`Character/QuestDialogue.h` is a pure state machine over one side of
Expand Down
1 change: 1 addition & 0 deletions db/migrations/2026-11-08-000100_explored_maps/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE explored_maps;
8 changes: 8 additions & 0 deletions db/migrations/2026-11-08-000100_explored_maps/up.sql
Original file line number Diff line number Diff line change
@@ -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)
);
28 changes: 26 additions & 2 deletions db/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -190,6 +190,12 @@ pub fn load_player(character_id: i32) -> crate::Result<Player> {
character_quests::table
.filter(character_quests::character_id.eq(character_id))
.load::<QuestProgress>(&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(
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions db/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions game-data/placeholder_text.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions game-data/server_content.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions game-data/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,10 @@ impl GameData {
self.fields.get(&map_id)
}

pub fn field_ids(&self) -> impl Iterator<Item = i32> + '_ {
self.fields.keys().copied()
}

pub fn field_exists(&self, map_id: i32) -> bool {
self.fields.contains_key(&map_id)
}
Expand Down
55 changes: 55 additions & 0 deletions game-data/src/quests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Explored>,
}

/// 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/<n>/item` entry.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -459,6 +483,11 @@ fn template(nx: &NxDataFile, id: i16) -> Result<QuestTemplate, GameDataError> {
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 {
Expand Down Expand Up @@ -491,6 +520,32 @@ fn template(nx: &NxDataFile, id: i16) -> Result<QuestTemplate, GameDataError> {
.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<Explored, GameDataError> {
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,
})
}

Expand Down
11 changes: 11 additions & 0 deletions game-data/src/server_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions game-data/supported_quests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,15 @@
28283
28325
29003
29004
29005
29006
29007
29008
29009
29010
29011
29014
29015
29300
29301
Expand Down
53 changes: 52 additions & 1 deletion game/src/quest.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -47,15 +47,52 @@ pub struct QuestRecord {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct QuestLog {
records: BTreeMap<i16, QuestRecord>,
/// 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<i16, BTreeSet<i32>>,
}

impl QuestLog {
pub fn from_records(records: impl IntoIterator<Item = QuestRecord>) -> 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<Item = (i16, i32)>) -> 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, map)` visit.
pub fn visits(&self) -> impl Iterator<Item = (i16, i32)> + '_ {
(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);
}
Expand Down Expand Up @@ -187,4 +224,18 @@ 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(27010), 2);
assert_eq!(log.explored(27011), 1);
assert_eq!(log.visits().count(), 3);
}
}
19 changes: 19 additions & 0 deletions integration-harness/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Integer, _>(character_id)
.bind::<SmallInt, _>(record_id)
.bind::<Integer, _>(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(
Expand Down
Loading
Loading