Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion agent_docs/quests.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ itself ("Etc item inventory is full."). `startscript`/
`endscript` are allowed only for scripts the server implements
(`game_data::quests::SCRIPTS`: `q1021s`, `q1021e`, Bartos's secret
`q4647e`, Garnox's pet evolutions `q8185e`, `q8189e` and `q4659e` (see
[Pets](./pets.md)), and Meren's Maker lessons below); those steps go through
[Pets](./pets.md)), Meren's Maker lessons and Dyle's informants below); those steps go through
QUEST_ACTION 4/5 and the server drives the dialogue (see
[NPC dialogue](./npc-dialogue.md)). `money` pays (or, if negative, takes)
mesos on completion; a quest that takes mesos needs them held, or the
Expand Down Expand Up @@ -245,6 +245,21 @@ repeats its start requirements with a `skill` entry that teaches nothing,
so the server ignores it. The scripts' text is unsourced and stays
placeholder.

Dyle's informants in Kerning City (2214-2219, before JM's 2220). The Knocked
Trash Can (1052108) in the Hut in the Swamp (107000301) ends 2214 only from 5 to 8 p.m. on the
stations' wall clock (`conti::time_of_day`, as the ships keep it): its
script (`q2214e`) gives JM's list of informants (4031894) and Cosmic's
20,000 EXP; at other hours the can is empty. A player who lost the
list gets another there on 2215 (`q2215e`) for 2,000 mesos. Each informant
(Mr. Pickall 9000008, Shumi 1052102, Nella 1052103 and Jake 1052006 for
2216-2219) has a start script and no end: talking to them through
QUEST_ACTION 4 starts and completes their quest at once
(`Effect::FinishQuest`, saved by `db::quest::finish` with the start's
checks) and gives Cosmic's 7,000 EXP, and the last of the four takes the
list. `game_data::quests::FINISHED_BY_START_SCRIPT` lets these four through
without a completion NPC. The can's and the informants' lines are
unsourced and stay placeholder (`dyle.*`).

The `game-data` asset test compares the server's result with the committed
list `game-data/supported_quests.txt`. HeavenClient doesn't classify quests:
the build copies that list next to the executable, and the client offers
Expand Down
34 changes: 31 additions & 3 deletions db/src/quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ pub fn start(
prerequisites: &[(i16, QuestState)],
repeatable: bool,
) -> crate::Result<QuestProgress> {
begin(player, qid, prerequisites, repeatable, false)
}

/// Start a quest and complete it at once, as a start script that completes
/// its quest does (the informants of quests 2216-2219): the checks of
/// [`start`], then the record is saved completed.
pub fn finish(
player: &Player,
qid: i16,
prerequisites: &[(i16, QuestState)],
repeatable: bool,
) -> crate::Result<QuestProgress> {
begin(player, qid, prerequisites, repeatable, true)
}

fn begin(
player: &Player,
qid: i16,
prerequisites: &[(i16, QuestState)],
repeatable: bool,
finished: bool,
) -> crate::Result<QuestProgress> {
let (status, completed_at) = if finished {
(2, Some(SystemTime::now()))
} else {
(1, None)
};
let cid = player.id();
let mut conn = crate::connection()?;
conn.transaction(|conn| -> diesel::QueryResult<_> {
Expand Down Expand Up @@ -78,14 +105,15 @@ pub fn start(
.values((
character_quests::character_id.eq(cid),
character_quests::quest_id.eq(qid),
character_quests::status.eq(1),
character_quests::status.eq(status),
character_quests::completed_at.eq(completed_at),
character_quests::progress.eq(&progress),
))
.on_conflict((character_quests::character_id, character_quests::quest_id))
.do_update()
.set((
character_quests::status.eq(1),
character_quests::completed_at.eq(None::<SystemTime>),
character_quests::status.eq(status),
character_quests::completed_at.eq(completed_at),
character_quests::progress.eq(&progress),
))
.get_result(conn)?;
Expand Down
9 changes: 9 additions & 0 deletions game-data/placeholder_text.toml
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,15 @@
"maker.crystal_test.1" = "[placeholder] Meren is satisfied and teaches you the Maker's next level. Keep the crystal."
"maker.gold_anvil.0" = "[placeholder] Meren sees the Gold Anvil you made and can hardly believe it."
"maker.gold_anvil.1" = "[placeholder] Meren takes the Gold Anvil and teaches you the Maker's last level."
"dyle.trash_can.not_yet" = "[placeholder] You search the trash can but find nothing. Perhaps it is not yet time (5 p.m. to 8 p.m.)."
"dyle.trash_can.full" = "[placeholder] You need a free ETC slot to take the paper."
"dyle.trash_can.found" = "[placeholder] You find a crumpled piece of paper listing JM's informants."
"dyle.trash_can.short" = "[placeholder] You don't have the 2,000 mesos for the fee."
"dyle.trash_can.paid" = "[placeholder] You leave the 2,000 mesos in the trash can and take the paper."
"dyle.informant.0" = "[placeholder] Mr. Pickall says Dyle looks like a Ligator, only bigger."
"dyle.informant.1" = "[placeholder] Shumi tells you what she knows about Dyle."
"dyle.informant.2" = "[placeholder] Nella tells you what she knows about Dyle."
"dyle.informant.3" = "[placeholder] Jake says Dyle was once a Ligator that grew from Growth Powder."

"family.entitlement.0.name" = "[placeholder] Go to a relative"
"family.entitlement.0.description" = "[placeholder] Go to a family member of your choice."
Expand Down
9 changes: 9 additions & 0 deletions game-data/server_content.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,15 @@ unsourced = [
"ola.notice.8",
"jewel.notice.0",
"jewel.notice.1",
"dyle.trash_can.not_yet",
"dyle.trash_can.full",
"dyle.trash_can.found",
"dyle.trash_can.short",
"dyle.trash_can.paid",
"dyle.informant.0",
"dyle.informant.1",
"dyle.informant.2",
"dyle.informant.3",
]

[text."glTutoMsg0.message"]
Expand Down
25 changes: 22 additions & 3 deletions game-data/src/quests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ pub const INERT_END_REQUIREMENTS: &[&str] = &["start", "end"];
/// Quest scripts (`startscript`/`endscript`) the server implements. A quest
/// naming any other script is unsupported.
pub const SCRIPTS: &[&str] = &[
"q1021s", "q1021e", "q4647e", "q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e",
"q8185e", "q8189e",
"q1021s", "q1021e", "q2214e", "q2215e", "q2216s", "q2217s", "q2218s", "q2219s", "q4647e",
"q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e", "q8185e", "q8189e",
];
/// Quests with nothing to complete them by (an empty `Check/1`) whose start
/// script completes them as it starts them: JM's informants (2216-2219),
/// each heard from once (`Effect::FinishQuest`).
pub const FINISHED_BY_START_SCRIPT: &[i16] = &[2216, 2217, 2218, 2219];
/// Meren's Maker classes (quests 6030-6032) and the quest they make up
/// (6029) share 6029's quest-info string, one digit per class: `Act/0 info`
/// starts it at "000", a class starts only while its own digit is 0
Expand Down Expand Up @@ -380,6 +384,8 @@ pub struct QuestKeys {
pub item_keys: Vec<String>,
/// Keys used by any `Act/1/skill` entry.
pub skill_keys: Vec<String>,
/// Its start script completes it ([`FINISHED_BY_START_SCRIPT`]).
pub finished_by_start_script: bool,
}

/// `Ok` if every declared requirement and action is supported, otherwise the
Expand All @@ -396,7 +402,10 @@ pub fn classify(keys: &QuestKeys) -> Result<(), String> {
// Without a completion NPC, a quest that asks for items or kills is
// handed in to the NPC that gave it (Pio's 1008). Without either, it is
// a record kept by some event, not something an NPC completes.
if !end.iter().any(|k| k == "npc" || k == "item" || k == "mob") {
// Unless the start script completes it.
if !keys.finished_by_start_script
&& !end.iter().any(|k| k == "npc" || k == "item" || k == "mob")
{
return Err("no completion NPC".into());
}
unsupported(
Expand Down Expand Up @@ -501,6 +510,12 @@ impl QuestCatalog {
keys.retain(|key| key != "infoex");
}
}
keys.finished_by_start_script = FINISHED_BY_START_SCRIPT.contains(&id)
&& keys
.start_check
.iter()
.flatten()
.any(|k| k == "startscript");
if id == REQUIREMENTS_IN_START_ACTIONS {
keys.start_act.clear();
}
Expand Down Expand Up @@ -1080,6 +1095,10 @@ mod tests {
let mut no_end = keys(&["npc"], &[], &[], &[]);
no_end.end_check = None;
assert!(classify(&no_end).is_err());
let mut informant = keys(&["npc", "startscript"], &[], &[], &[]);
assert!(classify(&informant).is_err());
informant.finished_by_start_script = true;
assert_eq!(classify(&informant), Ok(()));
}

/// The supported set must match `supported_quests.txt`, which HeavenClient
Expand Down
26 changes: 26 additions & 0 deletions game-data/src/server_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,24 @@ pub mod keys {
pub const MAKER_CRYSTAL_TEST: [&str; 2] = ["maker.crystal_test.0", "maker.crystal_test.1"];
pub const MAKER_GOLD_ANVIL: [&str; 2] = ["maker.gold_anvil.0", "maker.gold_anvil.1"];

/// JM's list of informants (Kerning City, quests 2214-2219): the Knocked
/// Trash Can in the Hut in the Swamp (NPC 1052108) holds it from 5 to 8 p.m. (`q2214e`, and
/// `q2215e` for a lost list with its fee), then each informant tells
/// what they know about Dyle (`q2216s` Mr. Pickall, `q2217s` Shumi,
/// `q2218s` Nella, `q2219s` Jake). No licensed script has their text,
/// so these keep their placeholders.
pub const DYLE_TRASH_CAN_NOT_YET: &str = "dyle.trash_can.not_yet";
pub const DYLE_TRASH_CAN_FULL: &str = "dyle.trash_can.full";
pub const DYLE_TRASH_CAN_FOUND: &str = "dyle.trash_can.found";
pub const DYLE_TRASH_CAN_SHORT: &str = "dyle.trash_can.short";
pub const DYLE_TRASH_CAN_PAID: &str = "dyle.trash_can.paid";
pub const DYLE_INFORMANTS: [&str; 4] = [
"dyle.informant.0",
"dyle.informant.1",
"dyle.informant.2",
"dyle.informant.3",
];

/// Trainer Bartos's Follow the Lead (quests 4646 and 4647): the hidden
/// note of his test (NPC 9102100, `multipet_success`), then his end
/// script `q4647e`, which takes a Pet Snack.
Expand Down Expand Up @@ -2955,6 +2973,14 @@ mod tests {
.chain(&keys::MAKER_PRACTICE)
.chain(&keys::MAKER_CRYSTAL_TEST)
.chain(&keys::MAKER_GOLD_ANVIL)
.chain(&keys::DYLE_INFORMANTS)
.chain(&[
keys::DYLE_TRASH_CAN_NOT_YET,
keys::DYLE_TRASH_CAN_FULL,
keys::DYLE_TRASH_CAN_FOUND,
keys::DYLE_TRASH_CAN_SHORT,
keys::DYLE_TRASH_CAN_PAID,
])
.chain(&keys::MILK_TANGYOON)
.chain(&keys::DOJO_TAUNT)
.chain(&keys::FITNESS_NOTICE)
Expand Down
6 changes: 6 additions & 0 deletions game-data/supported_quests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
Expand Down
70 changes: 70 additions & 0 deletions integration-harness/tests/dyle_informants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! JM's informants in Kerning City (2216-2219): with his list (4031894)
//! from 2214, each informant's start script starts and completes their
//! quest at once, and the last of the four takes the list.

use integration_harness::connection::MapleTestConnection;
use integration_harness::database::{grant_stack, set_job, set_quest};
use integration_harness::hunting::walk_to;
use integration_harness::preconditions::load_harness_config_or_fail;
use integration_harness::quests::{answer, build_quest_action, SCRIPT_START};
use integration_harness::trace::{step, warp, Trace};
use integration_harness::{login_to_world_session, relogin};
use std::time::Duration;

const LIST: i32 = 4_031_894;
const ETC: u8 = 4;
/// Each informant's quest, NPC, map and place.
const INFORMANTS: [(i16, i32, i32, i16, i16); 4] = [
(2216, 9_000_008, 103_000_000, 1112, 400),
(2217, 1_052_102, 103_000_000, 445, 426),
(2218, 1_052_103, 103_000_000, 71, -54),
(2219, 1_052_006, 103_000_100, 71, 187),
];

async fn go(c: &mut MapleTestConnection, m: &str, map: i32, x: i16, y: i16) {
let mut trace = Trace::default();
assert_eq!(warp(c, map, "", &mut trace).await, map);
step(c, m, walk_to(x, y)).await;
}

#[tokio::test]
async fn each_informant_completes_their_quest_at_once() {
let config = load_harness_config_or_fail().await;
let m = config.character_name.clone();
let id = login_to_world_session(&config)
.await
.expect("first login failed")
.character_id;
tokio::time::sleep(Duration::from_millis(500)).await;
set_job(id, 400, 30, 100).await.expect("a level 30 thief");
set_quest(id, 2214, 2, "").await.expect("2214 done");
grant_stack(id, ETC as i16, 1, LIST, 1)
.await
.expect("JM's list");

let mut session = relogin(&config).await;
let c = &mut session.connection;
for (i, (quest, npc, map, x, y)) in INFORMANTS.into_iter().enumerate() {
go(c, &m, map, x, y).await;
let t = step(c, &m, build_quest_action(SCRIPT_START, quest, npc)).await;
assert_eq!(t.prompts, [(npc, 0)], "{quest} has its start script");
assert!(t.progress.is_empty(), "{quest} waits for the dialogue");
let t = step(c, &m, answer(0, 1, None)).await;
assert_eq!(t.progress, [(quest, 2)], "{quest} is done at once");
assert_eq!(t.gains, [7_000]);
if i < 3 {
assert!(t.removed.is_empty(), "{quest} leaves the list");
} else {
assert_eq!(t.removed, [(ETC, 1)], "the last informant takes it");
}
// Done once.
let t = step(c, &m, build_quest_action(SCRIPT_START, quest, npc)).await;
assert!(t.prompts.is_empty() && t.progress.is_empty());
}
drop(session);

let restored = relogin(&config).await;
for (quest, ..) in INFORMANTS {
assert_eq!(restored.quests.get(&quest), Some(&2), "quest {quest}");
}
}
Loading
Loading